diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php
index 56146fc509..95a7d5ad86 100644
--- a/addressbook/inc/class.addressbook_groupdav.inc.php
+++ b/addressbook/inc/class.addressbook_groupdav.inc.php
@@ -696,10 +696,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler
 					{
 						trim($attribute);
 						list($key, $value) = explode('=', $attribute);
+						// check if value is enclosed in quotes
+						if (in_array($value[0], ['"', "'"], true) && $value[0] === substr($value, -1))
+						{
+							$value = substr($value,1,-1);
+						}
 						switch (strtolower($key))
 						{
 							case 'charset':
-								$charset = strtoupper(substr($value,1,-1));
+								$charset = strtoupper($value);
+								break;
 						}
 					}
 				}
diff --git a/addressbook/inc/class.addressbook_hooks.inc.php b/addressbook/inc/class.addressbook_hooks.inc.php
index 9a514656a6..9a59821007 100644
--- a/addressbook/inc/class.addressbook_hooks.inc.php
+++ b/addressbook/inc/class.addressbook_hooks.inc.php
@@ -291,41 +291,8 @@ class addressbook_hooks
 
 		if ($GLOBALS['egw_info']['user']['apps']['filemanager'])
 		{
-			$settings['default_document'] = array(
-				'type'   => 'vfs_file',
-				'size'   => 60,
-				'label'  => 'Default document to insert contacts',
-				'name'   => 'default_document',
-				'help'   => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.', 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,
-			);
-			$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()),
-				'run_lang' => false,
-				'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',
-			);
+			$merge = new Api\Contacts\Merge();
+			$settings += $merge->merge_preferences();
 		}
 
 		if ($GLOBALS['egw_info']['user']['apps']['felamimail'] || $GLOBALS['egw_info']['user']['apps']['mail'])
diff --git a/admin/inc/class.admin_acl.inc.php b/admin/inc/class.admin_acl.inc.php
index ba1652f06f..b66175563b 100644
--- a/admin/inc/class.admin_acl.inc.php
+++ b/admin/inc/class.admin_acl.inc.php
@@ -273,7 +273,7 @@ class admin_acl
 			{
 				$rows['sel_options']['filter2'][] = array(
 					'value' => $appname,
-					'label' => lang(Api\Link::get_registry($appname, 'entries')) ?? lang($appname)
+					'label' => lang(Api\Link::get_registry($appname, 'entries') ?: $appname)
 				);
 			}
 			usort($rows['sel_options']['filter2'], function($a,$b) {
diff --git a/admin/inc/class.admin_cmd_category.inc.php b/admin/inc/class.admin_cmd_category.inc.php
index 7059cde539..a5b88fb0e8 100644
--- a/admin/inc/class.admin_cmd_category.inc.php
+++ b/admin/inc/class.admin_cmd_category.inc.php
@@ -87,7 +87,7 @@ class admin_cmd_category extends admin_cmd
 		unset($set['old_parent'], $set['base_url'], $set['last_mod'], $set['all_cats'], $set['no_private']);
 		foreach($set as $key => $value)
 		{
-			if(array_key_exists($key, $old) && $old[$key] == $value)
+			if ($old && array_key_exists($key, $old) && $old[$key] == $value)
 			{
 				unset($set[$key]);
 				unset($old[$key]);
diff --git a/admin/inc/class.admin_customfields.inc.php b/admin/inc/class.admin_customfields.inc.php
index c285bbf1d5..dc86bd89de 100644
--- a/admin/inc/class.admin_customfields.inc.php
+++ b/admin/inc/class.admin_customfields.inc.php
@@ -128,10 +128,10 @@ class admin_customfields
 	public function index($content = array())
 	{
 		// determine appname
-		$this->appname = $this->appname ? $this->appname : ($_GET['appname'] ? $_GET['appname'] : ($content['appname'] ? $content['appname'] : false));
+		$this->appname = $this->appname ?: (!empty($_GET['appname']) ? $_GET['appname'] : (!empty($content['appname']) ? $content['appname'] : false));
 		if(!$this->appname) die(lang('Error! No appname found'));
 
-		$this->use_private = !isset($_GET['use_private']) || (boolean)$_GET['use_private'] || $content['use_private'];
+		$this->use_private = !empty($_GET['use_private']) && $_GET['use_private'] !== 'undefined' || !empty($content['use_private']);
 
 		// Read fields, constructor doesn't always know appname
 		$this->fields = Api\Storage\Customfields::get($this->appname,true);
@@ -323,10 +323,10 @@ class admin_customfields
 	 */
 	function edit($content = null)
 	{
-		$cf_id = $_GET['cf_id'] ? (int)$_GET['cf_id'] : (int)$content['cf_id'];
+		$cf_id = isset($_GET['cf_id']) ? (int)$_GET['cf_id'] : (int)$content['cf_id'];
 
 		// determine appname
-		$this->appname = $this->appname ? $this->appname : ($_GET['appname'] ? $_GET['appname'] : ($content['cf_app'] ? $content['cf_app'] : false));
+		$this->appname = $this->appname ?: (isset($_GET['appname']) ? $_GET['appname'] : (!empty($content['cf_app']) ? $content['cf_app'] : false));
 		if(!$this->appname)
 		{
 			if($cf_id && $this->so)
@@ -339,7 +339,7 @@ class admin_customfields
 		{
 			die(lang('Error! No appname found'));
 		}
-		$this->use_private = !isset($_GET['use_private']) || (boolean)$_GET['use_private'] || $content['use_private'];
+		$this->use_private = !isset($_GET['use_private']) || (boolean)$_GET['use_private'] || !empty($content['use_private']);
 
 		// Read fields, constructor doesn't always know appname
 		$this->fields = Api\Storage\Customfields::get($this->appname,true);
@@ -347,7 +347,7 @@ class admin_customfields
 		// Update based on info returned from template
 		if (is_array($content))
 		{
-			$action = @key($content['button']);
+			$action = key($content['button'] ?? []);
 			switch($action)
 			{
 				case 'delete':
@@ -422,7 +422,7 @@ class admin_customfields
 		}
 		else
 		{
-			$content['use_private'] = !isset($_GET['use_private']) || (boolean)$_GET['use_private'];
+			$content['use_private'] = !empty($_GET['use_private']) && $_GET['use_private'] !== 'undefined';
 		}
 
 
@@ -474,11 +474,11 @@ class admin_customfields
 		// Show sub-type row, and get types
 		if($this->manage_content_types)
 		{
-			if(count($this->content_types) == 0)
+			if(empty($this->content_types))
 			{
 				$this->content_types = Api\Config::get_content_types($this->appname);
 			}
-			if (count($this->content_types)==0)
+			if (empty($this->content_types))
 			{
 				// if you define your default types of your app with the search_link hook, they are available here, if no types were found
 				$this->content_types = (array)Api\Link::get_registry($this->appname, 'default_types');
@@ -592,7 +592,7 @@ class admin_customfields
 	*/
 	function create_field(&$content)
 	{
-		$new_name = trim($content['fields'][count($content['fields'])-1]['name']);
+		$new_name = trim($content['fields'][count((array)$content['fields'])-1]['name']);
 		if (empty($new_name) || isset($this->fields[$new_name]))
 		{
 			$content['error_msg'] .= empty($new_name) ?
@@ -601,7 +601,7 @@ class admin_customfields
 		}
 		else
 		{
-			$this->fields[$new_name] = $content['fields'][count($content['fields'])-1];
+			$this->fields[$new_name] = $content['fields'][count((array)$content['fields'])-1];
 			if(!$this->fields[$new_name]['label']) $this->fields[$new_name]['label'] = $this->fields[$new_name]['name'];
 			$this->save_repository();
 		}
diff --git a/admin/inc/class.admin_mail.inc.php b/admin/inc/class.admin_mail.inc.php
index f8539e40f5..db74bc11db 100644
--- a/admin/inc/class.admin_mail.inc.php
+++ b/admin/inc/class.admin_mail.inc.php
@@ -1258,7 +1258,7 @@ class admin_mail
 			if ($content['ident_id'] != $content['old_ident_id'] &&
 				($content['old_ident_id'] || $content['ident_id'] != $content['std_ident_id']))
 			{
-				if ($content['ident_id'] > 0)
+				if ((int)$content['ident_id'] > 0)
 				{
 					$identity = Mail\Account::read_identity($content['ident_id'], false, $content['called_for']);
 					unset($identity['account_id']);
@@ -1285,7 +1285,7 @@ class admin_mail
 		{
 			$sel_options['ident_email_alias'] = array_merge(
 				array('' => $content['mailLocalAddress'].' ('.lang('Default').')'),
-				array_combine($content['mailAlternateAddress'], $content['mailAlternateAddress']));
+				array_combine($content['mailAlternateAddress'] ?? [], $content['mailAlternateAddress'] ?? []));
 			// if admin explicitly set a non-alias, we need to add it to aliases to keep it after storing signature by user
 			if ($content['ident_email'] !== $content['mailLocalAddress'] && !isset($sel_options['ident_email_alias'][$content['ident_email']]))
 			{
diff --git a/admin/templates/default/categories.edit.xet b/admin/templates/default/categories.edit.xet
index a1ed1c4538..96120b6b2d 100644
--- a/admin/templates/default/categories.edit.xet
+++ b/admin/templates/default/categories.edit.xet
@@ -44,7 +44,7 @@
 				</row>
 				<row>
 					<description value="Category owner" for="owner"/>
-					<taglist statustext="Limit global category to members of a certain group" id="owner" needed="1" height="190" class="et2_fullWidth" rows="4" />
+					<taglist-account statustext="Limit global category to members of a certain group" id="owner" needed="1" class="et2_fullWidth" />
 				</row>
 
 			</rows>
diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts
index bdbef7b34a..e82b54b1c1 100644
--- a/api/js/etemplate/et2_widget_placeholder.ts
+++ b/api/js/etemplate/et2_widget_placeholder.ts
@@ -227,8 +227,9 @@ export class et2_placeholder_select extends et2_inputWidget
 		app.onchange = (node, widget) =>
 		{
 			preview.set_value("");
-			if(['user'].indexOf(widget.get_value()) >= 0)
+			if(['user', 'filemanager'].indexOf(widget.get_value()) >= 0)
 			{
+				// These ones don't let you select an entry for preview (they don't work)
 				entry.set_disabled(true);
 				entry.app_select.val('user');
 				entry.set_value({app: 'user', id: '', query: ''});
@@ -338,7 +339,7 @@ export class et2_placeholder_select extends et2_inputWidget
 						{
 							continue;
 						}
-						options[key].push({
+						options[this.egw().lang(key)].push({
 							value: key + '-' + sub,
 							label: this.egw().lang(sub)
 						});
diff --git a/api/js/etemplate/et2_widget_toolbar.ts b/api/js/etemplate/et2_widget_toolbar.ts
index e0cedc3031..dd1dec1dc5 100644
--- a/api/js/etemplate/et2_widget_toolbar.ts
+++ b/api/js/etemplate/et2_widget_toolbar.ts
@@ -50,6 +50,22 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 			"type": "string",
 			"default": "more",
 			"description": "Define a style for list header (more ...), which can get short 3dots with no caption or bigger button with caption more ..."
+		},
+		"preference_id": {
+			"name": "Preference id",
+			"type": "string",
+			"default": false,
+			"description": "Define a custom preference id for saving the toolbar preferences." +
+				           "This is useful when you have the same toolbar and you use it in a pop up but also in a tab, which have different dom ids" +
+				           "When not set it defaults to the dom id of the form."
+		},
+		"preference_app": {
+			"name": "Preference application",
+			"type": "string",
+			"default": false,
+			"description": 	"Define a custom preference application for saving the toolbar preferences." +
+							"This is useful when you have the same toolbar and you use it in a pop up but also in a tab, wich have different application names" +
+							"When not set it defaults to the result of this.egw().app_name();"
 		}
 	};
 
@@ -94,6 +110,13 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 		// Set proper id and dom_id for the widget
 		this.set_id(this.id);
 
+		if(!this.options.preference_id){
+			this.options.preference_id = this.dom_id;
+		}
+
+		if(!this.options.preference_app){
+			this.options.preference_app = this.egw().app_name();
+		}
 
 		this.actionbox = jQuery(document.createElement('div'))
 			.addClass("et2_toolbar_more")
@@ -232,7 +255,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 		{
 			this.actionbox.find('.toolbar-admin-pref').click(function(e){
 				e.stopImmediatePropagation();
-				egw.json('EGroupware\\Api\\Etemplate\\Widget\\Toolbar::ajax_get_default_prefs', [egw.app_name(), that.dom_id], function(_prefs){
+				egw.json('EGroupware\\Api\\Etemplate\\Widget\\Toolbar::ajax_get_default_prefs', [that.options.preference_app, that.options.preference_id], function(_prefs){
 					let prefs = [];
 					for (let p in _prefs)
 					{
@@ -242,7 +265,8 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 				}).sendRequest(true);
 			});
 		}
-		let pref = (!egwIsMobile())? egw.preference(this.dom_id, this.egw().app_name()): undefined;
+
+		let pref = (!egwIsMobile())? egw.preference(this.options.preference_id, this.options.preference_app): undefined;
 		if (pref && !jQuery.isArray(pref)) this.preference = pref;
 
 		//Set the default actions for the first time
@@ -461,7 +485,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 				if (that.actionlist.find(".ui-draggable").length == 0)
 				{
 					that.preference = {};
-					egw.set_preference(that.egw().app_name(),that.dom_id,that.preference);
+					egw.set_preference(that.options.preference_app,that.options.preference_id,that.preference);
 				}
 			},
 			tolerance:"touch"
@@ -525,7 +549,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 	{
 		this.preference[_action] = _state;
 		if (egwIsMobile()) return;
-		egw.set_preference(this.egw().app_name(),this.dom_id,this.preference);
+		egw.set_preference(this.options.preference_app,this.options.preference_id,this.preference);
 	}
 
 	/**
@@ -537,7 +561,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 	{
 		let button_options = {
 		};
-		let button  = jQuery(document.createElement('button'))
+		let button = jQuery(document.createElement('button'))
 			.addClass("et2_button et2_button_text et2_button_with_image")
 			.attr('id', this.id+'-'+action.id)
 			.attr('type', 'button')
@@ -773,7 +797,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 						id:child,
 						value: child,
 						label: _actions[key]['children'][child]['caption'],
-						app: egw.app_name(),
+						app: self.options.preference_app,
 						icon: _actions[key]['children'][child]['iconUrl']
 					});
 				}
@@ -784,7 +808,7 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 					id:key,
 					value: key,
 					label: _actions[key]['caption'],
-					app: egw.app_name(),
+					app: self.options.preference_app,
 					icon: _actions[key]['iconUrl']
 				});
 			}
@@ -808,12 +832,12 @@ export class et2_toolbar extends et2_DOMWidget implements et2_IInput
 							_value.actions = pref;
 						}
 						egw.json('EGroupware\\Api\\Etemplate\\Widget\\Toolbar::ajax_setAdminSettings',
-							[_value, self.dom_id, egw.app_name()],function(_result){
+							[_value, self.options.preference_id, self.options.preference_app],function(_result){
 								egw.message(_result);
 							}).sendRequest(true);
 					}
 				},
-				title: egw.lang('admin settings for %1', this.dom_id),
+				title: egw.lang('admin settings for %1', this.options.preference_id),
 				buttons: buttons,
 				minWidth: 600,
 				minHeight: 300,
diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts
index a4e5a459b7..eadedb8631 100644
--- a/api/js/jsapi/egw_app.ts
+++ b/api/js/jsapi/egw_app.ts
@@ -744,20 +744,24 @@ export abstract class EgwApp
 		// Find what we need
 		let nm = null;
 		let action = _action;
-		let as_pdf = false;
+		let as_pdf = null;
 
 		// Find Select all
-		while(nm == null && action != null)
+		while(nm == null && action.parent != null)
 		{
 			if(action.data != null && action.data.nextmatch)
 			{
 				nm = action.data.nextmatch;
 			}
+			if(as_pdf === null && action.getActionById('as_pdf') !== null)
+			{
+				as_pdf = action.getActionById('as_pdf').checked;
+			}
 			action = action.parent;
 		}
 		let all = nm?.getSelection().all || false;
 
-		as_pdf = action.getActionById('as_pdf')?.checked || false;
+		as_pdf = as_pdf || false;
 
 		// Get list of entry IDs
 		let ids = [];
diff --git a/api/lang/egw_de.lang b/api/lang/egw_de.lang
index ad9510f170..ae3ec4d874 100644
--- a/api/lang/egw_de.lang
+++ b/api/lang/egw_de.lang
@@ -274,6 +274,7 @@ choose a background style.	common	de	Wählen Sie einen Hintergrundstil.
 choose a text color for the icons	common	de	Wählen Sie eine Textfarbe für die Symbole
 choose file...	common	de	Dateien wählen...
 choose the category	common	de	Kategorie auswählen
+choose the default filename for merged documents.	preferences	de	Wählen Sie den Standard-Dateinamen für zusammengeführte Platzhalter-Dokumente.
 choose the parent category	common	de	Wählen der übergeordneten Kategorie
 choose time	common	de	Uhrzeit auswählen
 chosen parent category no longer exists	common	de	Die ausgewählte Elternkategorie existiert nicht (mehr).
@@ -378,6 +379,7 @@ december	common	de	Dezember
 deck	common	de	Deck (intern)
 default	common	de	Vorgabe
 default category	common	de	Standard-Kategorie
+default document to insert entries	preferences	de	Standarddokument für Einfügen in Dokument
 default height for the windows	common	de	Vorgabewert für Höhe des Fensters
 default visible actions	common	de	standardmäßig sichtbare Aktionen
 default width for the windows	common	de	Vorgabewert für Breite des Fensters
@@ -417,6 +419,8 @@ diable the execution a bugfixscript for internet explorer 5.5 and higher to show
 direction left to right	common	de	Richtung von links nach rechts
 directory	common	de	Verzeichnis
 directory does not exist, is not readable by the webserver or is not relative to the document root!	common	de	Verzeichnis existiert nicht, ist nicht vom Webserver lesbar oder ist nicht entsprechend zur Dokumentroot!
+directory for storing merged documents	preferences	de	Verzeichnis für zusammengeführte Platzhalter-Dokumente
+directory with documents to insert entries	preferences	de	Vorlagen-Verzeichnis für Einfügen in Dokument
 disable internet explorer png-image-bugfix	common	de	Internet Explorer PNG-Bilder-Bugfix abschalten
 disable slider effects	common	de	Schwebeeffekte des Navigationsmenüs abschalten
 disable the animated slider effects when showing or hiding menus in the page? opera and konqueror users will probably must want this.	common	de	Die animierten Schwebeeffekte beim Anzeigen oder Verstecken des Navigationsmenüs in der Seite abschalten? Benutzer von Opera oder Konquerer müssen diese Funktion abschalten.
@@ -1500,6 +1504,7 @@ western sahara	common	de	WEST SAHARA
 what color should all the blank space on the desktop have	common	de	Welche Farbe soll der freie Platz auf der Arbeitsfläche haben
 what happens with overflowing content: visible (default), hidden, scroll, auto (browser decides)	common	de	was passiert mit überbreitem Inhalt: sichtbar (standard), versteckt, rollend, automatisch (der Browser entscheidet)
 what style would you like the image to have?	common	de	Welchen Stil soll das Bild haben?
+when you merge entries into documents, they will be stored here. If no directory is provided, they will be stored in your home directory (%1)	preferences	de	Wenn Sie Einträge mit Platzhalter-Dokumenten zusammenführen, werden diese hier gespeichert. Wenn Sie kein Verzeichnis angeben, werden diese in Ihrem Homeverzeichnis gespeichert (%1)
 when you say yes the home and logout buttons are presented as applications in the main top applcation bar.	common	de	Wenn Sie dies aktivieren,  werden die Start und Abmelde Symbole als Anwendungen im oberen Anwendungsbalken angezeigt.
 where and how will the egroupware links like preferences, about and logout be displayed.	common	de	Wo und wie werden die EGroupware Verknüpfungen wie Einstellungen, Über ..., und Abmelden angezeigt.
 which groups	common	de	Welche Gruppen
diff --git a/api/lang/egw_en.lang b/api/lang/egw_en.lang
index 09abb18ec0..722e90c99b 100644
--- a/api/lang/egw_en.lang
+++ b/api/lang/egw_en.lang
@@ -274,6 +274,7 @@ choose a background style.	common	en	Choose a background style
 choose a text color for the icons	common	en	Choose a text color for the icons
 choose file...	common	en	Choose file...
 choose the category	common	en	Choose the category
+choose the default filename for merged documents.	preferences	en	Choose the default filename for merged documents.
 choose the parent category	common	en	Choose the parent category
 choose time	common	en	Choose Time
 chosen parent category no longer exists	common	en	Chosen parent category no longer exists
@@ -378,6 +379,7 @@ december	common	en	December
 deck	common	en	Deck (internal)
 default	common	en	Default
 default category	common	en	Default category
+default document to insert entries	preferences	en	Default document to insert entries
 default height for the windows	common	en	Default height for the windows
 default visible actions	common	en	Default visible actions
 default width for the windows	common	en	Default width for the windows
@@ -417,6 +419,8 @@ diable the execution a bugfixscript for internet explorer 5.5 and higher to show
 direction left to right	common	en	Direction left to right
 directory	common	en	Directory
 directory does not exist, is not readable by the webserver or is not relative to the document root!	common	en	Directory does not exist, is not readable by the web server or is not relative to the document root!
+directory for storing merged documents	preferences	en	Directory for storing merged documents
+directory with documents to insert entries	preferences	en	Directory with documents to insert entries
 disable internet explorer png-image-bugfix	common	en	Disable Internet Explorer png image bugfix
 disable slider effects	common	en	Disable slider effects
 disable the animated slider effects when showing or hiding menus in the page? opera and konqueror users will probably must want this.	common	en	Disable the animated slider effects when showing or hiding menus in the page.
@@ -857,6 +861,7 @@ maybe	common	en	Maybe
 mayotte	common	en	MAYOTTE
 medium	common	en	Medium
 menu	common	en	Menu
+merged document filename	preferences	en	Merged document filename
 message	common	en	Message
 message ...	common	en	Message ...
 message prepared for sending.	common	en	Message prepared for sending.
@@ -1501,6 +1506,7 @@ western sahara	common	en	WESTERN SAHARA
 what color should all the blank space on the desktop have	common	en	What color should all the blank space on the desktop have?
 what happens with overflowing content: visible (default), hidden, scroll, auto (browser decides)	common	en	What happens with overflowing content: visible (default), hidden, scroll, auto (browser decides)
 what style would you like the image to have?	common	en	Image style
+when you merge entries into documents, they will be stored here. If no directory is provided, they will be stored in your home directory (%1)	preferences	en	When you merge entries into documents, they will be stored here.  If no directory is provided, they will be stored in your home directory (%1)
 when you say yes the home and logout buttons are presented as applications in the main top applcation bar.	common	en	If you say yes, the Home and Log out buttons are presented as applications in the main top application bar.
 where and how will the egroupware links like preferences, about and logout be displayed.	common	en	Where and how will the EGroupware links like Preferences, About and Log out be displayed.
 which groups	common	en	Which groups
diff --git a/api/src/Accounts.php b/api/src/Accounts.php
index 889f6c1907..69c9def084 100644
--- a/api/src/Accounts.php
+++ b/api/src/Accounts.php
@@ -468,7 +468,7 @@ class Accounts
 		$data = self::cache_read($id);
 
 		// add default description for Admins and Default group
-		if ($data['account_type'] === 'g')
+		if ($data && $data['account_type'] === 'g')
 		{
 			self::add_default_group_description($data);
 		}
@@ -595,11 +595,15 @@ class Accounts
 	/**
 	 * Return formatted username for a given account_id
 	 *
-	 * @param int $account_id account id
+	 * @param ?int $account_id account id, default current user
 	 * @return string full name of user or "#$account_id" if user not found
 	 */
-	static function username(int $account_id)
+	static function username(int $account_id=null)
 	{
+		if (empty($account_id))
+		{
+			$account_id = $GLOBALS['egw_info']['user']['account_id'];
+		}
 		if (!($account = self::cache_read($account_id)))
 		{
 			return '#'.$account_id;
@@ -985,7 +989,7 @@ class Accounts
 			$ret = $just_id && $data['memberships'] ? array_keys($data['memberships']) : $data['memberships'];
 		}
 		//error_log(__METHOD__."($account_id, $just_id) data=".array2string($data)." returning ".array2string($ret));
-		return $ret;
+		return $ret ?? [];
 	}
 
 	/**
diff --git a/api/src/Auth.php b/api/src/Auth.php
index 5fdfa1c5a6..74a086de23 100644
--- a/api/src/Auth.php
+++ b/api/src/Auth.php
@@ -15,8 +15,7 @@
 namespace EGroupware\Api;
 
 // allow to set an application depending authentication type (eg. for syncml, groupdav, ...)
-if (isset($GLOBALS['egw_info']['server']['auth_type_'.$GLOBALS['egw_info']['flags']['currentapp']]) &&
-	$GLOBALS['egw_info']['server']['auth_type_'.$GLOBALS['egw_info']['flags']['currentapp']])
+if (!empty($GLOBALS['egw_info']['server']['auth_type_'.$GLOBALS['egw_info']['flags']['currentapp']]))
 {
 	$GLOBALS['egw_info']['server']['auth_type'] = $GLOBALS['egw_info']['server']['auth_type_'.$GLOBALS['egw_info']['flags']['currentapp']];
 }
@@ -223,11 +222,11 @@ class Auth
 		{
 			return true;
 		}
-		if (is_null($passwordAgeBorder) && $GLOBALS['egw_info']['server']['change_pwd_every_x_days'])
+		if (is_null($passwordAgeBorder) && !empty($GLOBALS['egw_info']['server']['change_pwd_every_x_days']))
 		{
 			$passwordAgeBorder = (DateTime::to('now','ts')-($GLOBALS['egw_info']['server']['change_pwd_every_x_days']*86400));
 		}
-		if (is_null($daysLeftUntilChangeReq) && $GLOBALS['egw_info']['server']['warn_about_upcoming_pwd_change'])
+		if (is_null($daysLeftUntilChangeReq) && !empty($GLOBALS['egw_info']['server']['warn_about_upcoming_pwd_change']))
 		{
 			// maxage - passwordage = days left until change is required
 			$daysLeftUntilChangeReq = ($GLOBALS['egw_info']['server']['change_pwd_every_x_days'] - ((DateTime::to('now','ts')-($alpwchange_val?$alpwchange_val:0))/86400));
@@ -235,9 +234,9 @@ class Auth
 		if ($alpwchange_val == 0 ||	// admin requested password change
 			$passwordAgeBorder > $alpwchange_val ||	// change password every N days policy requests change
 			// user should be warned N days in advance about change and is not yet
-			$GLOBALS['egw_info']['server']['change_pwd_every_x_days'] &&
-			$GLOBALS['egw_info']['user']['apps']['preferences'] &&
-			$GLOBALS['egw_info']['server']['warn_about_upcoming_pwd_change'] &&
+			!empty($GLOBALS['egw_info']['server']['change_pwd_every_x_days']) &&
+			!empty($GLOBALS['egw_info']['user']['apps']['preferences']) &&
+			!empty($GLOBALS['egw_info']['server']['warn_about_upcoming_pwd_change']) &&
 			$GLOBALS['egw_info']['server']['warn_about_upcoming_pwd_change'] > $daysLeftUntilChangeReq &&
 			$UserKnowsAboutPwdChange !== true)
 		{
@@ -255,8 +254,8 @@ class Auth
 			else
 			{
 				// login page does not inform user about passwords about to expire
-				if ($GLOBALS['egw_info']['flags']['currentapp'] != 'login' &&
-					($GLOBALS['egw_info']['flags']['currentapp'] != 'home' ||
+				if ($GLOBALS['egw_info']['flags']['currentapp'] !== 'login' &&
+					($GLOBALS['egw_info']['flags']['currentapp'] !== 'home' ||
 						strpos($_SERVER['SCRIPT_NAME'], '/home/') !== false))
 				{
 					$UserKnowsAboutPwdChange = true;
diff --git a/api/src/Categories.php b/api/src/Categories.php
index 9cbbb46e24..c0d4ea494d 100644
--- a/api/src/Categories.php
+++ b/api/src/Categories.php
@@ -853,7 +853,7 @@ class Categories
 
 		if (is_null(self::$cache)) self::init_cache();
 
-		$cat = self::$cache[$cat_id];
+		$cat = self::$cache[$cat_id] ?? null;
 		if ($item == 'path')
 		{
 			if ($cat['parent'])
@@ -864,7 +864,7 @@ class Categories
 		}
 		if ($item == 'data')
 		{
-			return $cat['data'] ? json_php_unserialize($cat['data'], true) : array();
+			return !empty($cat['data']) ? json_php_unserialize($cat['data'], true) : array();
 		}
 		elseif ($cat[$item])
 		{
diff --git a/api/src/Config.php b/api/src/Config.php
index bc90e47eb6..cfe545e13c 100755
--- a/api/src/Config.php
+++ b/api/src/Config.php
@@ -210,7 +210,7 @@ class Config
 		{
 			self::init_static();
 		}
-		return (array)self::$configs[$app];
+		return self::$configs[$app] ?? [];
 	}
 
 	/**
@@ -238,7 +238,7 @@ class Config
 	{
 		$config = self::read($app);
 
-		return is_array($config['types']) ? $config['types'] : array();
+		return !empty($config['types']) && is_array($config['types']) ? $config['types'] : [];
 	}
 
 	/**
diff --git a/api/src/Contacts.php b/api/src/Contacts.php
index 2409357a24..d3a5a1b2b1 100755
--- a/api/src/Contacts.php
+++ b/api/src/Contacts.php
@@ -202,9 +202,9 @@ class Contacts extends Contacts\Storage
 			$this->prefs['hide_accounts'] = '0';
 		}
 		// get the default addressbook from the users prefs
-		$this->default_addressbook = $GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] ?
+		$this->default_addressbook = !empty($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default']) ?
 			(int)$GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] : $this->user;
-		$this->default_private = substr($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'],-1) == 'p';
+		$this->default_private = substr($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] ?? '',-1) == 'p';
 		if ($this->default_addressbook > 0 && $this->default_addressbook != $this->user &&
 			($this->default_private ||
 			$this->default_addressbook == (int)$GLOBALS['egw']->preferences->forced['addressbook']['add_default'] ||
@@ -312,14 +312,14 @@ class Contacts extends Contacts\Storage
 			'adr_two_countryname'  => lang('country').' ('.lang('business').')',
 		);
 		//_debug_array($this->contact_fields);
-		$this->own_account_acl = $GLOBALS['egw_info']['server']['own_account_acl'];
+		$this->own_account_acl = $GLOBALS['egw_info']['server']['own_account_acl'] ?? null;
 		if (!is_array($this->own_account_acl)) $this->own_account_acl = json_php_unserialize($this->own_account_acl, true);
 		// we have only one acl (n_fn) for the whole name, as not all backends store every part in an own field
 		if ($this->own_account_acl && in_array('n_fn',$this->own_account_acl))
 		{
 			$this->own_account_acl = array_merge($this->own_account_acl,array('n_prefix','n_given','n_middle','n_family','n_suffix'));
 		}
-		if ($GLOBALS['egw_info']['server']['org_fileds_to_update'])
+		if (!empty($GLOBALS['egw_info']['server']['org_fileds_to_update']))
 		{
 			$this->org_fields =  $GLOBALS['egw_info']['server']['org_fileds_to_update'];
 			if (!is_array($this->org_fields)) $this->org_fields = unserialize($this->org_fields);
@@ -337,7 +337,7 @@ class Contacts extends Contacts\Storage
 		}
 		$this->categories = new Categories($this->user,'addressbook');
 
-		$this->delete_history = $GLOBALS['egw_info']['server']['history'];
+		$this->delete_history = $GLOBALS['egw_info']['server']['history'] ?? null;
 	}
 
 	/**
diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php
index 4dc6d5a47c..169e233abc 100644
--- a/api/src/Contacts/Merge.php
+++ b/api/src/Contacts/Merge.php
@@ -155,119 +155,6 @@ class Merge extends Api\Storage\Merge
 		return $replacements;
 	}
 
-	/**
-	 * Generate table with replacements for the preferences
-	 *
-	 */
-	public function show_replacements()
-	{
-		$GLOBALS['egw_info']['flags']['app_header'] = lang('Addressbook').' - '.lang('Replacements for inserting contacts into documents');
-		$GLOBALS['egw_info']['flags']['nonavbar'] = (bool)$_GET['nonavbar'];
-
-		ob_start();
-		echo "<table width='90%' align='center'>\n";
-		echo '<tr><td colspan="4"><h3>'.lang('Contact fields:')."</h3></td></tr>";
-
-		$n = 0;
-		foreach($this->contacts->contact_fields as $name => $label)
-		{
-			if (in_array($name,array('tid','label','geo'))) continue;	// dont show them, as they are not used in the UI atm.
-
-			if (in_array($name,array('email','org_name','tel_work','url')) && $n&1)		// main values, which should be in the first column
-			{
-				echo "</tr>\n";
-				$n++;
-			}
-			if (!($n&1)) echo '<tr>';
-			echo '<td>{{'.$name.'}}</td><td>'.$label.'</td>';
-			if($name == 'cat_id')
-			{
-				if ($n&1) echo "</tr>\n";
-				echo '<td>{{categories}}</td><td>'.lang('Category path').'</td>';
-				$n++;
-			}
-			if ($n&1) echo "</tr>\n";
-			$n++;
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
-		foreach($this->contacts->customfields as $name => $field)
-		{
-			echo '<tr><td>{{#'.$name.'}}</td><td colspan="3">'.$field['label']."</td></tr>\n";
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('General fields:')."</h3></td></tr>";
-		foreach(array(
-			'link' => lang('HTML link to the current record'),
-			'links' => lang('Titles of any entries linked to the current record, excluding attached files'),
- 			'attachments' => lang('List of files linked to the current record'),
-			'links_attachments' => lang('Links and attached files'),
-			'links/[appname]' => lang('Links to specified application.  Example: {{links/infolog}}'),
-			'date' => lang('Date'),
-			'user/n_fn' => lang('Name of current user, all other contact fields are valid too'),
-			'user/account_lid' => lang('Username'),
-			'pagerepeat' => lang('For serial letter use this tag. Put the content, you want to repeat between two Tags.'),
-			'label' => lang('Use this tag for addresslabels. Put the content, you want to repeat, between two tags.'),
-			'labelplacement' => lang('Tag to mark positions for address labels'),
-			'IF fieldname' => lang('Example {{IF n_prefix~Mr~Hello Mr.~Hello Ms.}} - search the field "n_prefix", for "Mr", if found, write Hello Mr., else write Hello Ms.'),
-			'NELF' => lang('Example {{NELF role}} - if field role is not empty, you will get a new line with the value of field role'),
-			'NENVLF' => lang('Example {{NENVLF role}} - if field role is not empty, set a LF without any value of the field'),
-			'LETTERPREFIX' => lang('Example {{LETTERPREFIX}} - Gives a letter prefix without double spaces, if the title is empty for example'),
-			'LETTERPREFIXCUSTOM' => lang('Example {{LETTERPREFIXCUSTOM n_prefix title n_family}} - Example: Mr Dr. James Miller'),
-			) as $name => $label)
-		{
-			echo '<tr><td>{{'.$name.'}}</td><td colspan="3">'.$label."</td></tr>\n";
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('EPL Only').":</h3></td></tr>";
-		echo '<tr><td>{{share}}</td><td colspan="3">'.lang('Public sharing URL')."</td></tr>\n";
-
-		Api\Translation::add_app('calendar');
-		echo '<tr><td colspan="4"><h3>'.lang('Calendar fields:')." # = 1, 2, ..., 20, -1</h3></td></tr>";
-		foreach(array(
-			'title' => lang('Title'),
-			'description' => lang('Description'),
-			'participants' => lang('Participants'),
-			'location' => lang('Location'),
-			'start'    => lang('Start').': '.lang('Date').'+'.lang('Time'),
-			'startday' => lang('Start').': '.lang('Weekday'),
-			'startdate'=> lang('Start').': '.lang('Date'),
-			'starttime'=> lang('Start').': '.lang('Time'),
-			'end'      => lang('End').': '.lang('Date').'+'.lang('Time'),
-			'endday'   => lang('End').': '.lang('Weekday'),
-			'enddate'  => lang('End').': '.lang('Date'),
-			'endtime'  => lang('End').': '.lang('Time'),
-			'duration' => lang('Duration'),
-			'category' => lang('Category'),
-			'priority' => lang('Priority'),
-			'updated'  => lang('Updated'),
-			'recur_type' => lang('Repetition'),
-			'access'   => lang('Access').': '.lang('public').', '.lang('private'),
-			'owner'    => lang('Owner'),
-		) as $name => $label)
-		{
-			if(in_array($name, array('start',
-									 'end')) && $n & 1)        // main values, which should be in the first column
-			{
-				echo "</tr>\n";
-				$n++;
-			}
-			if(!($n & 1))
-			{
-				echo '<tr>';
-			}
-			echo '<td>{{calendar/#/' . $name . '}}</td><td>' . $label . '</td>';
-			if($n & 1)
-			{
-				echo "</tr>\n";
-			}
-			$n++;
-		}
-		echo "</table>\n";
-
-		$GLOBALS['egw']->framework->render(ob_get_clean());
-	}
-
 	/**
 	 * Get a list of placeholders provided.
 	 *
@@ -352,10 +239,58 @@ class Merge extends Api\Storage\Merge
 			'label' => "Formatted private address"
 		];
 
+		$placeholders['EPL only'][] = [
+			'value' => $this->prefix($prefix, 'share', '{'),
+			'label' => 'Public sharing URL'
+		];
+
 		$this->add_customfield_placeholders($placeholders, $prefix);
+
+		// Don't add any linked placeholders if we're not at the top level
+		// This avoids potential recursion
+		if(!$prefix)
+		{
+			$this->add_calendar_placeholders($placeholders, $prefix);
+		}
+
 		return $placeholders;
 	}
 
+	protected function add_calendar_placeholders(&$placeholders, $prefix)
+	{
+		Api\Translation::add_app('calendar');
+
+		// NB: The -1 is actually ‑1, a non-breaking hyphen to avoid UI issues where we split on -
+		$group = lang('Calendar fields:') . " # = 1, 2, ..., 20, ‑1";
+		foreach(array(
+					'title'        => lang('Title'),
+					'description'  => lang('Description'),
+					'participants' => lang('Participants'),
+					'location'     => lang('Location'),
+					'start'        => lang('Start') . ': ' . lang('Date') . '+' . lang('Time'),
+					'startday'     => lang('Start') . ': ' . lang('Weekday'),
+					'startdate'    => lang('Start') . ': ' . lang('Date'),
+					'starttime'    => lang('Start') . ': ' . lang('Time'),
+					'end'          => lang('End') . ': ' . lang('Date') . '+' . lang('Time'),
+					'endday'       => lang('End') . ': ' . lang('Weekday'),
+					'enddate'      => lang('End') . ': ' . lang('Date'),
+					'endtime'      => lang('End') . ': ' . lang('Time'),
+					'duration'     => lang('Duration'),
+					'category'     => lang('Category'),
+					'priority'     => lang('Priority'),
+					'updated'      => lang('Updated'),
+					'recur_type'   => lang('Repetition'),
+					'access'       => lang('Access') . ': ' . lang('public') . ', ' . lang('private'),
+					'owner'        => lang('Owner'),
+				) as $name => $label)
+		{
+			$placeholders[$group][] = array(
+				'value' => $this->prefix(($prefix ? $prefix . '/' : '') . 'calendar/#', $name, '{'),
+				'label' => $label
+			);
+		}
+	}
+
 	/**
 	 * Get insert-in-document action with optional default document on top
 	 *
diff --git a/api/src/Contacts/Sql.php b/api/src/Contacts/Sql.php
index 481d2b9fb0..a6748459fc 100644
--- a/api/src/Contacts/Sql.php
+++ b/api/src/Contacts/Sql.php
@@ -77,15 +77,15 @@ class Sql extends Api\Storage
 		// Get custom fields from addressbook instead of api
 		$this->customfields = Api\Storage\Customfields::get('addressbook');
 
-		if ($GLOBALS['egw_info']['server']['account_repository'])
+		if (!empty($GLOBALS['egw_info']['server']['account_repository']))
 		{
 			$this->account_repository = $GLOBALS['egw_info']['server']['account_repository'];
 		}
-		elseif ($GLOBALS['egw_info']['server']['auth_type'])
+		elseif (!empty($GLOBALS['egw_info']['server']['auth_type']))
 		{
 			$this->account_repository = $GLOBALS['egw_info']['server']['auth_type'];
 		}
-		if ($GLOBALS['egw_info']['server']['contact_repository'])
+		if (!empty($GLOBALS['egw_info']['server']['contact_repository']))
 		{
 			$this->contact_repository = $GLOBALS['egw_info']['server']['contact_repository'];
 		}
@@ -742,7 +742,7 @@ class Sql extends Api\Storage
 		$cat_filter = array();
 		foreach(is_array($cats) ? $cats : (is_numeric($cats) ? array($cats) : explode(',',$cats)) as $cat)
 		{
-			if (is_numeric($cat)) $cat_filter[] = $this->db->concat("','",cat_id,"','")." LIKE '%,$cat,%'";
+			if (is_numeric($cat)) $cat_filter[] = $this->db->concat("','", 'cat_id', "','")." LIKE '%,$cat,%'";
 		}
 		return $cat_filter;
 	}
diff --git a/api/src/Contacts/Storage.php b/api/src/Contacts/Storage.php
index 0cb66f4b6d..fe2b3b4531 100755
--- a/api/src/Contacts/Storage.php
+++ b/api/src/Contacts/Storage.php
@@ -256,7 +256,7 @@ class Storage
 		}
 		$this->customfields = Api\Storage\Customfields::get('addressbook');
 		// contacts backend (contacts in LDAP require accounts in LDAP!)
-		if($GLOBALS['egw_info']['server']['contact_repository'] == 'ldap' && $this->account_repository == 'ldap')
+		if (($GLOBALS['egw_info']['server']['contact_repository']??null) === 'ldap' && $this->account_repository === 'ldap')
 		{
 			$this->contact_repository = 'ldap';
 			$this->somain = new Ldap();
@@ -264,7 +264,7 @@ class Storage
 		}
 		else	// sql or sql->ldap
 		{
-			if ($GLOBALS['egw_info']['server']['contact_repository'] == 'sql-ldap')
+			if (($GLOBALS['egw_info']['server']['contact_repository']??null) === 'sql-ldap')
 			{
 				$this->contact_repository = 'sql-ldap';
 			}
@@ -347,9 +347,9 @@ class Storage
 		if ($user)
 		{
 			// contacts backend (contacts in LDAP require accounts in LDAP!)
-			if($GLOBALS['egw_info']['server']['contact_repository'] == 'ldap' && $this->account_repository == 'ldap')
+			if(($GLOBALS['egw_info']['server']['contact_repository']??null) === 'ldap' && $this->account_repository === 'ldap')
 			{
-				// static grants from ldap: all rights for the own personal addressbook and the group ones of the meberships
+				// static grants from ldap: all rights for the own personal addressbook and the group ones of the memberships
 				$grants = array($user => ~0);
 				foreach($GLOBALS['egw']->accounts->memberships($user,true) as $gid)
 				{
@@ -415,9 +415,9 @@ class Storage
 	 */
 	function allow_account_edit($user=null)
 	{
-		return $GLOBALS['egw_info']['server']['allow_account_edit'] &&
+		return !empty($GLOBALS['egw_info']['server']['allow_account_edit']) &&
 			array_intersect($GLOBALS['egw_info']['server']['allow_account_edit'],
-				$GLOBALS['egw']->accounts->memberships($user ? $user : $this->user, true));
+				$GLOBALS['egw']->accounts->memberships($user ?: $this->user, true));
 	}
 
 	/**
diff --git a/api/src/Db.php b/api/src/Db.php
index cb42718df4..c2160cc0a8 100644
--- a/api/src/Db.php
+++ b/api/src/Db.php
@@ -591,7 +591,7 @@ class Db
 			$this->setupType = $this->Type;
 			$this->Type = 'mysql';
 		}
-		if ($new_connection)
+		if (!empty($new_connection))
 		{
 			foreach(get_included_files() as $file)
 			{
@@ -1599,7 +1599,7 @@ class Db
 		{
 			return $array;
 		}
-		if (!$column_definitions)
+		if (empty($column_definitions))
 		{
 			$column_definitions = $this->column_definitions;
 		}
diff --git a/api/src/Db/Pdo.php b/api/src/Db/Pdo.php
index cd038c6f15..353409d523 100644
--- a/api/src/Db/Pdo.php
+++ b/api/src/Db/Pdo.php
@@ -125,7 +125,7 @@ class Pdo
 			// Exception reveals password, so we ignore the exception and connect again without pw, to get the right exception without pw
 			self::$pdo = new \PDO($dsn,$egw_db->User,'$egw_db->Password');
 		}
-		if ($query)
+		if (!empty($query))
 		{
 			self::$pdo->exec($query);
 		}
diff --git a/api/src/Etemplate.php b/api/src/Etemplate.php
index 0631d9e3b7..45e88ae271 100644
--- a/api/src/Etemplate.php
+++ b/api/src/Etemplate.php
@@ -117,22 +117,22 @@ class Etemplate extends Etemplate\Widget\Template
 
 			foreach(count(array_filter(array_keys($extras), 'is_int')) ? $extras : array($extras) as $extra)
 			{
-				if ($extra['data'] && is_array($extra['data']))
+				if (!empty($extra['data']) && is_array($extra['data']))
 				{
 					$content = array_merge($content, $extra['data']);
 				}
 
-				if ($extra['preserve'] && is_array($extra['preserve']))
+				if (!empty($extra['preserve']) && is_array($extra['preserve']))
 				{
 					$preserv = array_merge($preserv, $extra['preserve']);
 				}
 
-				if ($extra['readonlys'] && is_array($extra['readonlys']))
+				if (!empty($extra['readonlys']) && is_array($extra['readonlys']))
 				{
 					$readonlys = array_merge($readonlys, $extra['readonlys']);
 				}
 
-				if ($extra['sel_options'] && is_array($extra['sel_options']))
+				if (!empty($extra['sel_options']) && is_array($extra['sel_options']))
 				{
 					$sel_options = array_merge($sel_options, $extra['sel_options']);
 				}
@@ -177,7 +177,7 @@ class Etemplate extends Etemplate\Widget\Template
 		}
 
 		// some apps (eg. InfoLog) set app_header only in get_rows depending on filter settings
-		self::$request->app_header = $GLOBALS['egw_info']['flags']['app_header'];
+		self::$request->app_header = $GLOBALS['egw_info']['flags']['app_header'] ?? null;
 
 		// compile required translations translations
 		$currentapp = $GLOBALS['egw_info']['flags']['currentapp'];
@@ -209,7 +209,7 @@ class Etemplate extends Etemplate\Widget\Template
 			'currentapp' => $currentapp,
 		);
 
-		if($data['content']['nm']['rows'] && is_array($data['content']['nm']['rows']))
+		if (!empty($data['content']['nm']['rows']) && is_array($data['content']['nm']['rows']))
 		{
 			// Deep copy rows so we don't lose them when request is set to null
 			// (some content by reference)
@@ -420,7 +420,7 @@ class Etemplate extends Etemplate\Widget\Template
 		}
 
 		$tcontent = is_array($content) ? $content :
-			self::complete_array_merge(self::$request->preserv, $validated);
+			self::complete_array_merge(self::$request->preserv ?? [], $validated);
 
 		$hook_data = Hooks::process(
 			array(
diff --git a/api/src/Etemplate/Request.php b/api/src/Etemplate/Request.php
index f4823645b9..4843c9503d 100644
--- a/api/src/Etemplate/Request.php
+++ b/api/src/Etemplate/Request.php
@@ -428,9 +428,9 @@ class Request
 	 * @param string $var
 	 * @param mixed $val
 	 */
-	public function __set($var,$val)
+	public function __set($var, $val)
 	{
-		if ($this->data[$var] !== $val)
+		if (!isset($this->data[$var]) || $this->data[$var] !== $val)
 		{
 			$this->data[$var] = $val;
 			//error_log(__METHOD__."('$var', ...) data of id=$this->id changed ...");
diff --git a/api/src/Etemplate/Request/Files.php b/api/src/Etemplate/Request/Files.php
index b15054d320..613bd5be30 100644
--- a/api/src/Etemplate/Request/Files.php
+++ b/api/src/Etemplate/Request/Files.php
@@ -97,9 +97,10 @@ class Files extends Etemplate\Request
 	 * Factory method to get a new request object or the one for an existing request
 	 *
 	 * @param string $id =null
-	 * @return ?Etemplate\Request|false the object or false if $id is not found
+	 * @param bool $handle_not_found =true true: handle not found by trying to redirect, false: just return null
+	 * @return Request|null null if Request not found and $handle_not_found === false
 	 */
-	static function read($id=null)
+	public static function read($id=null, $handle_not_found=true)
 	{
 		$request = new Files($id);
 
diff --git a/api/src/Etemplate/Request/Session.php b/api/src/Etemplate/Request/Session.php
index 7bad1420fe..339c09307d 100644
--- a/api/src/Etemplate/Request/Session.php
+++ b/api/src/Etemplate/Request/Session.php
@@ -83,9 +83,10 @@ class Session extends Etemplate\Request
 	 * Factory method to get a new request object or the one for an existing request
 	 *
 	 * @param string $id =null
-	 * @return ?Etemplate\request|false the object or false if $id is not found
+	 * @param bool $handle_not_found =true true: handle not found by trying to redirect, false: just return null
+	 * @return Request|null null if Request not found and $handle_not_found === false
 	 */
-	static function read($id=null)
+	public static function read($id=null, $handle_not_found=true)
 	{
 		$request = new Session($id);
 
diff --git a/api/src/Etemplate/Widget.php b/api/src/Etemplate/Widget.php
index 00a591f000..a3f41e0f23 100644
--- a/api/src/Etemplate/Widget.php
+++ b/api/src/Etemplate/Widget.php
@@ -553,12 +553,21 @@ class Widget
 			$method = new ReflectionMethod($this, $method_name);
 			foreach($method->getParameters() as $index => $param)
 			{
-				if(!$param->isOptional() && !array_key_exists($index,$params))
+				if(!$param->isOptional() && !array_key_exists($index, $params))
 				{
 					error_log("Missing required parameter {$param->getPosition()}: {$param->getName()}");
 					$call = false;
 				}
-				if($param->isArray() && !is_array($params[$index]))
+				// Check to see if method wants an array, and we're providing it
+				$paramType = $param->getType();
+				if(!$paramType)
+				{
+					continue;
+				}
+				$types = $paramType instanceof \ReflectionUnionType
+					? $paramType->getTypes()
+					: [$paramType];
+				if(in_array('array', array_map(fn(\ReflectionNamedType $t) => $t->getName(), $types)) && !is_array($params[$index]))
 				{
 					error_log("$method_name expects an array for {$param->getPosition()}: {$param->getName()}");
 					$params[$index] = (array)$params[$index];
@@ -1044,6 +1053,10 @@ class Widget
 	 */
 	public static function &setElementAttribute($name,$attr,$val)
 	{
+		if (!isset(self::$request))
+		{
+			throw new \Exception(__METHOD__."('$name', '$attr', ".json_encode($val)." called before instanciating Api\Etemplate!");
+		}
 		//error_log(__METHOD__."('$name', '$attr', ...) request=".get_class(self::$request).", response=".get_class(self::$response).function_backtrace());
 		$ref =& self::$request->modifications[$name][$attr];
 		if(self::$request && self::$response)
diff --git a/api/src/Etemplate/Widget/Box.php b/api/src/Etemplate/Widget/Box.php
index de3a379091..74a198d239 100644
--- a/api/src/Etemplate/Widget/Box.php
+++ b/api/src/Etemplate/Widget/Box.php
@@ -50,7 +50,7 @@ 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 (!empty($expand['cname']) && $expand['cname'] !== $cname && trim($cname))
+		if (($expand['cname'] ?? null) !== $cname && trim($cname))
 		{
 			$expand['cont'] =& self::get_array(self::$request->content, $cname);
 			$expand['cname'] = $cname;
diff --git a/api/src/Etemplate/Widget/Customfields.php b/api/src/Etemplate/Widget/Customfields.php
index 0964a7fd9b..466ff5f5bc 100644
--- a/api/src/Etemplate/Widget/Customfields.php
+++ b/api/src/Etemplate/Widget/Customfields.php
@@ -98,7 +98,7 @@ class Customfields extends Transformer
 		$form_name = self::form_name($cname, $this->id, $expand);
 
 		// Store properties at top level, so all customfield widgets can share
-		if($this->attrs['app'])
+		if (!empty($this->attrs['app']))
 		{
 			$app = $this->attrs['app'];
 		}
@@ -141,12 +141,12 @@ class Customfields extends Transformer
 			// app changed
 			$customfields = Api\Storage\Customfields::get($app);
 		}
-		if($this->attrs['customfields'])
+		if (!empty($this->attrs['customfields']))
 		{
 			$customfields = $this->attrs['customfields'];
 		}
 		// Filter fields
-		if($this->attrs['field-names'])
+		if (!empty($this->attrs['field-names']))
 		{
 			$fields_name = explode(',', $this->attrs['field-names']);
 			foreach($fields_name as &$f)
@@ -162,8 +162,8 @@ class Customfields extends Transformer
 
 		$fields = $customfields;
 
-		$use_private = self::expand_name($this->attrs['use-private'],0,0,'','',self::$cont);
-		$this->attrs['sub-type'] = self::expand_name($this->attrs['sub-type'],0,0,'','',self::$cont);
+		$use_private = self::expand_name($this->attrs['use-private'] ?? null,0,0,'','',self::$cont);
+		$this->attrs['sub-type'] = self::expand_name($this->attrs['sub-type'] ?? null,0,0,'','',self::$cont);
 
 		foreach((array)$fields as $key => $field)
 		{
@@ -174,7 +174,7 @@ class Customfields extends Transformer
 			}
 
 			// Remove filtered fields
-			if($field_filters && in_array($key, $negate_fields) && in_array($key, $field_filters))
+			if (!empty($field_filters) && in_array($key, $negate_fields) && in_array($key, $field_filters))
 			{
 				unset($fields[$key]);
 			}
@@ -284,7 +284,7 @@ class Customfields extends Transformer
 
 		$type = $field['type'];
 		// Link-tos needs to change from appname to link-to
-		if($link_types[$field['type']])
+		if (!empty($link_types[$field['type']]))
 		{
 			if($type == 'filemanager')
 			{
@@ -314,8 +314,8 @@ class Customfields extends Transformer
 				{
 					$widget->attrs['data_format'] = $type == 'date' ? 'Y-m-d' : 'Y-m-d H:i:s';
 				}
-				if($field['values']['min']) $widget->attrs['min'] = $field['values']['min'];
-				if($field['values']['max']) $widget->attrs['min'] = $field['values']['max'];
+				if (isset($field['values']['min'])) $widget->attrs['min'] = $field['values']['min'];
+				if (isset($field['values']['max'])) $widget->attrs['min'] = $field['values']['max'];
 				break;
 
 			case 'vfs-upload':
@@ -355,7 +355,7 @@ class Customfields extends Transformer
 					$field['values'] = Api\Storage\Customfields::get_options_from_file($field['values']['@']);
 				}
 				// keep extra values set by app code, eg. addressbook advanced search
-				if (is_array(self::$request->sel_options[self::$prefix.$fname]))
+				if (!empty(self::$request->sel_options[self::$prefix.$fname]) && is_array(self::$request->sel_options[self::$prefix.$fname]))
 				{
 					self::$request->sel_options[self::$prefix.$fname] += (array)$field['values'];
 				}
diff --git a/api/src/Etemplate/Widget/Description.php b/api/src/Etemplate/Widget/Description.php
index 338e80f15d..e69bcdfd19 100644
--- a/api/src/Etemplate/Widget/Description.php
+++ b/api/src/Etemplate/Widget/Description.php
@@ -42,7 +42,7 @@ class Description extends Etemplate\Widget
 	 */
 	public function beforeSendToClient($cname, array $expand=null)
 	{
-		if ($this->attrs['activate_links'])
+		if (!empty($this->attrs['activate_links']))
 		{
 			$form_name = self::form_name($cname, $this->id, $expand);
 			$value =& self::get_array(self::$request->content, $form_name);
diff --git a/api/src/Etemplate/Widget/Grid.php b/api/src/Etemplate/Widget/Grid.php
index 17433ba3e1..a26d76a73b 100644
--- a/api/src/Etemplate/Widget/Grid.php
+++ b/api/src/Etemplate/Widget/Grid.php
@@ -88,8 +88,11 @@ class Grid extends Box
 			return false;	// return
 		}
 
-		if ($this->id && $this->type !== 'row') $cname = self::form_name($cname, $this->id, $expand);
-		if (!empty($expand['cname']) && $expand['cname'] !== $cname && $cname)
+		if($this->id && $this->type !== 'row')
+		{
+			$cname = self::form_name($cname, $this->id, $expand);
+		}
+		if($cname && (!empty($expand['cname']) && $expand['cname'] !== $cname || !$expand['cname']))
 		{
 			$expand['cont'] =& self::get_array(self::$request->content, $cname);
 			$expand['cname'] = $cname;
diff --git a/api/src/Etemplate/Widget/Image.php b/api/src/Etemplate/Widget/Image.php
index 7969ab83dc..f77067f082 100644
--- a/api/src/Etemplate/Widget/Image.php
+++ b/api/src/Etemplate/Widget/Image.php
@@ -36,17 +36,17 @@ class Image extends Etemplate\Widget
 
 		$image = $value != '' ? $value : $this->attrs['src'];
 
-		if (is_string($image)) list($app,$img) = explode('/',$image,2);
-		if (!$app || !$img || !is_dir(EGW_SERVER_ROOT.'/'.$app) || strpos($img,'/')!==false)
+		if (is_string($image)) list($app,$img) = explode('/',$image,2)+[null,null];
+		if (empty($app) || empty($img) || !is_dir(EGW_SERVER_ROOT.'/'.$app) || strpos($img,'/')!==false)
 		{
 			$img = $image;
 			list($app) = explode('.',$form_name);
 		}
 		$src = Api\Image::find($app, $img);
-		if(!$this->id)
+		/*if(!$this->id)
 		{
 //			self::setElementAttribute($this->attrs['src'], 'id', $this->attrs['src']);
-		}
+		}*/
 		self::setElementAttribute($this->attrs['src'], 'src', $src);
 	}
 }
diff --git a/api/src/Etemplate/Widget/Nextmatch.php b/api/src/Etemplate/Widget/Nextmatch.php
index d6ccd128c7..10004d276e 100644
--- a/api/src/Etemplate/Widget/Nextmatch.php
+++ b/api/src/Etemplate/Widget/Nextmatch.php
@@ -130,13 +130,13 @@ class Nextmatch extends Etemplate\Widget
 		$send_value = $value;
 
 		list($app) = explode('.',$value['get_rows']);
-		if(!$GLOBALS['egw_info']['apps'][$app])
+		if (empty($GLOBALS['egw_info']['apps'][$app]))
 		{
 			list($app) = explode('.',$this->attrs['template']);
 		}
 
 		// Check for a favorite in URL
-		if($_GET['favorite'] && $value['favorites'])
+		if (!empty($_GET['favorite']) && !empty($value['favorites']))
 		{
 			$safe_name = preg_replace('/[^A-Za-z0-9-_]/','_',strip_tags($_GET['favorite']));
 			$pref_name = "favorite_" .$safe_name;
@@ -210,7 +210,7 @@ class Nextmatch extends Etemplate\Widget
 		}
 
 		// Favorite group for admins
-		if($GLOBALS['egw_info']['apps']['admin'] && $value['favorites'])
+		if (!empty($GLOBALS['egw_info']['apps']['admin']) && !empty($value['favorites']))
 		{
 			self::$request->sel_options[$form_name]['favorite']['group'] = array('all' => lang('All users')) +
 				Select::typeOptions('select-account',',groups');
@@ -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 && empty($egw_actions[$prefix.'select_all']))
+			if ($first_level && $group !== false && ($action['group']??null) != $group && empty($egw_actions[$prefix.'select_all']))
 			{
 
 				$egw_actions[$prefix.'select_all'] = array(
diff --git a/api/src/Etemplate/Widget/Password.php b/api/src/Etemplate/Widget/Password.php
index ae4d5ef7f3..3e7cbe9997 100644
--- a/api/src/Etemplate/Widget/Password.php
+++ b/api/src/Etemplate/Widget/Password.php
@@ -49,7 +49,8 @@ class Password extends Etemplate\Widget\Textbox
 	{
 		$form_name = self::form_name($cname, $this->id, $expand);
 		$value =& self::get_array(self::$request->content, $form_name);
-		$plaintext = !in_array(self::expand_name($this->attrs['plaintext'],$expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']),
+		$plaintext = !empty($this->attrs['plaintext']) && !in_array(
+			self::expand_name($this->attrs['plaintext'], $expand['c'] ?? null, $expand['row'] ?? null, $expand['c_'] ?? null, $expand['row_'] ?? null, $expand['cont']),
 			['false', '0']);
 
 		if (!empty($value))
diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php
index a31d88c49d..c2f933c6c8 100644
--- a/api/src/Etemplate/Widget/Placeholder.php
+++ b/api/src/Etemplate/Widget/Placeholder.php
@@ -64,9 +64,13 @@ class Placeholder extends Etemplate\Widget
 
 		if(is_null($apps))
 		{
-			$apps = ['addressbook', 'user', 'general'] +
+			$apps = array_merge(
+				['addressbook', 'user', 'general'],
 				// We use linking for preview, so limit to apps that support links
-				array_keys(Api\Link::app_list('query'));
+				array_keys(Api\Link::app_list('query')),
+				// Filemanager doesn't support links, but add it anyway
+				['filemanager']
+			);
 		}
 
 		foreach($apps as $appname)
@@ -86,6 +90,8 @@ class Placeholder extends Etemplate\Widget
 						// Looks like app doesn't support merging
 						continue 2;
 					}
+
+					Api\Translation::load_app($appname, $GLOBALS['egw_info']['user']['preferences']['common']['lang']);
 					$list = method_exists($merge, 'get_placeholder_list') ? $merge->get_placeholder_list() : [];
 					break;
 			}
diff --git a/api/src/Etemplate/Widget/Select.php b/api/src/Etemplate/Widget/Select.php
index 84817c9aa5..e8c8844cff 100644
--- a/api/src/Etemplate/Widget/Select.php
+++ b/api/src/Etemplate/Widget/Select.php
@@ -109,14 +109,14 @@ class Select extends Etemplate\Widget
 	{
 		parent::set_attrs($xml, $cloned);
 
-		if ($this->attrs['multiple'] !== 'dynamic')
+		if (!isset($this->attrs['multiple']) || $this->attrs['multiple'] !== 'dynamic')
 		{
 			$this->attrs['multiple'] = !isset($this->attrs['multiple']) ? false :
 				!(!$this->attrs['multiple'] || $this->attrs['multiple'] === 'false');
 		}
 
 		// set attrs[multiple] from attrs[options], unset options only if it just contains number or rows
-		if ($this->attrs['options'] > 1)
+		if (isset($this->attrs['options']) && $this->attrs['options'] > 1)
 		{
 			$this->attrs['multiple'] = (int)$this->attrs['options'];
 			if ((string)$this->attrs['multiple'] == $this->attrs['options'])
@@ -124,7 +124,7 @@ class Select extends Etemplate\Widget
 				unset($this->attrs['options']);
 			}
 		}
-		elseif($this->attrs['rows'] > 1)
+		elseif(isset($this->attrs['rows']) && $this->attrs['rows'] > 1)
 		{
 			$this->attrs['multiple'] = true;
 		}
@@ -311,8 +311,8 @@ class Select extends Etemplate\Widget
 		{
 			$form_name = self::form_name($cname, $this->id, $expand);
 		}
-		if (!is_array(self::$request->sel_options[$form_name])) self::$request->sel_options[$form_name] = array();
-		$type = $this->attrs['type'] ? $this->attrs['type'] : $this->type;
+		if (empty(self::$request->sel_options[$form_name]) || !is_array(self::$request->sel_options[$form_name])) self::$request->sel_options[$form_name] = [];
+		$type = $this->attrs['type'] ?? $this->type;
 		if ($type != 'select' && $type != 'menupopup')
 		{
 			// Check selection preference, we may be able to skip reading some data
@@ -335,8 +335,8 @@ class Select extends Etemplate\Widget
 				if (!isset($form_names_done[$form_name]) &&
 					($type_options = self::typeOptions($this,
 					// typeOptions thinks # of rows is the first thing in options
-					($this->attrs['rows'] && strpos($this->attrs['options'], $this->attrs['rows']) !== 0 ? $this->attrs['rows'].','.$this->attrs['options'] : $this->attrs['options']),
-					$no_lang, $this->attrs['readonly'], self::get_array(self::$request->content, $form_name), $form_name)))
+					(!empty($this->attrs['rows']) && !empty($this->attrs['options']) && strpos($this->attrs['options'], $this->attrs['rows']) !== 0 ? $this->attrs['rows'].','.$this->attrs['options'] : $this->attrs['options']),
+					$no_lang, $this->attrs['readonly'] ?? false, self::get_array(self::$request->content, $form_name), $form_name)))
 				{
 					self::fix_encoded_options($type_options);
 
@@ -356,7 +356,7 @@ class Select extends Etemplate\Widget
 		$options = (isset(self::$request->sel_options[$form_name]) ? $form_name : $this->id);
 		if(is_array(self::$request->sel_options[$options]))
 		{
-			if(in_array($this->attrs['type'], self::$cached_types) && !isset($form_names_done[$options]))
+			if (isset($this->attrs['type']) && in_array($this->attrs['type'], self::$cached_types) && !isset($form_names_done[$options]))
 			{
 				// Fix any custom options from application
 				self::fix_encoded_options(self::$request->sel_options[$options],true);
@@ -561,7 +561,7 @@ class Select extends Etemplate\Widget
 			$field = self::expand_name($field, 0, 0,'','',self::$cont);
 		}
 
-		list($rows,$type,$type2,$type3,$type4,$type5) = $legacy_options;
+		list($rows,$type,$type2,$type3,$type4,$type5) = $legacy_options+[null,null,null,null,null,null];
 		$no_lang = false;
 		$options = array();
 		switch ($widget_type)
@@ -644,7 +644,7 @@ class Select extends Etemplate\Widget
 						// These are extra info for easy dealing with categories
 						// client side, without extra loading
 						'main'  => (int)$cat['main'],
-						'children'	=> $cat['children'],
+						'children'	=> $cat['children'] ?? null,
 						//add different class per level to allow different styling for each category level:
 						'class' => "cat_level". $cat['level']
 					);
@@ -839,7 +839,7 @@ class Select extends Etemplate\Widget
 				}
 				foreach((array)$options as $right => $name)
 				{
-					if(!!($value & $right))
+					if (!!((int)$value & (int)$right))
 					{
 						$new_value[] = $right;
 					}
diff --git a/api/src/Etemplate/Widget/Template.php b/api/src/Etemplate/Widget/Template.php
index b83255e7e9..f256e78272 100644
--- a/api/src/Etemplate/Widget/Template.php
+++ b/api/src/Etemplate/Widget/Template.php
@@ -68,7 +68,7 @@ class Template extends Etemplate\Widget
 		list($name) = explode('?', $_name);	// remove optional cache-buster
 		if (isset(self::$cache[$name]) || !($path = self::relPath($name, $template_set, $version, $load_via)))
 		{
-			if ((!$path || self::read($load_via, $template_set)) && isset(self::$cache[$name]))
+			if ((empty($path) || self::read($load_via, $template_set)) && isset(self::$cache[$name]))
 			{
 				//error_log(__METHOD__."('$name', '$template_set', '$version', '$load_via') read from cache");
 				return self::$cache[$name];
@@ -146,7 +146,7 @@ class Template extends Etemplate\Widget
 	{
 		static $prefixes = null;
 		unset($version);	// not used currently
-		list($app, $rest) = explode('.', $load_via ?: $name, 2);
+		list($app, $rest) = explode('.', $load_via ?: $name, 2)+[null,null];
 
 		if (empty($template_set))
 		{
@@ -184,7 +184,7 @@ class Template extends Etemplate\Widget
 			$path = $prefix.$path;
 		}
 		//error_log(__METHOD__."('$name', '$template_set') returning ".array2string($path));
-		return $path;
+		return $path ?? null;
 	}
 
 	/**
@@ -230,16 +230,16 @@ class Template extends Etemplate\Widget
 	{
 		$cname =& $params[0];
 		$old_cname = $params[0];
-		if ($this->attrs['content']) $cname = self::form_name($cname, $this->attrs['content'], $params[1]);
+		if (!empty($this->attrs['content'])) $cname = self::form_name($cname, $this->attrs['content'], $params[1]);
 
 		// Check for template from content, and run over it
 		// templates included via template tag have their name to load them from in attribute "template"
-		$expand_name = self::expand_name($this->id ? $this->id : $this->attrs['template'], '','','','',self::$request->content);
+		$expand_name = self::expand_name($this->id ?: $this->attrs['template'], '','','','',self::$request->content);
 		if(!$expand_name && $this->id && $this->attrs['template'])
 		{
 			$expand_name = $this->attrs['template'];
 		}
-		if($this->original_name)
+		if (!empty($this->original_name))
 		{
 			$expand_name = self::expand_name($this->original_name, '','','','',self::$request->content);
 		}
diff --git a/api/src/Etemplate/Widget/Textbox.php b/api/src/Etemplate/Widget/Textbox.php
index 74717982ac..26a32a3897 100644
--- a/api/src/Etemplate/Widget/Textbox.php
+++ b/api/src/Etemplate/Widget/Textbox.php
@@ -62,14 +62,14 @@ class Textbox extends Etemplate\Widget
 		parent::set_attrs($xml, $cloned);
 
 		// Legacy handling only
-		// A negative size triggered the HTML readonly attibute, but not etemplate readonly,
+		// A negative size triggered the HTML readonly attribute, but not etemplate readonly,
 		// so you got an input element, but it was not editable.
-		if ($this->attrs['size'] < 0)
+		if (isset($this->attrs['size']) && $this->attrs['size'] < 0)
 		{
 			self::setElementAttribute($this->id, 'size', abs($this->attrs['size']));
 			self::$request->readonlys[$this->id] = false;
 			self::setElementAttribute($this->id, 'readonly', true);
-			trigger_error("Using a negative size to set textbox readonly. " .$this, E_USER_DEPRECATED);
+			//trigger_error("Using a negative size to set textbox readonly. " .$this, E_USER_DEPRECATED);
 		}
 		return $this;
 	}
diff --git a/api/src/Etemplate/Widget/Tree.php b/api/src/Etemplate/Widget/Tree.php
index 1fa12c56d0..b05deaac1c 100644
--- a/api/src/Etemplate/Widget/Tree.php
+++ b/api/src/Etemplate/Widget/Tree.php
@@ -143,7 +143,7 @@ class Tree extends Etemplate\Widget
 		parent::set_attrs($xml, $cloned);
 
 		// set attrs[multiple] from attrs[options]
-		if ($this->attrs['options'] > 1)
+		if (isset($this->attrs['options']) && (int)$this->attrs['options'] > 1)
 		{
 			self::setElementAttribute($this->id, 'multiple', true);
 		}
@@ -297,21 +297,21 @@ class Tree extends Etemplate\Widget
 	{
 		$form_name = self::form_name($cname, $this->id);
 
-		if (($templated_path = self::templateImagePath($this->attrs['image_path'])) != $this->attrs['image_path'])
+		if (($templated_path = self::templateImagePath($this->attrs['image_path'] ?? null)) !== ($this->attrs['image_path'] ?? null))
 		{
 			self::setElementAttribute($form_name, 'image_path', $this->attrs['image_path'] = $templated_path);
 			//error_log(__METHOD__."() setting templated image-path for $form_name: $templated_path");
 		}
 
-		if (!is_array(self::$request->sel_options[$form_name])) self::$request->sel_options[$form_name] = array();
-		if ($this->attrs['type'])
+		if (empty(self::$request->sel_options[$form_name])) self::$request->sel_options[$form_name] = [];
+		if (!empty($this->attrs['type']))
 		{
 			// += to keep further options set by app code
-			self::$request->sel_options[$form_name] += self::typeOptions($this->attrs['type'], $this->attrs['options'],
-				$no_lang, $this->attrs['readonly'], self::get_array(self::$request->content, $form_name), $form_name);
+			self::$request->sel_options[$form_name] += self::typeOptions($this->attrs['type'], $this->attrs['options'] ?? null,
+				$no_lang, $this->attrs['readonly'] ?? null, self::get_array(self::$request->content, $form_name), $form_name);
 
 			// if no_lang was modified, forward modification to the client
-			if ($no_lang != $this->attr['no_lang'])
+			if (!isset($this->attr['no_lang']) || $no_lang != $this->attr['no_lang'])
 			{
 				self::setElementAttribute($form_name, 'no_lang', $no_lang);
 			}
@@ -440,7 +440,7 @@ class Tree extends Etemplate\Widget
 	 */
 	public static function typeOptions($widget_type, $legacy_options, &$no_lang=false, $readonly=false, $value=null, $form_name=null)
 	{
-		list($rows,$type,$type2,$type3) = explode(',',$legacy_options);
+		list($rows,$type,$type2,$type3) = explode(',', $legacy_options)+[null,null,null,null];
 
 		$no_lang = false;
 		$options = array();
diff --git a/api/src/Etemplate/Widget/Vfs.php b/api/src/Etemplate/Widget/Vfs.php
index 9781da9ad7..d04e8b96cc 100644
--- a/api/src/Etemplate/Widget/Vfs.php
+++ b/api/src/Etemplate/Widget/Vfs.php
@@ -37,10 +37,10 @@ class Vfs extends File
 	 */
 	public function beforeSendToClient($cname, $expand = array())
 	{
-		if($this->type == 'vfs-upload' || $this->attrs['type'] == 'vfs-upload')
+		if ($this->type === 'vfs-upload' || !empty($this->attrs['type']) && $this->attrs['type'] === 'vfs-upload')
 		{
 			$form_name = self::form_name($cname, $this->id, $expand ? $expand : array('cont'=>self::$request->content));
-			if($this->attrs['path'])
+			if (!empty($this->attrs['path']))
 			{
 				$path = self::expand_name($this->attrs['path'],$expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']);
 			}
@@ -226,7 +226,7 @@ class Vfs extends File
 		foreach($links as $link)
 		{
 			$matches = null;
-			if (is_array($link) && preg_match('|^'.preg_quote(Api\Vfs::PREFIX,'|').'('.preg_quote(self::get_temp_dir($app, ''), '|').'[^/]+)/|', $link['id']['tmp_name'], $matches))
+			if (is_array($link) && !empty($link['id']['tmp_name']) && preg_match('|^'.preg_quote(Api\Vfs::PREFIX,'|').'('.preg_quote(self::get_temp_dir($app, ''), '|').'[^/]+)/|', $link['id']['tmp_name'], $matches))
 			{
 				$replace[substr($link['id']['tmp_name'], strlen(Api\Vfs::PREFIX))] =
 					Api\Link::vfs_path($app, $id, Api\Vfs::basename($link['id']['tmp_name']), true);
diff --git a/api/src/Framework/Ajax.php b/api/src/Framework/Ajax.php
index 77f0e67530..e10618fa2c 100755
--- a/api/src/Framework/Ajax.php
+++ b/api/src/Framework/Ajax.php
@@ -2,12 +2,12 @@
 /**
  * EGroupware - Framework for Ajax based templates: jdots & Pixelegg
  *
- * @link http://www.stylite.de
+ * @link https://www.egroupware.org
  * @package api
  * @subpackage framework
- * @author Andreas Stöckel <as@stylite.de>
- * @author Ralf Becker <rb@stylite.de>
- * @author Nathan Gray <ng@stylite.de>
+ * @author Andreas Stöckel
+ * @author Ralf Becker <rb@egroupware.org>
+ * @author Nathan Gray <ng@egroupware.org>
  * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
  */
 
@@ -16,7 +16,7 @@ namespace EGroupware\Api\Framework;
 use EGroupware\Api;
 
 /**
-* Stylite jdots template
+* Framework for Ajax based templates
 */
 abstract class Ajax extends Api\Framework
 {
@@ -88,13 +88,13 @@ abstract class Ajax extends Api\Framework
 		$width = self::DEFAULT_SIDEBAR_WIDTH;
 
 		//Check whether the width had been stored explicitly for the jdots template, use that value
-		if ($GLOBALS['egw_info']['user']['preferences'][$app]['jdotssideboxwidth'])
+		if (!empty($GLOBALS['egw_info']['user']['preferences'][$app]['jdotssideboxwidth']))
 		{
 			$width = (int)$GLOBALS['egw_info']['user']['preferences'][$app]['jdotssideboxwidth'];
 //				error_log(__METHOD__.__LINE__."($app):$width --> reading jdotssideboxwidth");
 		}
 		//Otherwise use the legacy "idotssideboxwidth" value
-		else if ($GLOBALS['egw_info']['user']['preferences'][$app]['idotssideboxwidth'])
+		elseif (!empty($GLOBALS['egw_info']['user']['preferences'][$app]['idotssideboxwidth']))
 		{
 			$width = (int)$GLOBALS['egw_info']['user']['preferences'][$app]['idotssideboxwidth'];
 //				error_log(__METHOD__.__LINE__."($app):$width --> reading idotssideboxwidth");
@@ -249,7 +249,7 @@ abstract class Ajax extends Api\Framework
 			{
 				if (empty($GLOBALS['egw_info']['flags']['java_script'])) $GLOBALS['egw_info']['flags']['java_script']='';
 				// eT2 sets $GLOBALS['egw_info']['flags']['nonavbar'] === 'popup' for popups, Etemplate::exec($outputmode === 2)
-				$extra['check-framework'] = $_GET['cd'] !== 'no' && $GLOBALS['egw_info']['flags']['nonavbar'] !== 'popup';
+				$extra['check-framework'] = (!isset($_GET['cd']) || $_GET['cd'] !== 'no') && $GLOBALS['egw_info']['flags']['nonavbar'] !== 'popup';
 			}
 		}
 
@@ -1047,16 +1047,16 @@ abstract class Ajax extends Api\Framework
 		if (self::$footer_done) return;	// prevent (multiple) footers
 		self::$footer_done = true;
 
-		if (!isset($GLOBALS['egw_info']['flags']['nofooter']) || !$GLOBALS['egw_info']['flags']['nofooter'])
+		if (empty($GLOBALS['egw_info']['flags']['nofooter']))
 		{
-			if ($no_framework && $GLOBALS['egw_info']['user']['preferences']['common']['show_generation_time'])
+			if ($no_framework && !empty($GLOBALS['egw_info']['user']['preferences']['common']['show_generation_time']))
 			{
 				$vars = $this->_get_footer();
 				$footer = "\n".$vars['page_generation_time']."\n";
 			}
 		}
-		return $footer.
-			$GLOBALS['egw_info']['flags']['need_footer']."\n".	// eg. javascript, which need to be at the end of the page
+		return ($footer??'').
+			($GLOBALS['egw_info']['flags']['need_footer']??'')."\n".	// eg. javascript, which need to be at the end of the page
 			"</body>\n</html>\n";
 	}
 
diff --git a/api/src/Framework/Bundle.php b/api/src/Framework/Bundle.php
index 09d5cfe23e..28d9bc421d 100644
--- a/api/src/Framework/Bundle.php
+++ b/api/src/Framework/Bundle.php
@@ -49,12 +49,12 @@ class Bundle
 		unset($GLOBALS['egw_info']['server']['debug_minify']);
 
 		$file2bundle = array();
-		if ($GLOBALS['egw_info']['server']['debug_minify'] !== 'True')
+		if (!isset($GLOBALS['egw_info']['server']['debug_minify']) || $GLOBALS['egw_info']['server']['debug_minify'] !== 'True')
 		{
 			// get used bundles and cache them on tree-level for 2h
 			//$bundles = self::all(); Cache::setTree(__CLASS__, 'bundles', $bundles, 7200);
 			$bundles = Cache::getTree(__CLASS__, 'bundles', array(__CLASS__, 'all'), array(), 7200);
-			$bundles_ts = $bundles['.ts'];
+			$bundles_ts = $bundles['.ts'] ?? null;
 			unset($bundles['.ts']);
 			foreach($bundles as $name => $files)
 			{
@@ -83,13 +83,13 @@ class Bundle
 
 			if (!isset($to_include[$file]))
 			{
-				if (($bundle = $file2bundle[$file]))
+				if (($bundle = $file2bundle[$file] ?? false))
 				{
 					//error_log(__METHOD__."() requiring bundle $bundle for $file");
 					if (!in_array($bundle, $included_bundles))
 					{
 						$included_bundles[] = $bundle;
-						$minurl = self::$bundle2minurl[$bundle];
+						$minurl = self::$bundle2minurl[$bundle] ?? null;
 						if (!isset($minurl) && isset($GLOBALS['egw_info']['apps'][$bundle]))
 						{
 							$minurl = '/'.$bundle.'/js/app.min.js';
@@ -108,10 +108,10 @@ class Bundle
 				else
 				{
 					unset($query);
-					list($path, $query) = explode('?', $file, 2);
+					list($path, $query) = explode('?', $file, 2)+[null,null];
 					$mod = filemtime(EGW_SERVER_ROOT.$path);
 					// check if we have a more recent minified version of the file and use it
-					if ($GLOBALS['egw_info']['server']['debug_minify'] !== 'True' &&
+					if ((!isset($GLOBALS['egw_info']['server']['debug_minify']) || $GLOBALS['egw_info']['server']['debug_minify'] !== 'True') &&
 						substr($path, -3) == '.js' && file_exists(EGW_SERVER_ROOT.($min_path = substr($path, 0, -3).'.min.js')) &&
 						(($min_mod = filemtime(EGW_SERVER_ROOT.$min_path)) >= $mod))
 					{
@@ -148,7 +148,7 @@ class Bundle
 	 */
 	protected static function urls(array $js_includes, &$max_modified=null, $minurl=null)
 	{
-		$debug_minify = $GLOBALS['egw_info']['server']['debug_minify'] === 'True';
+		$debug_minify = !empty($GLOBALS['egw_info']['server']['debug_minify']) && $GLOBALS['egw_info']['server']['debug_minify'] === 'True';
 		// ignore not existing minurl
 		if (!empty($minurl) && !file_exists(EGW_SERVER_ROOT.$minurl)) $minurl = null;
 		$to_include_first = $to_include = $to_minify = array();
@@ -158,7 +158,7 @@ class Bundle
 		{
 			if ($path == '/api/js/jsapi/egw.js') continue; // Leave egw.js out of bundle
 			unset($query);
-			list($path,$query) = explode('?',$path,2);
+			list($path,$query) = explode('?',$path,2)+[null,null];
 			$mod = filemtime(EGW_SERVER_ROOT.$path);
 			if ($mod > $max_modified) $max_modified = $mod;
 
diff --git a/api/src/Framework/CssIncludes.php b/api/src/Framework/CssIncludes.php
index 539af4d32d..bdc4c30d80 100644
--- a/api/src/Framework/CssIncludes.php
+++ b/api/src/Framework/CssIncludes.php
@@ -113,7 +113,7 @@ class CssIncludes
 		{
 			foreach(self::resolve_css_includes($path) as $path)
 			{
-				list($file,$query) = explode('?',$path,2);
+				list($file,$query) = explode('?',$path,2)+[null,null];
 				if (($mod = filemtime(EGW_SERVER_ROOT.$file)) > $max_modified) $max_modified = $mod;
 
 				// do NOT include app.css or categories.php, as it changes from app to app
diff --git a/api/src/Html.php b/api/src/Html.php
index 4acfa409c6..3d0dc5baf0 100644
--- a/api/src/Html.php
+++ b/api/src/Html.php
@@ -100,6 +100,7 @@ class Html
 			// use preg_replace_callback as we experienced problems with links such as <www.example.tld/pfad/zu/einer/pdf-Datei.pdf>
 			$result4 = preg_replace_callback( $Expr, function ($match) {
 					//error_log(__METHOD__.__LINE__.array2string($match));
+					$match += [null,null,null,null];
 					if ($match[4]==';' && (strlen($match[3])-4) >=0 && strpos($match[3],'&gt',strlen($match[3])-4)!==false)
 					{
 						$match[3] = substr($match[3],0,strpos($match[3],'&gt',strlen($match[3])-4));
@@ -111,7 +112,7 @@ class Html
 						$match[4] = "&gt;";
 					}
 					//error_log(__METHOD__.__LINE__.array2string($match));
-					return $match[1]."<a href=\"http://www".$match[2].$match[3]."\" target=\"_blank\">"."www".$match[2].$match[3]."</a>".$match[4];
+					return $match[1]."<a href=\"https://www".$match[2].$match[3]."\" target=\"_blank\">"."www".$match[2].$match[3]."</a>".$match[4];
 				}, $result3 );
 		}
 		return $result4;
@@ -755,7 +756,7 @@ tinymce.init({
 		{
 			parse_str($vars,$vars);
 		}
-		list($url,$v) = explode('?', $_url);	// url may contain additional vars
+		list($url,$v) = explode('?', $_url)+[null,null];	// url may contain additional vars
 		if ($v)
 		{
 			parse_str($v,$v);
diff --git a/api/src/Html/HtmLawed.php b/api/src/Html/HtmLawed.php
index 770c96dc84..31ec440987 100644
--- a/api/src/Html/HtmLawed.php
+++ b/api/src/Html/HtmLawed.php
@@ -391,7 +391,7 @@ function hl_email_tag_transform($element, $attribute_array=0)
 		// $GLOBALS['egw_info']['user']['preferences']['mail']['allowExternalIMGs'] ? '' : 'match' => '/^cid:.*/'),
 		if (isset($attribute_array['src']))
 		{
-			if (!(strlen($attribute_array['src'])>4 && strlen($attribute_array['src'])<400))
+			if (!(strlen($attribute_array['src'])>4 && strlen($attribute_array['src'])<800))
 			{
 					$attribute_array['alt']= $attribute_array['alt'].' [blocked (reason: url length):'.$attribute_array['src'].']';
 					if (!isset($attribute_array['title'])) $attribute_array['title']=$attribute_array['alt'];
diff --git a/api/src/Link.php b/api/src/Link.php
index 68298cfb91..fe65f6e722 100644
--- a/api/src/Link.php
+++ b/api/src/Link.php
@@ -471,7 +471,7 @@ class Link extends Link\Storage
 	 */
 	static function temp_link_id($app,$id)
 	{
-		return $app.':'.(!in_array($app, array(self::VFS_APPNAME,self::VFS_LINK, self::DATA_APPNAME)) ? $id : $id['name']);
+		return $app.':'.(!in_array($app, array(self::VFS_APPNAME,self::VFS_LINK, self::DATA_APPNAME)) || !is_array($id) ? $id : $id['name']);
 	}
 
 	/**
@@ -683,15 +683,15 @@ class Link extends Link\Storage
 		{
 			echo "<p>Link::unlink('$link_id','$app',".array2string($id).",'$owner','$app2','$id2', $hold_for_purge)</p>\n";
 		}
-		if ($link_id < 0)	// vfs-link?
+		if ((int)$link_id < 0)	// vfs-link?
 		{
 			return self::delete_attached(-$link_id);
 		}
-		elseif ($app == self::VFS_APPNAME)
+		elseif ($app === self::VFS_APPNAME)
 		{
 			return self::delete_attached($app2,$id2,$id);
 		}
-		elseif ($app2 == self::VFS_APPNAME)
+		elseif ($app2 === self::VFS_APPNAME)
 		{
 			return self::delete_attached($app,$id,$id2);
 		}
diff --git a/api/src/Link/Storage.php b/api/src/Link/Storage.php
index d55a8f323b..bdac0f380e 100644
--- a/api/src/Link/Storage.php
+++ b/api/src/Link/Storage.php
@@ -127,7 +127,7 @@ class Storage
 		{
 			echo "<p>solink.get_links($app,".print_r($id,true).",$only_app,$order,$deleted)</p>\n";
 		}
-		if (($not_only = $only_app[0] == '!'))
+		if (!empty($only_app) && ($not_only = $only_app[0] == '!'))
 		{
 			$only_app = substr($only_app,1);
 		}
@@ -173,7 +173,7 @@ class Storage
 		catch(Api\Db\Exception $e) {
 			_egw_log_exception($e);
 		}
-		return is_array($id) ? $links : ($links[$id] ? $links[$id] : array());
+		return is_array($id) ? $links : ($links[$id] ?? []);
 	}
 
 	private static function _add2links($row,$left,$only_app,$not_only,array &$links)
diff --git a/api/src/Mail.php b/api/src/Mail.php
index 1731ecd4a9..295b524efe 100644
--- a/api/src/Mail.php
+++ b/api/src/Mail.php
@@ -414,14 +414,10 @@ class Mail
 		{
 			//error_log(__METHOD__." Session restore ".function_backtrace());
 			$this->restoreSessionData();
-			$lv_mailbox = $this->sessionData['mailbox'];
-			$firstMessage = $this->sessionData['previewMessage'];
 		}
 		else
 		{
 			$this->restoreSessionData();
-			$lv_mailbox = $this->sessionData['mailbox'];
-			$firstMessage = $this->sessionData['previewMessage'];
 			$this->sessionData = array();
 		}
 		if (!$_reuseCache) $this->forcePrefReload($_profileID,!$_reuseCache);
@@ -1000,9 +996,7 @@ class Mail
 	 */
 	static function getTimeOut($_use='IMAP')
 	{
-		$timeout = $GLOBALS['egw_info']['user']['preferences']['mail']['connectionTimeout'];
-		if (empty($timeout)) $timeout = ($_use=='SIEVE'?10:20); // this is the default value
-		return $timeout;
+		return $_use=='SIEVE' ? 10 : 20; // this is the default value
 	}
 
 	/**
@@ -3211,7 +3205,7 @@ class Mail
 			}
 			//error_log(__METHOD__.__LINE__.array2string($autoFolderObjects));
 			if (!$isGoogleMail) {
-				$folders = array_merge($inboxFolderObject,$autoFolderObjects,(array)$inboxSubFolderObjects,(array)$folders,(array)$typeFolderObject['others'] ?? [],(array)$typeFolderObject['shared'] ?? []);
+				$folders = array_merge($inboxFolderObject,$autoFolderObjects,(array)$inboxSubFolderObjects,(array)$folders,(array)($typeFolderObject['others'] ?? []),(array)($typeFolderObject['shared'] ?? []));
 			} else {
 				// avoid calling sortByAutoFolder as it is not regarding subfolders
 				$gAutoFolderObjectsTmp = $googleAutoFolderObjects;
@@ -5589,10 +5583,10 @@ class Mail
 		if (empty($_folder)) $_folder = $this->sessionData['mailbox']?: $this->icServer->getCurrentMailbox();
 		$_uid = !(is_object($_uid) || is_array($_uid)) ? (array)$_uid : $_uid;
 
-		if (!$_stream && isset($rawBody[$this->icServer->ImapServerId][$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)]))
+		if (!$_stream && isset($rawBody[$this->icServer->ImapServerId][(string)$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)]))
 		{
 			//error_log(__METHOD__.' ('.__LINE__.') '." Using Cache for raw Body $_uid, $_partID in Folder $_folder");
-			return $rawBody[$this->icServer->ImapServerId][$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)];
+			return $rawBody[$this->icServer->ImapServerId][(string)$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)];
 		}
 
 		$uidsToFetch = new Horde_Imap_Client_Ids();
@@ -5629,7 +5623,7 @@ class Mail
 		if (!$_stream)
 		{
 			//error_log(__METHOD__.' ('.__LINE__.') '."[{$this->icServer->ImapServerId}][$_folder][$_uid][".(empty($_partID)?'NIL':$_partID)."]");
-			$rawBody[$this->icServer->ImapServerId][$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)] = $body;
+			$rawBody[$this->icServer->ImapServerId][(string)$_folder][$_uid[0]][(empty($_partID)?'NIL':$_partID)] = $body;
 		}
 		return $body;
 	}
@@ -6775,6 +6769,8 @@ class Mail
 		//error_log(__METHOD__."()");
 		$imageC = 0;
 		$images = null;
+		$attachments = null;
+
 		if (preg_match_all("/(src|background)=\"(.*)\"/Ui", $_html2parse, $images) && isset($images[2]))
 		{
 			foreach($images[2] as $i => $url)
@@ -6782,18 +6778,18 @@ class Mail
 				//$isData = false;
 				$basedir = $data = '';
 				$needTempFile = true;
-
+				$attachmentData = ['name' => '', 'type' => '', 'file' => '', 'tmp_name' => ''];
 				try
 				{
 					// do not change urls for absolute images (thanks to corvuscorax)
-					if (substr($url, 0, 5) !== 'data:')
+					if (!str_starts_with($url, 'data:'))
 					{
-						$filename = basename($url); // need to resolve all sort of url
+						$attachmentData['name'] = basename($url); // need to resolve all sort of url
 						if (($directory = dirname($url)) == '.') $directory = '';
-						$ext = pathinfo($filename, PATHINFO_EXTENSION);
-						$mimeType  = MimeMagic::ext2mime($ext);
-						if ( strlen($directory) > 1 && substr($directory,-1) != '/') { $directory .= '/'; }
-						$myUrl = $directory.$filename;
+						$ext = pathinfo($attachmentData['name'], PATHINFO_EXTENSION);
+						$attachmentData['type'] = MimeMagic::ext2mime($ext);
+						if ( strlen($directory) > 1 && !str_ends_with($directory, '/')) { $directory .= '/'; }
+						$myUrl = $directory.$attachmentData['name'];
 						if ($myUrl[0]=='/') // local path -> we only allow path's that are available via http/https (or vfs)
 						{
 							$basedir = Framework::getUrl('/');
@@ -6801,7 +6797,7 @@ class Mail
 						// use vfs instead of url containing webdav.php
 						// ToDo: we should test if the webdav url is of our own scope, as we cannot handle foreign
 						// webdav.php urls as vfs
-						if (strpos($myUrl,'/webdav.php') !== false) // we have a webdav link, so we build a vfs/sqlfs link of it.
+						if (str_contains($myUrl, '/webdav.php')) // we have a webdav link, so we build a vfs/sqlfs link of it.
 						{
 							Vfs::load_wrapper('vfs');
 							list(,$myUrl) = explode('/webdav.php',$myUrl,2);
@@ -6811,7 +6807,7 @@ class Mail
 
 						// If it is an inline image url, we need to fetch the actuall attachment
 						// content and later on to be able to store its content as temp file
-						if (strpos($myUrl, '/index.php?menuaction=mail.mail_ui.displayImage') !== false && $mail_bo)
+						if ($mail_bo && str_contains($myUrl, '/index.php?menuaction=mail.mail_ui.displayImage'))
 						{
 							$URI_params = array();
 							// Strips the url and store it into a temp for further procss
@@ -6826,50 +6822,50 @@ class Mail
 								if ($attachment)
 								{
 									$data = $attachment->getContents();
-									$mimeType = $attachment->getType();
-									$filename = $attachment->getDispositionParameter('filename');
+									$attachmentData['type'] = $attachment->getType();
+									$attachmentData['name'] = $attachment->getDispositionParameter('filename');
 								}
 							}
 						}
 
-						if ( strlen($basedir) > 1 && substr($basedir,-1) != '/' && $myUrl[0]!='/') { $basedir .= '/'; }
-						if ($needTempFile && !$attachment && substr($myUrl,0,4) !== "http") $data = file_get_contents($basedir.urldecode($myUrl));
+						if ( $myUrl[0]!='/' && strlen($basedir) > 1 && !str_ends_with($basedir, '/')) { $basedir .= '/'; }
+						if ($needTempFile && !$attachment && !str_starts_with($myUrl, "http")) $data = file_get_contents($basedir.urldecode($myUrl));
 					}
-					if (substr($url,0,strlen('data:'))=='data:')
+					if (str_starts_with($url, 'data:'))
 					{
 						//error_log(__METHOD__.' ('.__LINE__.') '.' -> '.$i.': '.array2string($images[$i]));
 						// we only support base64 encoded data
 						$tmp = substr($url,strlen('data:'));
-						list($mimeType,$data_base64) = explode(';base64,',$tmp);
+						list($attachmentData['type'],$data_base64) = explode(';base64,',$tmp);
 						$data = base64_decode($data_base64);
 						// FF currently does NOT add any mime-type
-						if (strtolower(substr($mimeType, 0, 6)) != 'image/')
+						if (strtolower(substr($attachmentData['type'], 0, 6)) != 'image/')
 						{
-							$mimeType = MimeMagic::analyze_data($data);
+							$attachmentData['type'] = MimeMagic::analyze_data($data);
 						}
-						list($what,$exactly) = explode('/',$mimeType);
+						list($what,$exactly) = explode('/',$attachmentData['type']);
 						$needTempFile = true;
-						$filename = ($what?$what:'data').$imageC++.'.'.$exactly;
+						$attachmentData['name'] = ($what ?: 'data').$imageC++.'.'.$exactly;
 					}
 					if ($data || $needTempFile === false)
 					{
 						if ($needTempFile)
 						{
-							$attachment_file =tempnam($GLOBALS['egw_info']['server']['temp_dir'],$GLOBALS['egw_info']['flags']['currentapp']."_");
-							$tmpfile = fopen($attachment_file,'w');
+							$attachmentData['file'] =tempnam($GLOBALS['egw_info']['server']['temp_dir'],$GLOBALS['egw_info']['flags']['currentapp']."_");
+							$tmpfile = fopen($attachmentData['file'],'w');
 							fwrite($tmpfile,$data);
 							fclose($tmpfile);
 						}
 						else
 						{
-							$attachment_file = $basedir.urldecode($myUrl);
+							$attachmentData['file'] = $basedir.urldecode($myUrl);
 						}
-						// we use $attachment_file as base for cid instead of filename, as it may be image.png
+						// we use $attachmentData['file'] as base for cid instead of filename, as it may be image.png
 						// (or similar) in all cases (when cut&paste). This may lead to more attached files, in case
 						// we use the same image multiple times, but, if we do this, we should try to detect that
 						// on upload. filename itself is not sufficient to determine the sameness of images
-						$cid = 'cid:' . md5($attachment_file);
-						if ($_mailObject->AddEmbeddedImage($attachment_file, substr($cid, 4), urldecode($filename), $mimeType) !== null)
+						$cid = 'cid:' . md5($attachmentData['file']);
+						if ($_mailObject->AddEmbeddedImage($attachmentData['file'], substr($cid, 4), urldecode($attachmentData['file']), $attachmentData['type']) !== null)
 						{
 							//$_html2parse = preg_replace("/".$images[1][$i]."=\"".preg_quote($url, '/')."\"/Ui", $images[1][$i]."=\"".$cid."\"", $_html2parse);
 							$_html2parse = str_replace($images[0][$i], $images[1][$i].'="'.$cid.'"', $_html2parse);
@@ -6882,12 +6878,8 @@ class Mail
 					error_log("Error adding inline attachment.  " . $e->getMessage());
 					error_log($e->getTraceAsString());
 				}
-				$attachments [] = array(
-					'name' => $filename,
-					'type' => $mimeType,
-					'file' => $attachment_file,
-					'tmp_name' => $attachment_file
-				);
+				$attachmentData['tmp_name'] = $attachmentData['file'];
+				$attachments [] = $attachmentData;
 			}
 			return is_array($attachments) ? $attachments : null;
 		}
diff --git a/api/src/Mail/Account.php b/api/src/Mail/Account.php
index c535b39026..3fcc988a9a 100644
--- a/api/src/Mail/Account.php
+++ b/api/src/Mail/Account.php
@@ -549,7 +549,7 @@ class Account implements \ArrayAccess
 					$row = array_merge($row, Credentials::from_session($row));
 				}
 				// fill an empty ident_realname or ident_email of current user with data from user account
-				if ($replace_placeholders && (!isset($user) || $user == $GLOBALS['egw_info']['user']['acount_id']))
+				if ($replace_placeholders && (!isset($user) || $user == $GLOBALS['egw_info']['user']['account_id']))
 				{
 					if (empty($row['ident_realname'])) $row['ident_realname'] = $GLOBALS['egw_info']['user']['account_fullname'];
 					if (empty($row['ident_email'])) $row['ident_email'] = $GLOBALS['egw_info']['user']['account_email'];
@@ -737,13 +737,13 @@ class Account implements \ArrayAccess
 
 						if (empty($data['ident_email']) && $is_current_user)
 						{
-							$data['ident_email'] = $GLOBALS['egw_info']['user']['account_email'];
+							$data['ident_email'] = $GLOBALS['egw_info']['user']['account_email'] ?? null;
 						}
 					}
 					if (empty($data['ident_realname']))
 					{
 						$data['ident_realname'] = $account->ident_realname || !$is_current_user ?
-							$account->ident_realname : $GLOBALS['egw_info']['user']['account_fullname'];
+							$account->ident_realname : ($GLOBALS['egw_info']['user']['account_fullname'] ?? null);
 					}
 				}
 			}
@@ -1414,7 +1414,7 @@ class Account implements \ArrayAccess
 		{
 			// for current user prefer account with ident_email matching user email or domain
 			// (this also helps notifications to account allowing to send with from address of current user / account_email)
-			if ($only_current_user && $GLOBALS['egw_info']['user']['account_email'])
+			if ($only_current_user && !empty($GLOBALS['egw_info']['user']['account_email']))
 			{
 				list(,$domain) = explode('@', $account_email = $GLOBALS['egw_info']['user']['account_email']);
 				// empty ident_email will be replaced with account_email!
diff --git a/api/src/Mail/Credentials.php b/api/src/Mail/Credentials.php
index 091506be30..a5482d24b9 100644
--- a/api/src/Mail/Credentials.php
+++ b/api/src/Mail/Credentials.php
@@ -265,10 +265,10 @@ class Credentials
 				throw new Api\Exception\WrongParameter("Unknown data[acc_imap_logintype]=".array2string($data['acc_imap_logintype']).'!');
 		}
 		$password = base64_decode(Api\Cache::getSession('phpgwapi', 'password'));
-		$realname = !$set_identity || $data['ident_realname'] ? $data['ident_realname'] :
-			$GLOBALS['egw_info']['user']['account_fullname'];
-		$email = !$set_identity || $data['ident_email'] ? $data['ident_email'] :
-			$GLOBALS['egw_info']['user']['account_email'];
+		$realname = !$set_identity || !empty($data['ident_realname']) ? $data['ident_realname'] :
+			($GLOBALS['egw_info']['user']['account_fullname'] ?? null);
+		$email = !$set_identity || !empty($data['ident_email']) ? $data['ident_email'] :
+			($GLOBALS['egw_info']['user']['account_email'] ?? null);
 
 		return array(
 			'ident_realname' => $realname,
diff --git a/api/src/Mail/Html.php b/api/src/Mail/Html.php
index 9f134cdd89..667b845e4f 100644
--- a/api/src/Mail/Html.php
+++ b/api/src/Mail/Html.php
@@ -169,7 +169,7 @@ class Html
 				if ($addbracesforendtag === true )
 				{
 					if (stripos($_body,'<'.$tag)!==false)  $ct = preg_match_all('#<'.$tag.'(?:\s.*)?>(.+)</'.$endtag.'>#isU', $_body, $found);
-					if ($ct>0)
+					if (isset($ct) && $ct>0)
 					{
 						//error_log(__METHOD__.__LINE__.array2string($found[0]));
 						// only replace what we have found
@@ -495,7 +495,7 @@ class Html
 		$html =  preg_replace('/&(?!#?[a-zA-Z0-9]+;)/', '&amp;', $html);
 
 		$dom = new \DOMDocument('1.0','UTF-8');
-		if(!$dom->loadHTML(
+		if (!@$dom->loadHTML(
 			'<?xml encoding="UTF-8">'. Api\Translation::convert($html,preg_match('/<meta[^>]+content="[^>"]+charset=([^;"]+)/i', $html, $matches) ? $matches[1] : false, 'utf8'),
 			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOBLANKS
 		))
diff --git a/api/src/Mail/Imap.php b/api/src/Mail/Imap.php
index 8a9ddd8780..ecd2bcdcbc 100644
--- a/api/src/Mail/Imap.php
+++ b/api/src/Mail/Imap.php
@@ -360,9 +360,7 @@ class Imap extends Horde_Imap_Client_Socket implements Imap\PushIface
 	 */
 	static function getTimeOut($_use='IMAP')
 	{
-		$timeout = $GLOBALS['egw_info']['user']['preferences']['mail']['connectionTimeout'];
-		if (empty($timeout) || !($timeout > 0)) $timeout = $_use == 'SIEVE' ? 10 : 20; // this is the default value
-		return $timeout;
+		return $_use == 'SIEVE' ? 10 : 20; // this is the default value
 	}
 
 	/**
@@ -742,7 +740,7 @@ class Imap extends Horde_Imap_Client_Socket implements Imap\PushIface
 	 * @param   string  $returnAttributes   true means return an assoc array containing mailbox names and mailbox attributes
 	 *                                      false - the default - means return an array of mailboxes with only selected attributes like delimiter
 	 *
-	 * @return  mixed   array of mailboxes
+	 * @return  ?array   array of mailboxes or null
 	 */
 	function listSubscribedMailboxes($reference = ''  , $restriction_search = 0, $returnAttributes = false)
 	{
@@ -794,10 +792,10 @@ class Imap extends Horde_Imap_Client_Socket implements Imap\PushIface
 			}
 			else
 			{
-				$ret[$k]=array('MAILBOX'=>$k,'ATTRIBUTES'=>$box['attributes'],'delimiter'=>($box['delimiter']?$box['delimiter']:$this->getDelimiter('personal')),'SUBSCRIBED'=>true);
+				$ret[$k]=array('MAILBOX'=>$k,'ATTRIBUTES'=>$box['attributes'],'delimiter'=>($box['delimiter']?:$this->getDelimiter('personal')),'SUBSCRIBED'=>true);
 			}
 		}
-		return $ret;
+		return $ret ?? null;
 	}
 
 	/**
@@ -1376,6 +1374,7 @@ class Imap extends Horde_Imap_Client_Socket implements Imap\PushIface
 			case 'retrieveRules':
 			case 'getVacation':
 			case 'setVacation':
+			case 'getExtensions':
 				if (is_null($this->sieve))
 				{
 					$this->sieve = new Sieve($this);
diff --git a/api/src/Mail/Notifications.php b/api/src/Mail/Notifications.php
index d1a1d02301..54b8343967 100644
--- a/api/src/Mail/Notifications.php
+++ b/api/src/Mail/Notifications.php
@@ -73,7 +73,7 @@ class Notifications
 		$account_specific = 0;
 		foreach($rows as $row)
 		{
-			if ($row['account_id'])
+			if (!empty($row['account_id']))
 			{
 				$account_specific = $row['account_id'];
 			}
@@ -82,7 +82,7 @@ class Notifications
 			{
 				self::$cache[$acc_id][$row['account_id']][] = $row['notif_folder'];
 			} // make sure set the account_specific correctly when notify_folder gets removed
-			elseif (!$row['account_id'] && !is_array($account_id) && is_array($rows[$account_id]))
+			elseif (empty($row['account_id']) && !is_array($account_id) && is_array($rows[$account_id]))
 			{
 				$account_specific = $account_id;
 			}
diff --git a/api/src/Mail/Script.php b/api/src/Mail/Script.php
index 28fdf9823c..a70d00e9d4 100644
--- a/api/src/Mail/Script.php
+++ b/api/src/Mail/Script.php
@@ -34,6 +34,7 @@ class Script
 	var $emailNotification; /* email notification settings. */
 	var $pcount;       /* highest priority value in ruleset. */
 	var $errstr;       /* error text. */
+	var $extensions;	/* contains extensions status*/
 	/**
 	 * Body transform content types
 	 *
@@ -69,6 +70,21 @@ class Script
 		$this->emailNotification = array(); // Added email notifications
 		$this->pcount = 0;
 		$this->errstr = '';
+		$this->extensions = [];
+	}
+
+	private function _setExtensionsStatus(Sieve $connection)
+	{
+		$this->extensions = [
+			'vacation' => $connection->hasExtension('vacation'),
+			'regex' => $connection->hasExtension('regex'),
+			'enotify' => $connection->hasExtension('enotify'),
+			'body' => $connection->hasExtension('body'),
+			'variables' => $connection->hasExtension('variables'),
+			'date' => $connection->hasExtension('date'),
+			'imap4flags' => $connection->hasExtension('imap4flags'),
+			'relational' => $connection->hasExtension('relational'),
+		];
 	}
 
 	// get sieve script rules for this user
@@ -86,6 +102,7 @@ class Script
 		$anyofbit = 4;
 		$keepbit = 8;
 		$regexbit = 128;
+		$this->_setExtensionsStatus($connection);
 
 		if (!isset($this->name)){
 			$this->errstr = 'retrieveRules: no script name specified';
@@ -150,10 +167,10 @@ class Script
 						$rule['anyof']		= ($bits[8] & $anyofbit);
 						$rule['keep']		= ($bits[8] & $keepbit);
 						$rule['regexp']		= ($bits[8] & $regexbit);
-						$rule['bodytransform'] = ($bits[12]);
-						$rule['field_bodytransform'] = ($bits[13]);
-						$rule['ctype'] = ($bits[14]);
-						$rule['field_ctype_val'] = ($bits[15]);
+						$rule['bodytransform'] = ($bits[12]??null);
+						$rule['field_bodytransform'] = ($bits[13]??null);
+						$rule['ctype'] = ($bits[14]??null);
+						$rule['field_ctype_val'] = ($bits[15]??null);
 						$rule['unconditional']	= 0;
 						if (!$rule['from'] && !$rule['to'] && !$rule['subject'] &&
 							!$rule['field'] && !$rule['size'] && $rule['action']) {
@@ -188,7 +205,7 @@ class Script
 							}
 							$vacation['addresses'] = &$vaddresses;
 
-							$vacation['forwards'] = $bits[5];
+							$vacation['forwards'] = $bits[5]??null;
 						}
 						break;
 					case "notify":
@@ -236,7 +253,6 @@ class Script
 
 		$activerules = 0;
 		$regexused = 0;
-		$regexsupported = true;
 		$rejectused = 0;
 		$vacation_active = false;
 
@@ -245,13 +261,10 @@ class Script
 
 		//include "$default->lib_dir/version.php";
 
-		// lets generate the main body of the script from our rules
-		$enotify = $variables= $supportsbody = false;
-		if ($connection->hasExtension('enotify')) $enotify = true;
-		if ($connection->hasExtension('variables')) $variables = true;
-		if ($connection->hasExtension('body')) $supportsbody = true;
-		if (!$connection->hasExtension('vacation')) $this->vacation = false;
-		if (!$connection->hasExtension('regex')) $regexsupported = false;
+		// set extensions status
+		$this->_setExtensionsStatus($connection);
+
+		if (!$this->extensions['vacation']) $this->vacation = false;
 
 		$newscriptbody = "";
 		$continue = 1;
@@ -334,7 +347,7 @@ class Script
 								$newruletext .= "size " . $xthan . $rule['size'] . "K";
 								$started = 1;
 						}
-						if ($supportsbody){
+						if ($this->extensions['body']){
 							if (!empty($rule['field_bodytransform'])){
 								if ($started) $newruletext .= ", ";
 								$btransform	= " :raw ";
@@ -379,6 +392,9 @@ class Script
 				if (preg_match("/discard/i",$rule['action'])) {
 						$newruletext .= "discard;";
 				}
+				if (preg_match("/flags/i",$rule['action'])) {
+					$newruletext .= "addflag \"".$rule['action_arg']."\";";
+				}
 				if ($rule['keep']) $newruletext .= "\n\tkeep;";
 				if (!$rule['unconditional']) $newruletext .= "\n}";
 
@@ -417,7 +433,7 @@ class Script
 				$vacation_active = true;
 				if ($vacation['text'])
 				{
-					if ($regexsupported)
+					if ($this->extensions['regex'])
 					{
 						$newscriptbody .= "if header :regex ".'"X-Spam-Status" '.'"\\\\bYES\\\\b"'."{\n\tstop;\n}\n"; //stop vacation reply if it is spam
 						$regexused = 1;
@@ -441,17 +457,17 @@ class Script
 					}
 					$newscriptbody .= "\tkeep;\n}\n";
 				}
-				$newscriptbody .= "vacation :days " . $vacation['days'];
+				$vac_rule = "vacation :days " . $vacation['days'];
 				$first = 1;
 				if (!empty($vacation['addresses'][0]))
 				{
-					$newscriptbody .=  " :addresses [";
+					$vac_rule .=  " :addresses [";
 					foreach ($vacation['addresses'] as $vaddress) {
-							if (!$first) $newscriptbody .= ", ";
-							$newscriptbody .= "\"" . trim($vaddress) . "\"";
+							if (!$first) $vac_rule .= ", ";
+							$vac_rule .= "\"" . trim($vaddress) . "\"";
 							$first = 0;
 					}
-					$newscriptbody .=  "] ";
+					$vac_rule .=  "] ";
 				}
 				$message = $vacation['text'];
 				if ($vacation['start_date'] || $vacation['end_date'])
@@ -463,7 +479,20 @@ class Script
 							date($format_date,$vacation['end_date']),
 						),$message);
 				}
-				$newscriptbody .= " text:\n" . $message . "\n.\n;\n\n";
+				$vac_rule .= " text:\n" . $message . "\n.\n;\n\n";
+				if ($this->extensions['date'] && $vacation['start_date'] && $vacation['end_date'])
+				{
+					$newscriptbody .= "if allof (\n".
+					"currentdate :value \"ge\" \"date\" \"". date('Y-m-d', $vacation['start_date']) ."\",\n".
+					"currentdate :value \"le\" \"date\" \"". date('Y-m-d', $vacation['end_date']) ."\")\n".
+					"{\n".
+						$vac_rule."\n".
+					"}\n";
+				}
+				else
+				{
+					$newscriptbody .= $vac_rule;
+				}
 			}
 
 			// update with any changes.
@@ -476,10 +505,10 @@ class Script
 
 			// format notification body
 			$egw_site_title = $GLOBALS['egw_info']['server']['site_title'];
-			if ($enotify==true)
+			if ($this->extensions['enotify']==true)
 			{
 				$notification_body = lang("You have received a new message on the")." {$egw_site_title}";
-				if ($variables)
+				if ($this->extensions['variables'])
 				{
 					$notification_body .= ", ";
 					$notification_body .= 'From: ${from}';
@@ -522,13 +551,18 @@ class Script
 
 		if ($activerules) {
 			$newscripthead .= "require [\"fileinto\"";
-			if ($regexsupported && $regexused) $newscripthead .= ",\"regex\"";
+			if ($this->extensions['regex'] && $regexused) $newscripthead .= ",\"regex\"";
 			if ($rejectused) $newscripthead .= ",\"reject\"";
 			if ($this->vacation && $vacation_active) {
 				$newscripthead .= ",\"vacation\"";
 			}
-			if ($supportsbody) $newscripthead .= ",\"body\"";
-			if ($this->emailNotification && $this->emailNotification['status'] == 'on') $newscripthead .= ',"'.($enotify?'e':'').'notify"'.($variables?',"variables"':''); // Added email notifications
+			if ($this->extensions['body']) $newscripthead .= ",\"body\"";
+			if ($this->extensions['date']) $newscripthead .= ",\"date\"";
+			if ($this->extensions['relational']) $newscripthead .= ",\"relational\"";
+			if ($this->extensions['variables']) $newscripthead .= ",\"variables\"";
+			if ($this->extensions['imap4flags']) $newscripthead .= ",\"imap4flags\"";
+
+			if ($this->emailNotification && $this->emailNotification['status'] == 'on') $newscripthead .= ',"'.($this->extensions['enotify']?'e':'').'notify"'.($this->extensions['variables']?',"variables"':''); // Added email notifications
 			$newscripthead .= "];\n\n";
 		} else {
 			// no active rules, but might still have an active vacation rule
@@ -536,18 +570,21 @@ class Script
 			if ($this->vacation && $vacation_active)
 			{
 				$newscripthead .= "require [\"vacation\"";
-				if ($regexsupported && $regexused) $newscripthead .= ",\"regex\"";
+				if ($this->extensions['regex'] && $regexused) $newscripthead .= ",\"regex\"";
+				if ($this->extensions['date']) $newscripthead .= ",\"date\"";
+				if ($this->extensions['relational']) $newscripthead .= ",\"relational\"";
+
 				$closeRequired=true;
 			}
 			if ($this->emailNotification && $this->emailNotification['status'] == 'on')
 			{
 				if ($this->vacation && $vacation_active)
 				{
-					$newscripthead .= ",\"".($enotify?'e':'')."notify\"".($variables?',"variables"':'')."];\n\n"; // Added email notifications
+					$newscripthead .= ",\"".($this->extensions['enotify']?'e':'')."notify\"".($this->extensions['variables']?',"variables"':'')."];\n\n"; // Added email notifications
 				}
 				else
 				{
-					$newscripthead .= "require [\"".($enotify?'e':'')."notify\"".($variables?',"variables"':'')."];\n\n"; // Added email notifications
+					$newscripthead .= "require [\"".($this->extensions['enotify']?'e':'')."notify\"".($this->extensions['variables']?',"variables"':'')."];\n\n"; // Added email notifications
 				}
 			}
 			if ($closeRequired) $newscripthead .= "];\n\n";
@@ -570,7 +607,7 @@ class Script
 				$newscriptfoot .= "#rule&&" . $rule['priority'] . "&&" . $rule['status'] . "&&" .
 				addslashes($rule['from']) . "&&" . addslashes($rule['to']) . "&&" . addslashes($rule['subject']) . "&&" . $rule['action'] . "&&" .
 				$rule['action_arg'] . "&&" . $rule['flg'] . "&&" . addslashes($rule['field']) . "&&" . addslashes($rule['field_val']) . "&&" . $rule['size'];
-				if ($supportsbody && (!empty($rule['field_bodytransform']) || ($rule['ctype']!= '0' && !empty($rule['ctype'])))) $newscriptfoot .= "&&" . $rule['bodytransform'] . "&&" . $rule['field_bodytransform']. "&&" . $rule['ctype'] . "&&" . $rule['field_ctype_val'];
+				if ($this->extensions['body'] && (!empty($rule['field_bodytransform']) || ($rule['ctype']!= '0' && !empty($rule['ctype'])))) $newscriptfoot .= "&&" . $rule['bodytransform'] . "&&" . $rule['field_bodytransform']. "&&" . $rule['ctype'] . "&&" . $rule['field_ctype_val'];
 				$newscriptfoot .= "\n";
 				$pcount = $pcount+2;
 				//error_log(__CLASS__."::".__METHOD__.__LINE__.array2string($newscriptfoot));
@@ -616,7 +653,7 @@ class Script
 		}
 		catch (\Exception $e) {
 			$this->errstr = 'updateScript: putscript failed: ' . $e->getMessage().($e->details?': '.$e->details:'');
-			if ($regexused&&!$regexsupported) $this->errstr .= " REGEX is not an supported CAPABILITY";
+			if ($regexused && !$this->extensions['regex']) $this->errstr .= " REGEX is not an supported CAPABILITY";
 			error_log(__METHOD__.__LINE__.' # Error: ->'.$this->errstr);
 			error_log(__METHOD__.__LINE__.' # ScriptName:'.$this->name.' Script:'.$newscript);
 			error_log(__METHOD__.__LINE__.' # Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid']);
diff --git a/api/src/Mail/Smtp/Sql.php b/api/src/Mail/Smtp/Sql.php
index 83a443cf83..7e20377cf7 100644
--- a/api/src/Mail/Smtp/Sql.php
+++ b/api/src/Mail/Smtp/Sql.php
@@ -94,7 +94,7 @@ class Sql extends Mail\Smtp
 				);
 			}
 		}
-		if ($this->debug) error_log(__METHOD__."('$_accountName') returning ".array2string($emailAddresses));
+		if (!empty($this->debug)) error_log(__METHOD__."('$_accountName') returning ".array2string($emailAddresses));
 
 		return $emailAddresses;
 	}
@@ -191,7 +191,7 @@ class Sql extends Mail\Smtp
 					case self::TYPE_MAILBOX:
 						$userData['mailMessageStore'] = $row['mail_value'];
 						//error_log(__METHOD__."('$user') row=".array2string($row).', enabled[$row[account_id]]='.array2string($enabled[$row['account_id']]).', forwardOnly[$row[account_id]]='.array2string($forwardOnly[$row['account_id']]));
-						if ($row['account_id'] > 0 && $enabled[$row['account_id']] && !$forwardOnly[$row['account_id']])
+						if ($row['account_id'] > 0 && !empty($enabled[$row['account_id']]) && empty($forwardOnly[$row['account_id']]))
 						{
 							$userData['uid'][] = $this->accounts->id2name($row['account_id'], 'account_lid');
 							$userData['mailbox'][] = $row['mail_value'];
@@ -218,7 +218,7 @@ class Sql extends Mail\Smtp
 				}
 			}
 		}
-		if ($this->debug) error_log(__METHOD__."('$user') returning ".array2string($userData));
+		if (!empty($this->debug)) error_log(__METHOD__."('$user') returning ".array2string($userData));
 
 		return $userData;
 	}
@@ -240,7 +240,7 @@ class Sql extends Mail\Smtp
 	function setUserData($_uidnumber, array $_mailAlternateAddress, array $_mailForwardingAddress, $_deliveryMode,
 		$_accountStatus, $_mailLocalAddress, $_quota, $_forwarding_only=false, $_setMailbox=null)
 	{
-		if ($this->debug) error_log(__METHOD__."($_uidnumber, ".array2string($_mailAlternateAddress).', '.array2string($_mailForwardingAddress).", '$_deliveryMode', '$_accountStatus', '$_mailLocalAddress', $_quota, forwarding_only=".array2string($_forwarding_only).') '.function_backtrace());
+		if (!empty($this->debug)) error_log(__METHOD__."($_uidnumber, ".array2string($_mailAlternateAddress).', '.array2string($_mailForwardingAddress).", '$_deliveryMode', '$_accountStatus', '$_mailLocalAddress', $_quota, forwarding_only=".array2string($_forwarding_only).') '.function_backtrace());
 
 		if (!$_forwarding_only && $this->accounts->id2name($_uidnumber, 'account_email') !== $_mailLocalAddress)
 		{
diff --git a/api/src/Mailer.php b/api/src/Mailer.php
index d002c5d1cd..75fe433c37 100644
--- a/api/src/Mailer.php
+++ b/api/src/Mailer.php
@@ -564,7 +564,7 @@ class Mailer extends Horde_Mime_Mail
 			if (!isset($flowed)) $flowed = $this->_body && !in_array($this->_body->getType(), array('multipart/encrypted', 'multipart/signed'));
 
 			// check if flowed is disabled in mail site configuration
-			if (($config = Config::read('mail')) && $config['disable_rfc3676_flowed'])
+			if (($config = Config::read('mail')) && !empty($config['disable_rfc3676_flowed']))
 			{
 				$flowed = false;
 			}
@@ -616,7 +616,7 @@ class Mailer extends Horde_Mime_Mail
 		}
 
 		// log mails to file specified in $GLOBALS['egw_info']['server']['log_mail'] or error_log for true
-		if ($GLOBALS['egw_info']['server']['log_mail'])
+		if (!empty($GLOBALS['egw_info']['server']['log_mail']))
 		{
 			$msg = $GLOBALS['egw_info']['server']['log_mail'] !== true ? date('Y-m-d H:i:s')."\n" : '';
 			$msg .= (!isset($e) ? 'Mail send' : 'Mail NOT send').
@@ -732,7 +732,7 @@ class Mailer extends Horde_Mime_Mail
 				$recipients->add($h->getAddressList());
 			}
 		}
-		if ($this->_bcc) {
+		if (!empty($this->_bcc)) {
 			$recipients->add($this->_bcc);
 		}
 
diff --git a/api/src/Session.php b/api/src/Session.php
index 29d6692975..f9f3a4bec1 100644
--- a/api/src/Session.php
+++ b/api/src/Session.php
@@ -1538,7 +1538,7 @@ class Session
 		}
 
 		// check if the url already contains a query and ensure that vars is an array and all strings are in extravars
-		if (strpos($ret_url=$url, '?') !== false) list($ret_url,$othervars) = explode('?', $url, 2)+[null,null];
+		list($ret_url,$othervars) = explode('?', $url, 2)+[null,null];
 		if ($extravars && is_array($extravars))
 		{
 			$vars += $extravars;
diff --git a/api/src/Storage.php b/api/src/Storage.php
index 6695b9b95b..42b31fb1a4 100644
--- a/api/src/Storage.php
+++ b/api/src/Storage.php
@@ -674,7 +674,7 @@ class Storage extends Storage\Base
 				elseif (is_string($name) && $val!=null && in_array($name, $this->db_cols))
 				{
 					$extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table);
-					if ($extra_columns['fd'][array_search($name, $this->db_cols)])
+					if (!empty($extra_columns['fd'][array_search($name, $this->db_cols)]))
 					{
 						$filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array(
 							array_search($name, $this->db_cols) => $val,
diff --git a/api/src/Storage/Base.php b/api/src/Storage/Base.php
index 2bfb54d884..b75e0ff8f4 100644
--- a/api/src/Storage/Base.php
+++ b/api/src/Storage/Base.php
@@ -1034,14 +1034,14 @@ class Base
 					$this->total = $this->db->select($this->table_name,$colums,$query,__LINE__,__FILE__,false,$order_by,false,0,$join)->NumRows();
 				}
 			}
-			$rs = $this->db->select($this->table_name,$mysql_calc_rows.$colums,$query,__LINE__,__FILE__,
+			$rs = $this->db->select($this->table_name,($mysql_calc_rows??'').$colums,$query,__LINE__,__FILE__,
 				$start,$order_by,$this->app,$num_rows,$join);
 			if ($this->debug) error_log(__METHOD__."() ".$this->db->Query_ID->sql);
 			$cols = $this->_get_columns($only_keys,$extra_cols);
 		}
 		if ((int) $this->debug >= 4) echo "<p>sql='{$this->db->Query_ID->sql}'</p>\n";
 
-		if ($mysql_calc_rows)
+		if (!empty($mysql_calc_rows))
 		{
 			$this->total = $this->db->query('SELECT FOUND_ROWS()')->fetchColumn();
 		}
@@ -1157,8 +1157,8 @@ class Base
 				}
 			}
 		}
-		if (is_array($query) && $op != 'AND') $query = $this->db->column_data_implode(' '.$op.' ',$query);
-		return $query;
+		if (!empty($query) && is_array($query) && $op != 'AND') $query = $this->db->column_data_implode(' '.$op.' ',$query);
+		return $query ?? null;
 	}
 
 	/**
diff --git a/api/src/Storage/Customfields.php b/api/src/Storage/Customfields.php
index 82d3b61d9f..fafa460512 100755
--- a/api/src/Storage/Customfields.php
+++ b/api/src/Storage/Customfields.php
@@ -190,7 +190,7 @@ class Customfields implements \IteratorAggregate
 	/**
 	 * Format a single custom field value as string
 	 *
-	 * @param array $field field defintion incl. type
+	 * @param array $field field definition incl. type
 	 * @param string $value field value
 	 * @return string formatted value
 	 */
@@ -204,7 +204,7 @@ class Customfields implements \IteratorAggregate
 					$values = array();
 					foreach($field['rows'] > 1 ? explode(',', $value) : (array) $value as $value)
 					{
-						$values[] = Api\Accounts::username($value);
+						$values[] = is_numeric($value) ? Api\Accounts::username($value) : $value;
 					}
 					$value = implode(', ',$values);
 				}
diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php
index ca01c49612..a83c1fc5e1 100644
--- a/api/src/Storage/Merge.php
+++ b/api/src/Storage/Merge.php
@@ -32,6 +32,16 @@ use ZipArchive;
  */
 abstract class Merge
 {
+	/**
+	 * Preference, path where we look for merge templates
+	 */
+	public const PREF_TEMPLATE_DIR = 'document_dir';
+
+	/**
+	 * Preference, path to special documents that are listed first
+	 */
+	public const PREF_DEFAULT_TEMPLATE = 'default_document';
+
 	/**
 	 * Preference, path where we will put the generated document
 	 */
@@ -104,21 +114,21 @@ abstract class Merge
 	public $export_limit;
 
 	public $public_functions = array(
-		"merge_entries"		=> true
+		"merge_entries" => true
 	);
 
 	/**
 	 * Configuration for HTML Tidy to clean up any HTML content that is kept
 	 */
 	public static $tidy_config = array(
-		'output-xml'		=> true,	// Entity encoding
-		'show-body-only'	=> true,
-		'output-encoding'	=> 'utf-8',
-		'input-encoding'	=> 'utf-8',
-		'quote-ampersand'	=> false,	// Prevent double encoding
-		'quote-nbsp'		=> true,	// XSLT can handle spaces easier
-		'preserve-entities'	=> true,
-		'wrap'			=> 0,		// Wrapping can break output
+		'output-xml'        => true,    // Entity encoding
+		'show-body-only'    => true,
+		'output-encoding'   => 'utf-8',
+		'input-encoding'    => 'utf-8',
+		'quote-ampersand'   => false,    // Prevent double encoding
+		'quote-nbsp'        => true,    // XSLT can handle spaces easier
+		'preserve-entities' => true,
+		'wrap'              => 0,        // Wrapping can break output
 	);
 
 	/**
@@ -156,8 +166,8 @@ abstract class Merge
 
 		$this->contacts = new Api\Contacts();
 
-		$this->datetime_format = $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'].' '.
-			($GLOBALS['egw_info']['user']['preferences']['common']['timeformat']==12 ? 'h:i a' : 'H:i');
+		$this->datetime_format = $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'] . ' ' .
+			($GLOBALS['egw_info']['user']['preferences']['common']['timeformat'] == 12 ? 'h:i a' : 'H:i');
 
 		$this->export_limit = self::getExportLimit();
 	}
@@ -171,8 +181,8 @@ abstract class Merge
 	{
 		$accountsel = new uiaccountsel();
 
-		return '<input type="hidden" value="" name="newsettings[export_limit_excepted]" />'.
-			$accountsel->selection('newsettings[export_limit_excepted]','export_limit_excepted',$config['export_limit_excepted'],'both',4);
+		return '<input type="hidden" value="" name="newsettings[export_limit_excepted]" />' .
+			$accountsel->selection('newsettings[export_limit_excepted]', 'export_limit_excepted', $config['export_limit_excepted'], 'both', 4);
 	}
 
 	/**
@@ -183,10 +193,10 @@ abstract class Merge
 	 * - format_datetime($time,$format=null)
 	 *
 	 * @param int $id id of entry
-	 * @param string &$content=null content to create some replacements only if they are use
+	 * @param string &$content =null content to create some replacements only if they are use
 	 * @return array|boolean array with replacements or false if entry not found
 	 */
-	abstract protected function get_replacements($id,&$content=null);
+	abstract protected function get_replacements($id, &$content = null);
 
 	/**
 	 * Return if merge-print is implemented for given mime-type (and/or extension)
@@ -194,47 +204,56 @@ abstract class Merge
 	 * @param string $mimetype eg. text/plain
 	 * @param string $extension only checked for applications/msword and .rtf
 	 */
-	static public function is_implemented($mimetype,$extension=null)
+	static public function is_implemented($mimetype, $extension = null)
 	{
-		static $zip_available=null;
-		if (is_null($zip_available))
+		static $zip_available = null;
+		if(is_null($zip_available))
 		{
 			$zip_available = check_load_extension('zip') &&
-				class_exists('ZipArchive');	// some PHP has zip extension, but no ZipArchive (eg. RHEL5!)
+				class_exists('ZipArchive');    // some PHP has zip extension, but no ZipArchive (eg. RHEL5!)
 		}
-		switch ($mimetype)
+		switch($mimetype)
 		{
 			case 'application/msword':
-				if (strtolower($extension) != '.rtf') break;
+				if(strtolower($extension) != '.rtf')
+				{
+					break;
+				}
 			case 'application/rtf':
 			case 'text/rtf':
-				return true;	// rtf files
-			case 'application/vnd.oasis.opendocument.text':	// oo text
-			case 'application/vnd.oasis.opendocument.spreadsheet':	// oo spreadsheet
+				return true;    // rtf files
+			case 'application/vnd.oasis.opendocument.text':    // oo text
+			case 'application/vnd.oasis.opendocument.spreadsheet':    // oo spreadsheet
 			case 'application/vnd.oasis.opendocument.presentation':
 			case 'application/vnd.oasis.opendocument.text-template':
 			case 'application/vnd.oasis.opendocument.spreadsheet-template':
 			case 'application/vnd.oasis.opendocument.presentation-template':
-				if (!$zip_available) break;
-				return true;	// open office write xml files
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms word 2007 xml format
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.d':	// mimetypes in vfs are limited to 64 chars
+				if(!$zip_available)
+				{
+					break;
+				}
+				return true;    // open office write xml files
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms word 2007 xml format
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.d':    // mimetypes in vfs are limited to 64 chars
 			case 'application/vnd.ms-word.document.macroenabled.12':
-			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':	// ms excel 2007 xml format
+			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':    // ms excel 2007 xml format
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.shee':
 			case 'application/vnd.ms-excel.sheet.macroenabled.12':
-				if (!$zip_available) break;
-				return true;	// ms word xml format
+				if(!$zip_available)
+				{
+					break;
+				}
+				return true;    // ms word xml format
 			case 'application/xml':
-				return true;	// alias for text/xml, eg. ms office 2003 word format
+				return true;    // alias for text/xml, eg. ms office 2003 word format
 			case 'message/rfc822':
 				return true; // ToDo: check if you are theoretical able to send mail
 			case 'application/x-yaml':
-				return true;	// yaml file, plain text with marginal syntax support for multiline replacements
+				return true;    // yaml file, plain text with marginal syntax support for multiline replacements
 			default:
-				if (substr($mimetype,0,5) == 'text/')
+				if(substr($mimetype, 0, 5) == 'text/')
 				{
-					return true;	// text files
+					return true;    // text files
 				}
 				break;
 		}
@@ -252,13 +271,16 @@ abstract class Merge
 	 * @param boolean $ignore_acl =false true: no acl check
 	 * @return array
 	 */
-	public function contact_replacements($contact,$prefix='',$ignore_acl=false, &$content = '')
+	public function contact_replacements($contact, $prefix = '', $ignore_acl = false, &$content = '')
 	{
-		if (!is_array($contact))
+		if(!is_array($contact))
 		{
 			$contact = $this->contacts->read($contact, $ignore_acl);
 		}
-		if (!is_array($contact)) return array();
+		if(!is_array($contact))
+		{
+			return array();
+		}
 
 		$replacements = array();
 		foreach(array_keys($this->contacts->contact_fields) as $name)
@@ -362,15 +384,15 @@ abstract class Merge
 			}
 			// Format date cfs per user Api\Preferences
 			if($this->mimetype !== 'application/x-yaml' && $contact[$name] &&
-					($field['type'] == 'date' || $field['type'] == 'date-time'))
+				($field['type'] == 'date' || $field['type'] == 'date-time'))
 			{
-				$this->date_fields[] = '#'.$name;
-				$replacements['$$'.($prefix ? $prefix.'/':'').$name.'$$'] = Api\DateTime::to($contact[$name], $field['type'] == 'date' ? true : '');
+				$this->date_fields[] = '#' . $name;
+				$replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = Api\DateTime::to($contact[$name], $field['type'] == 'date' ? true : '');
 			}
-			$replacements['$$'.($prefix ? $prefix.'/':'').$name.'$$'] =
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] =
 				// use raw data for yaml, no user-preference specific formatting
 				$this->mimetype == 'application/x-yaml' || $field['type'] == 'htmlarea' ? (string)$contact[$name] :
-				Customfields::format($field, (string)$contact[$name]);
+					Customfields::format($field, (string)$contact[$name]);
 		}
 
 		if($content && strpos($content, '$$#') !== FALSE)
@@ -380,22 +402,27 @@ abstract class Merge
 
 		// Add in extra cat field
 		$cats = array();
-		foreach(is_array($contact['cat_id']) ? $contact['cat_id'] : explode(',',$contact['cat_id']) as $cat_id)
+		foreach(is_array($contact['cat_id']) ? $contact['cat_id'] : explode(',', $contact['cat_id']) as $cat_id)
 		{
-			if(!$cat_id) continue;
-			if($GLOBALS['egw']->categories->id2name($cat_id,'main') != $cat_id)
+			if(!$cat_id)
+			{
+				continue;
+			}
+			if($GLOBALS['egw']->categories->id2name($cat_id, 'main') != $cat_id)
 			{
 				$path = explode(' / ', $GLOBALS['egw']->categories->id2name($cat_id, 'path'));
 				unset($path[0]); // Drop main
-				$cats[$GLOBALS['egw']->categories->id2name($cat_id,'main')][] = implode(' / ', $path);
-			} elseif($cat_id) {
+				$cats[$GLOBALS['egw']->categories->id2name($cat_id, 'main')][] = implode(' / ', $path);
+			}
+			elseif($cat_id)
+			{
 				$cats[$cat_id] = array();
 			}
 		}
-		$replacements['$$'.($prefix ? $prefix.'/':'').'categories$$'] = '';
+		$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'categories$$'] = '';
 		foreach($cats as $main => $cat)
 		{
-			$replacements['$$'.($prefix ? $prefix.'/':'').'categories$$'] .= $GLOBALS['egw']->categories->id2name($main,'name')
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'categories$$'] .= $GLOBALS['egw']->categories->id2name($main, 'name')
 				. (count($cat) > 0 ? ': ' : '') . implode(', ', $cats[$main]) . "\n";
 		}
 		return $replacements;
@@ -406,16 +433,16 @@ abstract class Merge
 	 *
 	 * Uses egw_link system to get link titles
 	 *
-	 * @param app Name of current app
-	 * @param id ID of current entry
-	 * @param only_app Restrict links to only given application
-	 * @param exclude Exclude links to these applications
-	 * @param style String One of:
-	 * 	'title' - plain text, just the title of the link
-	 * 	'link' - URL to the entry
-	 * 	'href' - HREF tag wrapped around the title
+	 * @param string app Name of current app
+	 * @param string id ID of current entry
+	 * @param string only_app Restrict links to only given application
+	 * @param string[] exclude Exclude links to these applications
+	 * @param string style  One of:
+	 *    'title' - plain text, just the title of the link
+	 *    'link' - URL to the entry
+	 *    'href' - HREF tag wrapped around the title
 	 */
-	protected function get_links($app, $id, $only_app='', $exclude = array(), $style = 'title')
+	protected function get_links($app, $id, $only_app = '', $exclude = array(), $style = 'title')
 	{
 		$links = Api\Link::get_links($app, $id, $only_app);
 		$link_titles = array();
@@ -425,34 +452,40 @@ abstract class Merge
 			if(!is_array($link_info) && $only_app && $only_app[0] !== '!')
 			{
 				$link_info = array(
-					'app'	=> $only_app,
-					'id'	=> $link_info
+					'app' => $only_app,
+					'id'  => $link_info
 				);
 			}
-			if($exclude && in_array($link_info['id'], $exclude)) continue;
+			if($exclude && in_array($link_info['id'], $exclude))
+			{
+				continue;
+			}
 
 			$title = Api\Link::title($link_info['app'], $link_info['id']);
-			
+
 			if($style == 'href' || $style == 'link')
 			{
 				$link = Api\Link::view($link_info['app'], $link_info['id'], $link_info);
 				if($link_info['app'] != Api\Link::VFS_APPNAME)
 				{
 					// Set app to false so we always get an external link
-					$link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php',$link, false));
+					$link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php', $link, false));
 				}
 				else
 				{
 					$link = Api\Framework::link($link, array());
 				}
 				// Prepend site
-				if ($link[0] == '/') $link = Api\Framework::getUrl($link);
+				if($link[0] == '/')
+				{
+					$link = Api\Framework::getUrl($link);
+				}
 
 				$title = $style == 'href' ? Api\Html::a_href(Api\Html::htmlspecialchars($title), $link) : $link;
 			}
 			$link_titles[] = $title;
 		}
-		return implode("\n",$link_titles);
+		return implode("\n", $link_titles);
 	}
 
 	/**
@@ -469,7 +502,7 @@ abstract class Merge
 	{
 		$array = array();
 		$pattern = '@\$\$(links_attachments|links|attachments|link)\/?(title|href|link)?\/?([a-z]*)\$\$@';
-		static $link_cache=null;
+		static $link_cache = null;
 		$matches = null;
 		if(preg_match_all($pattern, $content, $matches))
 		{
@@ -495,38 +528,44 @@ abstract class Merge
 						if($app != Api\Link::VFS_APPNAME)
 						{
 							// Set app to false so we always get an external link
-							$link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php',$link, false));
+							$link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php', $link, false));
 						}
 						else
 						{
 							$link = Api\Framework::link($link, array());
 						}
 						// Prepend site
-						if ($link[0] == '/') $link = Api\Framework::getUrl($link);
+						if($link[0] == '/')
+						{
+							$link = Api\Framework::getUrl($link);
+						}
 
 						// Formatting
 						if($matches[2][$i] == 'title')
 						{
 							$link = $title;
 						}
-						else if($matches[2][$i] == 'href')
+						else
 						{
-							// Turn on HTML style parsing or the link will be escaped
-							$this->parse_html_styles = true;
-							$link = Api\Html::a_href(Api\Html::htmlspecialchars($title), $link);
+							if($matches[2][$i] == 'href')
+							{
+								// Turn on HTML style parsing or the link will be escaped
+								$this->parse_html_styles = true;
+								$link = Api\Html::a_href(Api\Html::htmlspecialchars($title), $link);
+							}
 						}
 
-						$array['$$'.($prefix?$prefix.'/':'').$placeholder.'$$'] = $link;
+						$array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $link;
 						break;
 					case 'links':
-						$link_app = $matches[3][$i] ? $matches[3][$i] :  '!'.Api\Link::VFS_APPNAME;
-						$array['$$'.($prefix?$prefix.'/':'').$placeholder.'$$'] = $this->get_links($app, $id, $link_app, array(),$matches[2][$i]);
+						$link_app = $matches[3][$i] ? $matches[3][$i] : '!' . Api\Link::VFS_APPNAME;
+						$array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, $link_app, array(), $matches[2][$i]);
 						break;
 					case 'attachments':
-						$array['$$'.($prefix?$prefix.'/':'').$placeholder.'$$'] = $this->get_links($app, $id, Api\Link::VFS_APPNAME,array(),$matches[2][$i]);
+						$array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, Api\Link::VFS_APPNAME, array(), $matches[2][$i]);
 						break;
 					default:
-						$array['$$'.($prefix?$prefix.'/':'').$placeholder.'$$'] = $this->get_links($app, $id, $matches[3][$i], array(), $matches[2][$i]);
+						$array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, $matches[3][$i], array(), $matches[2][$i]);
 						break;
 				}
 				$link_cache[$id][$placeholder] = $array[$placeholder];
@@ -553,7 +592,7 @@ abstract class Merge
 
 		if(!$GLOBALS['egw_info']['user']['apps']['stylite'])
 		{
-			$replacements['$$'.$prefix.'share$$'] = lang('EPL Only');
+			$replacements['$$' . $prefix . 'share$$'] = lang('EPL Only');
 			return $replacements;
 		}
 
@@ -562,7 +601,7 @@ abstract class Merge
 
 		if($share)
 		{
-			$replacements['$$'.$prefix.'share$$'] = $link = Api\Sharing::share2link($share);
+			$replacements['$$' . $prefix . 'share$$'] = $link = Api\Sharing::share2link($share);
 		}
 
 		return $replacements;
@@ -589,7 +628,8 @@ abstract class Merge
 
 		// Need to create the share here.
 		// No way to know here if it should be writable, or who it's going to
-		$mode = /* ?  ? Sharing::WRITABLE :*/ Api\Sharing::READONLY;
+		$mode = /* ?  ? Sharing::WRITABLE :*/
+			Api\Sharing::READONLY;
 		$recipients = array();
 		$extra = array();
 
@@ -603,15 +643,18 @@ abstract class Merge
 	 *
 	 * @param int|string|DateTime $time unix timestamp or Y-m-d H:i:s string (in user time!)
 	 * @param string $format =null format string, default $this->datetime_format
-	 * @deprecated use Api\DateTime::to($time='now',$format='')
 	 * @return string
+	 * @deprecated use Api\DateTime::to($time='now',$format='')
 	 */
-	protected function format_datetime($time,$format=null)
+	protected function format_datetime($time, $format = null)
 	{
 		trigger_error(__METHOD__ . ' is deprecated, use Api\DateTime::to($time, $format)', E_USER_DEPRECATED);
-		if (is_null($format)) $format = $this->datetime_format;
+		if(is_null($format))
+		{
+			$format = $this->datetime_format;
+		}
 
-		return Api\DateTime::to($time,$format);
+		return Api\DateTime::to($time, $format);
 	}
 
 	/**
@@ -623,19 +666,19 @@ abstract class Merge
 	 */
 	public static function is_export_limit_excepted()
 	{
-		static $is_excepted=null;
+		static $is_excepted = null;
 
-		if (is_null($is_excepted))
+		if(is_null($is_excepted))
 		{
 			$is_excepted = isset($GLOBALS['egw_info']['user']['apps']['admin']);
 
 			// check export-limit and fail if user tries to export more entries then allowed
-			if (!$is_excepted && (is_array($export_limit_excepted = $GLOBALS['egw_info']['server']['export_limit_excepted']) ||
-				is_array($export_limit_excepted = unserialize($export_limit_excepted))))
+			if(!$is_excepted && (is_array($export_limit_excepted = $GLOBALS['egw_info']['server']['export_limit_excepted']) ||
+					is_array($export_limit_excepted = unserialize($export_limit_excepted))))
 			{
-				$id_and_memberships = $GLOBALS['egw']->accounts->memberships($GLOBALS['egw_info']['user']['account_id'],true);
+				$id_and_memberships = $GLOBALS['egw']->accounts->memberships($GLOBALS['egw_info']['user']['account_id'], true);
 				$id_and_memberships[] = $GLOBALS['egw_info']['user']['account_id'];
-				$is_excepted = (bool) array_intersect($id_and_memberships, $export_limit_excepted);
+				$is_excepted = (bool)array_intersect($id_and_memberships, $export_limit_excepted);
 			}
 		}
 		return $is_excepted;
@@ -646,30 +689,36 @@ abstract class Merge
 	 *
 	 * @param string $app ='common' checks and validates app_limit, if not set returns the global limit
 	 * @return mixed - no if no export is allowed, false if there is no restriction and int as there is a valid restriction
-	 *		you may have to cast the returned value to int, if you want to use it as number
+	 *        you may have to cast the returned value to int, if you want to use it as number
 	 */
-	public static function getExportLimit($app='common')
+	public static function getExportLimit($app = 'common')
 	{
-		static $exportLimitStore=array();
-		if (empty($app)) $app='common';
+		static $exportLimitStore = array();
+		if(empty($app))
+		{
+			$app = 'common';
+		}
 		//error_log(__METHOD__.__LINE__.' called with app:'.$app);
-		if (!array_key_exists($app,$exportLimitStore))
+		if(!array_key_exists($app, $exportLimitStore))
 		{
 			//error_log(__METHOD__.__LINE__.' -> '.$app_limit.' '.function_backtrace());
 			$exportLimitStore[$app] = $GLOBALS['egw_info']['server']['export_limit'];
-			if ($app !='common')
+			if($app != 'common')
 			{
-				$app_limit = Api\Hooks::single('export_limit',$app);
-				if ($app_limit) $exportLimitStore[$app] = $app_limit;
+				$app_limit = Api\Hooks::single('export_limit', $app);
+				if($app_limit)
+				{
+					$exportLimitStore[$app] = $app_limit;
+				}
 			}
 			//error_log(__METHOD__.__LINE__.' building cache for app:'.$app.' -> '.$exportLimitStore[$app]);
-			if (empty($exportLimitStore[$app]))
+			if(empty($exportLimitStore[$app]))
 			{
 				$exportLimitStore[$app] = false;
 				return false;
 			}
 
-			if (is_numeric($exportLimitStore[$app]))
+			if(is_numeric($exportLimitStore[$app]))
 			{
 				$exportLimitStore[$app] = (int)$exportLimitStore[$app];
 			}
@@ -691,12 +740,24 @@ abstract class Merge
 	 *
 	 * @return bool - true if no export is allowed or a limit is set, false if there is no restriction
 	 */
-	public static function hasExportLimit($app_limit,$checkas='AND')
+	public static function hasExportLimit($app_limit, $checkas = 'AND')
 	{
-		if (strtoupper($checkas) == 'ISALLOWED') return (empty($app_limit) || ($app_limit !='no' && $app_limit > 0) );
-		if (empty($app_limit)) return false;
-		if ($app_limit == 'no') return true;
-		if ($app_limit > 0) return true;
+		if(strtoupper($checkas) == 'ISALLOWED')
+		{
+			return (empty($app_limit) || ($app_limit != 'no' && $app_limit > 0));
+		}
+		if(empty($app_limit))
+		{
+			return false;
+		}
+		if($app_limit == 'no')
+		{
+			return true;
+		}
+		if($app_limit > 0)
+		{
+			return true;
+		}
 	}
 
 	/**
@@ -709,31 +770,34 @@ abstract class Merge
 	 * @param array $fix =null regular expression => replacement pairs eg. to fix garbled placeholders
 	 * @return string|boolean merged document or false on error
 	 */
-	public function &merge($document,$ids,&$err,$mimetype,array $fix=null)
+	public function &merge($document, $ids, &$err, $mimetype, array $fix = null)
 	{
-		if (!($content = file_get_contents($document)))
+		if(!($content = file_get_contents($document)))
 		{
-			$err = lang("Document '%1' does not exist or is not readable for you!",$document);
+			$err = lang("Document '%1' does not exist or is not readable for you!", $document);
 			$ret = false;
 			return $ret;
 		}
 
-		if (self::hasExportLimit($this->export_limit) && !self::is_export_limit_excepted() && count($ids) > (int)$this->export_limit)
+		if(self::hasExportLimit($this->export_limit) && !self::is_export_limit_excepted() && count($ids) > (int)$this->export_limit)
 		{
-			$err = lang('No rights to export more than %1 entries!',(int)$this->export_limit);
+			$err = lang('No rights to export more than %1 entries!', (int)$this->export_limit);
 			$ret = false;
 			return $ret;
 		}
 
 		// fix application/msword mimetype for rtf files
-		if ($mimetype == 'application/msword' && strtolower(substr($document,-4)) == '.rtf')
+		if($mimetype == 'application/msword' && strtolower(substr($document, -4)) == '.rtf')
 		{
 			$mimetype = 'application/rtf';
 		}
 
-		try {
-			$content = $this->merge_string($content,$ids,$err,$mimetype,$fix);
-		} catch (\Exception $e) {
+		try
+		{
+			$content = $this->merge_string($content, $ids, $err, $mimetype, $fix);
+		}
+		catch (\Exception $e)
+		{
 			_egw_log_exception($e);
 			$err = $e->getMessage();
 			$ret = false;
@@ -742,51 +806,51 @@ abstract class Merge
 		return $content;
 	}
 
-	protected function apply_styles (&$content, $mimetype, $mso_application_progid=null)
+	protected function apply_styles(&$content, $mimetype, $mso_application_progid = null)
 	{
-		if (!isset($mso_application_progid))
+		if(!isset($mso_application_progid))
 		{
 			$matches = null;
 			$mso_application_progid = $mimetype == 'application/xml' &&
-				preg_match('/'.preg_quote('<?mso-application progid="', '/').'([^"]+)'.preg_quote('"?>', '/').'/',substr($content,0,200),$matches) ?
-					$matches[1] : '';
+			preg_match('/' . preg_quote('<?mso-application progid="', '/') . '([^"]+)' . preg_quote('"?>', '/') . '/', substr($content, 0, 200), $matches) ?
+				$matches[1] : '';
 		}
 		// Tags we can replace with the target document's version
 		$replace_tags = array();
-		switch($mimetype.$mso_application_progid)
+		switch($mimetype . $mso_application_progid)
 		{
-			case 'application/vnd.oasis.opendocument.text':	// oo text
-			case 'application/vnd.oasis.opendocument.spreadsheet':	// oo spreadsheet
+			case 'application/vnd.oasis.opendocument.text':    // oo text
+			case 'application/vnd.oasis.opendocument.spreadsheet':    // oo spreadsheet
 			case 'application/vnd.oasis.opendocument.presentation':
 			case 'application/vnd.oasis.opendocument.text-template':
 			case 'application/vnd.oasis.opendocument.spreadsheet-template':
 			case 'application/vnd.oasis.opendocument.presentation-template':
 				$doc = new DOMDocument();
 				$xslt = new XSLTProcessor();
-				$doc->load(EGW_INCLUDE_ROOT.'/api/templates/default/Merge/openoffice.xslt');
+				$doc->load(EGW_INCLUDE_ROOT . '/api/templates/default/Merge/openoffice.xslt');
 				$xslt->importStyleSheet($doc);
 
 //echo $content;die();
 				break;
-			case 'application/xmlWord.Document':	// Word 2003*/
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms office 2007
+			case 'application/xmlWord.Document':    // Word 2003*/
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms office 2007
 			case 'application/vnd.ms-word.document.macroenabled.12':
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 			case 'application/vnd.ms-excel.sheet.macroenabled.12':
 				// It seems easier to split the parent tags here
 				$replace_tags = array(
 					// Tables, lists don't go inside <w:p>
-					'/<(ol|ul|table)( [^>]*)?>/' => '</w:t></w:r></w:p><$1$2>',
-					'/<\/(ol|ul|table)>/' => '</$1><w:p><w:r><w:t>',
+					'/<(ol|ul|table)( [^>]*)?>/'                    => '</w:t></w:r></w:p><$1$2>',
+					'/<\/(ol|ul|table)>/'                           => '</$1><w:p><w:r><w:t>',
 					// Fix for things other than text (newlines) inside table row
 					'/<(td)( [^>]*)?>((?!<w:t>))(.*?)<\/td>[\s]*?/' => '<$1$2><w:t>$4</w:t></td>',
 					// Remove extra whitespace
-					'/<li([^>]*?)>[^:print:]*?(.*?)<\/li>/' => '<li$1>$2</li>', // This doesn't get it all
-					'/<w:t>[\s]+(.*?)<\/w:t>/' => '<w:t>$1</w:t>',
+					'/<li([^>]*?)>[^:print:]*?(.*?)<\/li>/'         => '<li$1>$2</li>', // This doesn't get it all
+					'/<w:t>[\s]+(.*?)<\/w:t>/'                      => '<w:t>$1</w:t>',
 					// Remove spans with no attributes, linebreaks inside them cause problems
-					'/<span>(.*?)<\/span>/' => '$1'
+					'/<span>(.*?)<\/span>/'                         => '$1'
 				);
-				$content = preg_replace(array_keys($replace_tags),array_values($replace_tags),$content);
+				$content = preg_replace(array_keys($replace_tags), array_values($replace_tags), $content);
 
 				/*
 				In the case where you have something like <span><span></w:t><w:br/><w:t></span></span> (invalid - mismatched tags),
@@ -796,14 +860,15 @@ abstract class Merge
 				$count = $i = 0;
 				do
 				{
-					$content = preg_replace('/<span>(.*?)<\/span>/','$1',$content, -1, $count);
+					$content = preg_replace('/<span>(.*?)<\/span>/', '$1', $content, -1, $count);
 					$i++;
-				} while($count > 0 && $i < 10);
+				}
+				while($count > 0 && $i < 10);
 
 				$doc = new DOMDocument();
 				$xslt = new XSLTProcessor();
 				$xslt_file = $mimetype == 'application/xml' ? 'wordml.xslt' : 'msoffice.xslt';
-				$doc->load(EGW_INCLUDE_ROOT.'/api/templates/default/Merge/'.$xslt_file);
+				$doc->load(EGW_INCLUDE_ROOT . '/api/templates/default/Merge/' . $xslt_file);
 				$xslt->importStyleSheet($doc);
 				break;
 		}
@@ -822,8 +887,9 @@ abstract class Merge
 			$content = $xslt->transformToXml($element);
 //echo $content;die();
 			// Word 2003 needs two declarations, add extra declaration back in
-			if($mimetype == 'application/xml' && $mso_application_progid == 'Word.Document' && strpos($content, '<?xml') !== 0) {
-				$content = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'.$content;
+			if($mimetype == 'application/xml' && $mso_application_progid == 'Word.Document' && strpos($content, '<?xml') !== 0)
+			{
+				$content = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . $content;
 			}
 			// Validate
 			/*
@@ -845,12 +911,12 @@ abstract class Merge
 	 * @param string $charset =null charset to override default set by mimetype or export charset
 	 * @return string|boolean merged document or false on error
 	 */
-	public function &merge_string($_content,$ids,&$err,$mimetype,array $fix=null,$charset=null)
+	public function &merge_string($_content, $ids, &$err, $mimetype, array $fix = null, $charset = null)
 	{
 		$ids = empty($ids) ? [] : (array)$ids;
 		$matches = null;
-		if ($mimetype == 'application/xml' &&
-			preg_match('/'.preg_quote('<?mso-application progid="', '/').'([^"]+)'.preg_quote('"?>', '/').'/',substr($_content,0,200),$matches))
+		if($mimetype == 'application/xml' &&
+			preg_match('/' . preg_quote('<?mso-application progid="', '/') . '([^"]+)' . preg_quote('"?>', '/') . '/', substr($_content, 0, 200), $matches))
 		{
 			$mso_application_progid = $matches[1];
 		}
@@ -860,33 +926,38 @@ abstract class Merge
 		}
 		// alternative syntax using double curly brackets (eg. {{cat_id}} instead $$cat_id$$),
 		// agressivly removing all xml-tags eg. Word adds within placeholders
-		$content = preg_replace_callback('/{{[^}]+}}/i', function($matches)
+		$content = preg_replace_callback('/{{[^}]+}}/i', function ($matches)
 		{
-			return '$$'.strip_tags(substr($matches[0], 2, -2)).'$$';
-		}, $_content);
+			return '$$' . strip_tags(substr($matches[0], 2, -2)) . '$$';
+		},                               $_content);
 		// Handle escaped placeholder markers in RTF, they won't match when escaped
 		if($mimetype == 'application/rtf')
 		{
-			$content = preg_replace('/\\\{\\\{([^\\}]+)\\\}\\\}/i','$$\1$$',$content);
+			$content = preg_replace('/\\\{\\\{([^\\}]+)\\\}\\\}/i', '$$\1$$', $content);
 		}
 
 		// make currently processed mimetype available to class methods;
 		$this->mimetype = $mimetype;
 
 		// fix garbled placeholders
-		if ($fix && is_array($fix))
+		if($fix && is_array($fix))
 		{
-			$content = preg_replace(array_keys($fix),array_values($fix),$content);
+			$content = preg_replace(array_keys($fix), array_values($fix), $content);
 			//die("<pre>".htmlspecialchars($content)."</pre>\n");
 		}
-		list($contentstart,$contentrepeat,$contentend) = preg_split('/\$\$pagerepeat\$\$/',$content,-1, PREG_SPLIT_NO_EMPTY)+[null,null,null];  //get differt parts of document, seperatet by Pagerepeat
-		if ($mimetype == 'text/plain' && $ids && count($ids) > 1)
+		list($contentstart, $contentrepeat, $contentend) = preg_split('/\$\$pagerepeat\$\$/', $content, -1, PREG_SPLIT_NO_EMPTY) + [null,
+																																	null,
+																																	null];  //get differt parts of document, seperatet by Pagerepeat
+		if($mimetype == 'text/plain' && $ids && count($ids) > 1)
 		{
 			// textdocuments are simple, they do not hold start and end, but they may have content before and after the $$pagerepeat$$ tag
 			// header and footer should not hold any $$ tags; if we find $$ tags with the header, we assume it is the pagerepeatcontent
 			$nohead = false;
-			if (stripos($contentstart,'$$') !== false) $nohead = true;
-			if ($nohead)
+			if(stripos($contentstart, '$$') !== false)
+			{
+				$nohead = true;
+			}
+			if($nohead)
 			{
 				$contentend = $contentrepeat;
 				$contentrepeat = $contentstart;
@@ -894,51 +965,58 @@ abstract class Merge
 			}
 
 		}
-		if (in_array($mimetype, array('application/vnd.oasis.opendocument.text','application/vnd.oasis.opendocument.text-template')) && count($ids) > 1)
+		if(in_array($mimetype, array('application/vnd.oasis.opendocument.text',
+									 'application/vnd.oasis.opendocument.text-template')) && count($ids) > 1)
 		{
 			if(strpos($content, '$$pagerepeat') === false)
 			{
 				//for odt files we have to split the content and add a style for page break to  the style area
-				list($contentstart,$contentrepeat,$contentend) = preg_split('/office:body>/',$content,-1, PREG_SPLIT_NO_EMPTY);  //get differt parts of document, seperatet by Pagerepeat
-				$contentstart = substr($contentstart,0,strlen($contentstart)-1);  //remove "<"
-				$contentrepeat = substr($contentrepeat,0,strlen($contentrepeat)-2);  //remove "</";
+				list($contentstart, $contentrepeat, $contentend) = preg_split('/office:body>/', $content, -1, PREG_SPLIT_NO_EMPTY);           //get differt parts of document, seperatet by Pagerepeat
+				$contentstart = substr($contentstart, 0, strlen($contentstart) - 1);                                                          //remove "<"
+				$contentrepeat = substr($contentrepeat, 0, strlen($contentrepeat) - 2);                                                       //remove "</";
 				// need to add page-break style to the style list
-				list($stylestart,$stylerepeat,$styleend) = preg_split('/<\/office:automatic-styles>/',$content,-1, PREG_SPLIT_NO_EMPTY);  //get differt parts of document style sheets
-				$contentstart = $stylestart.'<style:style style:name="P200" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:break-before="page"/></style:style></office:automatic-styles>';
+				list($stylestart, $stylerepeat, $styleend) = preg_split('/<\/office:automatic-styles>/', $content, -1, PREG_SPLIT_NO_EMPTY);  //get differt parts of document style sheets
+				$contentstart = $stylestart . '<style:style style:name="P200" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:break-before="page"/></style:style></office:automatic-styles>';
 				$contentstart .= '<office:body>';
 				$contentend = '</office:body></office:document-content>';
 			}
 			else
 			{
 				// Template specifies where to repeat
-				list($contentstart,$contentrepeat,$contentend) = preg_split('/\$\$pagerepeat\$\$/',$content,-1, PREG_SPLIT_NO_EMPTY);  //get different parts of document, seperated by pagerepeat
+				list($contentstart, $contentrepeat, $contentend) = preg_split('/\$\$pagerepeat\$\$/', $content, -1, PREG_SPLIT_NO_EMPTY);  //get different parts of document, seperated by pagerepeat
 			}
 		}
-		if (in_array($mimetype, array('application/vnd.ms-word.document.macroenabled.12', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) && count($ids) > 1)
+		if(in_array($mimetype, array('application/vnd.ms-word.document.macroenabled.12',
+									 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) && count($ids) > 1)
 		{
 			//for Word 2007 XML files we have to split the content and add a style for page break to  the style area
-			list($contentstart,$contentrepeat,$contentend) = preg_split('/w:body>/',$content,-1, PREG_SPLIT_NO_EMPTY);  //get differt parts of document, seperatet by Pagerepeat
-			$contentstart = substr($contentstart,0,strlen($contentstart)-1);  //remove "</"
-			$contentrepeat = substr($contentrepeat,0,strlen($contentrepeat)-2);  //remove "</";
+			list($contentstart, $contentrepeat, $contentend) = preg_split('/w:body>/', $content, -1, PREG_SPLIT_NO_EMPTY);  //get differt parts of document, seperatet by Pagerepeat
+			$contentstart = substr($contentstart, 0, strlen($contentstart) - 1);                                            //remove "</"
+			$contentrepeat = substr($contentrepeat, 0, strlen($contentrepeat) - 2);                                         //remove "</";
 			$contentstart .= '<w:body>';
 			$contentend = '</w:body></w:document>';
 		}
-		list($Labelstart,$Labelrepeat,$Labeltend) = preg_split('/\$\$label\$\$/',$contentrepeat,-1, PREG_SPLIT_NO_EMPTY)+[null,null,null];  //get the label content
-		preg_match_all('/\$\$labelplacement\$\$/',$contentrepeat,$countlables, PREG_SPLIT_NO_EMPTY);
+		list($Labelstart, $Labelrepeat, $Labeltend) = preg_split('/\$\$label\$\$/', $contentrepeat, -1, PREG_SPLIT_NO_EMPTY) + [null,
+																																null,
+																																null];  //get the label content
+		preg_match_all('/\$\$labelplacement\$\$/', $contentrepeat, $countlables, PREG_SPLIT_NO_EMPTY);
 		$countlables = count($countlables[0]);
-		preg_replace('/\$\$labelplacement\$\$/','',$Labelrepeat,1);
+		preg_replace('/\$\$labelplacement\$\$/', '', $Labelrepeat, 1);
 		$lableprint = $countlables > 1;
-		if (count($ids) > 1 && !$contentrepeat)
+		if(count($ids) > 1 && !$contentrepeat)
 		{
 			$err = lang('for more than one contact in a document use the tag pagerepeat!');
 			$ret = false;
 			return $ret;
 		}
-		if ($this->report_memory_usage) error_log(__METHOD__."(count(ids)=".count($ids).") strlen(contentrepeat)=".strlen($contentrepeat).', strlen(labelrepeat)='.strlen($Labelrepeat));
-
-		if ($contentrepeat)
+		if($this->report_memory_usage)
 		{
-			$content_stream = fopen('php://temp','r+');
+			error_log(__METHOD__ . "(count(ids)=" . count($ids) . ") strlen(contentrepeat)=" . strlen($contentrepeat) . ', strlen(labelrepeat)=' . strlen($Labelrepeat));
+		}
+
+		if($contentrepeat)
+		{
+			$content_stream = fopen('php://temp', 'r+');
 			fwrite($content_stream, $contentstart);
 			$joiner = '';
 			switch($mimetype)
@@ -947,8 +1025,8 @@ abstract class Merge
 				case 'text/rtf':
 					$joiner = '\\par \\page\\pard\\plain';
 					break;
-				case 'application/vnd.oasis.opendocument.text':	// oo text
-				case 'application/vnd.oasis.opendocument.spreadsheet':	// oo spreadsheet
+				case 'application/vnd.oasis.opendocument.text':    // oo text
+				case 'application/vnd.oasis.opendocument.spreadsheet':    // oo spreadsheet
 				case 'application/vnd.oasis.opendocument.presentation':
 				case 'application/vnd.oasis.opendocument.text-template':
 				case 'application/vnd.oasis.opendocument.spreadsheet-template':
@@ -968,20 +1046,26 @@ abstract class Merge
 					$joiner = "\r\n";
 					break;
 				default:
-					$err = lang('%1 not implemented for %2!','$$pagerepeat$$',$mimetype);
+					$err = lang('%1 not implemented for %2!', '$$pagerepeat$$', $mimetype);
 					$ret = false;
 					return $ret;
 			}
 		}
-		foreach ((array)$ids as $n => $id)
+		foreach((array)$ids as $n => $id)
 		{
-			if ($contentrepeat) $content = $contentrepeat;   //content to repeat
-			if ($lableprint) $content = $Labelrepeat;
+			if($contentrepeat)
+			{
+				$content = $contentrepeat;
+			}   //content to repeat
+			if($lableprint)
+			{
+				$content = $Labelrepeat;
+			}
 
 			// generate replacements; if exception is thrown, catch it set error message and return false
 			try
 			{
-				if(!($replacements = $this->get_replacements($id,$content)))
+				if(!($replacements = $this->get_replacements($id, $content)))
 				{
 					$err = lang('Entry not found!');
 					$ret = false;
@@ -995,114 +1079,120 @@ abstract class Merge
 				$ret = false;
 				return $ret;
 			}
-			if ($this->report_memory_usage) error_log(__METHOD__."() $n: $id ".Api\Vfs::hsize(memory_get_usage(true)));
+			if($this->report_memory_usage)
+			{
+				error_log(__METHOD__ . "() $n: $id " . Api\Vfs::hsize(memory_get_usage(true)));
+			}
 			// some general replacements: current user, date and time
 			if(strpos($content, '$$user/') !== false && ($user = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'], 'person_id')))
 			{
 				$replacements += $this->contact_replacements($user, 'user', false, $content);
 				$replacements['$$user/primary_group$$'] = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'], 'account_primary_group'));
 			}
-			$replacements['$$date$$'] = Api\DateTime::to('now',true);
+			$replacements['$$date$$'] = Api\DateTime::to('now', true);
 			$replacements['$$datetime$$'] = Api\DateTime::to('now');
-			$replacements['$$time$$'] = Api\DateTime::to('now',false);
+			$replacements['$$time$$'] = Api\DateTime::to('now', false);
 
 			$app = $this->get_app();
 			$replacements += $this->share_placeholder($app, $id, '', $content);
 
 			// does our extending class registered table-plugins AND document contains table tags
-			if ($this->table_plugins && preg_match_all('/\\$\\$table\\/([A-Za-z0-9_]+)\\$\\$(.*?)\\$\\$endtable\\$\\$/s',$content,$matches,PREG_SET_ORDER))
+			if($this->table_plugins && preg_match_all('/\\$\\$table\\/([A-Za-z0-9_]+)\\$\\$(.*?)\\$\\$endtable\\$\\$/s', $content, $matches, PREG_SET_ORDER))
 			{
 				// process each table
 				foreach($matches as $match)
 				{
-					$plugin   = $match[1];	// plugin name
+					$plugin = $match[1];    // plugin name
 					$callback = $this->table_plugins[$plugin];
-					$repeat   = $match[2];	// line to repeat
+					$repeat = $match[2];    // line to repeat
 					$repeats = '';
-					if (isset($callback))
+					if(isset($callback))
 					{
-						for($n = 0; ($row_replacements = $this->$callback($plugin,$id,$n,$repeat)); ++$n)
+						for($n = 0; ($row_replacements = $this->$callback($plugin, $id, $n, $repeat)); ++$n)
 						{
 							$_repeat = $this->process_commands($repeat, $row_replacements);
-							$repeats .= $this->replace($_repeat,$row_replacements,$mimetype,$mso_application_progid);
+							$repeats .= $this->replace($_repeat, $row_replacements, $mimetype, $mso_application_progid);
 						}
 					}
-					$content = str_replace($match[0],$repeats,$content);
+					$content = str_replace($match[0], $repeats, $content);
 				}
 			}
-			$content = $this->process_commands($this->replace($content,$replacements,$mimetype,$mso_application_progid,$charset), $replacements);
+			$content = $this->process_commands($this->replace($content, $replacements, $mimetype, $mso_application_progid, $charset), $replacements);
 
 			// remove not existing replacements (eg. from calendar array)
-			if (strpos($content,'$$') !== null)
+			if(strpos($content, '$$') !== null)
 			{
-				$content = preg_replace('/\$\$[a-z0-9_\/]+\$\$/i','',$content);
+				$content = preg_replace('/\$\$[a-z0-9_\/]+\$\$/i', '', $content);
 			}
-			if ($contentrepeat)
+			if($contentrepeat)
 			{
 				fwrite($content_stream, ($n == 0 ? '' : $joiner) . $content);
 			}
 			if($lableprint)
 			{
-				$contentrep[is_array($id) ? implode(':',$id) : $id] = $content;
+				$contentrep[is_array($id) ? implode(':', $id) : $id] = $content;
 			}
 		}
-		if ($Labelrepeat)
+		if($Labelrepeat)
 		{
-			$countpage=0;
-			$count=0;
-			$contentrepeatpages[$countpage] = $Labelstart.$Labeltend;
+			$countpage = 0;
+			$count = 0;
+			$contentrepeatpages[$countpage] = $Labelstart . $Labeltend;
 
-			foreach ($contentrep as $Label)
+			foreach($contentrep as $Label)
 			{
-				$contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/',$Label,$contentrepeatpages[$countpage],1);
-				$count=$count+1;
-				if (($count % $countlables) == 0 && count($contentrep)>$count)  //new page
+				$contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/', $Label, $contentrepeatpages[$countpage], 1);
+				$count = $count + 1;
+				if(($count % $countlables) == 0 && count($contentrep) > $count)  //new page
 				{
-					$countpage = $countpage+1;
-					$contentrepeatpages[$countpage] = $Labelstart.$Labeltend;
+					$countpage = $countpage + 1;
+					$contentrepeatpages[$countpage] = $Labelstart . $Labeltend;
 				}
 			}
-			$contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/','',$contentrepeatpages[$countpage],-1);  //clean empty fields
+			$contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/', '', $contentrepeatpages[$countpage], -1);  //clean empty fields
 
 			switch($mimetype)
 			{
 				case 'application/rtf':
 				case 'text/rtf':
-					$ret = $contentstart.implode('\\par \\page\\pard\\plain',$contentrepeatpages).$contentend;
+					$ret = $contentstart . implode('\\par \\page\\pard\\plain', $contentrepeatpages) . $contentend;
 					break;
 				case 'application/vnd.oasis.opendocument.text':
 				case 'application/vnd.oasis.opendocument.presentation':
 				case 'application/vnd.oasis.opendocument.text-template':
 				case 'application/vnd.oasis.opendocument.presentation-template':
-					$ret = $contentstart.implode('<text:line-break />',$contentrepeatpages).$contentend;
+					$ret = $contentstart . implode('<text:line-break />', $contentrepeatpages) . $contentend;
 					break;
 				case 'application/vnd.oasis.opendocument.spreadsheet':
 				case 'application/vnd.oasis.opendocument.spreadsheet-template':
-					$ret = $contentstart.implode('</text:p><text:p>',$contentrepeatpages).$contentend;
+					$ret = $contentstart . implode('</text:p><text:p>', $contentrepeatpages) . $contentend;
 					break;
 				case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
 				case 'application/vnd.ms-word.document.macroenabled.12':
 				case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 				case 'application/vnd.ms-excel.sheet.macroenabled.12':
-					$ret = $contentstart.implode('<w:br w:type="page" />',$contentrepeatpages).$contentend;
+					$ret = $contentstart . implode('<w:br w:type="page" />', $contentrepeatpages) . $contentend;
 					break;
 				case 'text/plain':
-					$ret = $contentstart.implode("\r\n",$contentrep).$contentend;
+					$ret = $contentstart . implode("\r\n", $contentrep) . $contentend;
 					break;
 				default:
-					$err = lang('%1 not implemented for %2!','$$labelplacement$$',$mimetype);
+					$err = lang('%1 not implemented for %2!', '$$labelplacement$$', $mimetype);
 					$ret = false;
 			}
 			return $ret;
 		}
 
-		if ($contentrepeat)
+		if($contentrepeat)
 		{
 			fwrite($content_stream, $contentend);
 			rewind($content_stream);
 			$content = stream_get_contents($content_stream);
 		}
-		if ($this->report_memory_usage) error_log(__METHOD__."() returning ".Api\Vfs::hsize(memory_get_peak_usage(true)));
+		if($this->report_memory_usage)
+		{
+			error_log(__METHOD__ . "() returning " . Api\Vfs::hsize(memory_get_peak_usage(true)));
+		}
 
 		return $content;
 	}
@@ -1117,54 +1207,57 @@ abstract class Merge
 	 * @param string $charset =null charset to override default set by mimetype or export charset
 	 * @return string
 	 */
-	protected function replace($content,array $replacements,$mimetype,$mso_application_progid='',$charset=null)
+	protected function replace($content, array $replacements, $mimetype, $mso_application_progid = '', $charset = null)
 	{
 		switch($mimetype)
 		{
-			case 'application/vnd.oasis.opendocument.text':		// open office
+			case 'application/vnd.oasis.opendocument.text':        // open office
 			case 'application/vnd.oasis.opendocument.spreadsheet':
 			case 'application/vnd.oasis.opendocument.presentation':
 			case 'application/vnd.oasis.opendocument.text-template':
 			case 'application/vnd.oasis.opendocument.spreadsheet-template':
 			case 'application/vnd.oasis.opendocument.presentation-template':
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms office 2007
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms office 2007
 			case 'application/vnd.ms-word.document.macroenabled.12':
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 			case 'application/vnd.ms-excel.sheet.macroenabled.12':
 			case 'application/xml':
 			case 'text/xml':
 				$is_xml = true;
-				$charset = 'utf-8';	// xml files --> always use utf-8
+				$charset = 'utf-8';    // xml files --> always use utf-8
 				break;
 
 			case 'application/rtf':
 			case 'text/rtf':
-				$charset = 'iso-8859-1';	// rtf seems to user iso-8859-1 or equivalent windows charset, not utf-8
+				$charset = 'iso-8859-1';    // rtf seems to user iso-8859-1 or equivalent windows charset, not utf-8
 				break;
 
 			case 'text/html':
 				$is_xml = true;
 				$matches = null;
-				if (preg_match('/<meta http-equiv="content-type".*charset=([^;"]+)/i',$content,$matches))
+				if(preg_match('/<meta http-equiv="content-type".*charset=([^;"]+)/i', $content, $matches))
 				{
 					$charset = $matches[1];
 				}
-				elseif (empty($charset))
+				elseif(empty($charset))
 				{
 					$charset = 'utf-8';
 				}
 				break;
 
-			default:	// div. text files --> use our export-charset, defined in addressbook prefs
-				if (empty($charset)) $charset = $this->contacts->prefs['csv_charset'];
+			default:    // div. text files --> use our export-charset, defined in addressbook prefs
+				if(empty($charset))
+				{
+					$charset = $this->contacts->prefs['csv_charset'];
+				}
 				break;
 		}
 		//error_log(__METHOD__."('$document', ... ,$mimetype) --> $charset (egw=".Api\Translation::charset().', export='.$this->contacts->prefs['csv_charset'].')');
 
 		// do we need to convert charset
-		if ($charset && $charset != Api\Translation::charset())
+		if($charset && $charset != Api\Translation::charset())
 		{
-			$replacements = Api\Translation::convert($replacements,Api\Translation::charset(),$charset);
+			$replacements = Api\Translation::convert($replacements, Api\Translation::charset(), $charset);
 		}
 
 		// Date only placeholders for timestamps
@@ -1172,14 +1265,14 @@ abstract class Merge
 		{
 			foreach($this->date_fields as $field)
 			{
-				if(($value = $replacements['$$'.$field.'$$'] ?? null))
+				if(($value = $replacements['$$' . $field . '$$'] ?? null))
 				{
-					$time = Api\DateTime::createFromFormat('+'.Api\DateTime::$user_dateformat.' '.Api\DateTime::$user_timeformat.'*', $value);
-					$replacements['$$'.$field.'/date$$'] = $time ? $time->format(Api\DateTime::$user_dateformat)  : '';
+					$time = Api\DateTime::createFromFormat('+' . Api\DateTime::$user_dateformat . ' ' . Api\DateTime::$user_timeformat . '*', $value);
+					$replacements['$$' . $field . '/date$$'] = $time ? $time->format(Api\DateTime::$user_dateformat) : '';
 				}
 			}
 		}
-		if ($is_xml)	// zip'ed xml document (eg. OO)
+		if($is_xml)    // zip'ed xml document (eg. OO)
 		{
 			// Numeric fields
 			$names = array();
@@ -1187,35 +1280,35 @@ abstract class Merge
 			// Tags we can replace with the target document's version
 			$replace_tags = array();
 			// only keep tags, if we have xsl extension available
-			if (class_exists('XSLTProcessor') && class_exists('DOMDocument') && $this->parse_html_styles)
+			if(class_exists('XSLTProcessor') && class_exists('DOMDocument') && $this->parse_html_styles)
 			{
-				switch($mimetype.$mso_application_progid)
+				switch($mimetype . $mso_application_progid)
 				{
 					case 'text/html':
 						$replace_tags = array(
-							'<b>','<strong>','<i>','<em>','<u>','<span>','<ol>','<ul>','<li>',
-							'<table>','<tr>','<td>','<a>','<style>','<img>',
+							'<b>', '<strong>', '<i>', '<em>', '<u>', '<span>', '<ol>', '<ul>', '<li>',
+							'<table>', '<tr>', '<td>', '<a>', '<style>', '<img>',
 						);
 						break;
-					case 'application/vnd.oasis.opendocument.text':		// open office
+					case 'application/vnd.oasis.opendocument.text':        // open office
 					case 'application/vnd.oasis.opendocument.spreadsheet':
 					case 'application/vnd.oasis.opendocument.presentation':
 					case 'application/vnd.oasis.opendocument.text-template':
 					case 'application/vnd.oasis.opendocument.spreadsheet-template':
 					case 'application/vnd.oasis.opendocument.presentation-template':
 						$replace_tags = array(
-							'<b>','<strong>','<i>','<em>','<u>','<span>','<ol>','<ul>','<li>',
-							'<table>','<tr>','<td>','<a>',
+							'<b>', '<strong>', '<i>', '<em>', '<u>', '<span>', '<ol>', '<ul>', '<li>',
+							'<table>', '<tr>', '<td>', '<a>',
 						);
 						break;
-					case 'application/xmlWord.Document':	// Word 2003*/
-					case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms office 2007
+					case 'application/xmlWord.Document':    // Word 2003*/
+					case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms office 2007
 					case 'application/vnd.ms-word.document.macroenabled.12':
 					case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 					case 'application/vnd.ms-excel.sheet.macroenabled.12':
 						$replace_tags = array(
-							'<b>','<strong>','<i>','<em>','<u>','<span>','<ol>','<ul>','<li>',
-							'<table>','<tr>','<td>',
+							'<b>', '<strong>', '<i>', '<em>', '<u>', '<span>', '<ol>', '<ul>', '<li>',
+							'<table>', '<tr>', '<td>',
 						);
 						break;
 				}
@@ -1231,114 +1324,122 @@ abstract class Merge
 				}
 				// decode html entities back to utf-8
 
-				if (is_string($value) && (strpos($value,'&') !== false) && $this->parse_html_styles)
+				if(is_string($value) && (strpos($value, '&') !== false) && $this->parse_html_styles)
 				{
-					$value = html_entity_decode($value,ENT_QUOTES,$charset);
+					$value = html_entity_decode($value, ENT_QUOTES, $charset);
 
 					// remove all non-decodable entities
-					if (strpos($value,'&') !== false)
+					if(strpos($value, '&') !== false)
 					{
-						$value = preg_replace('/&[^; ]+;/','',$value);
+						$value = preg_replace('/&[^; ]+;/', '', $value);
 					}
 				}
 				if(!$this->parse_html_styles || (
-					strpos($value, "\n") !== FALSE &&
-						strpos($value,'<br') === FALSE && strpos($value, '<span') === FALSE && strpos($value, '<p') === FALSE && strpos($value, '<div') === FALSE
-				))
+						strpos($value, "\n") !== FALSE &&
+						strpos($value, '<br') === FALSE && strpos($value, '<span') === FALSE && strpos($value, '<p') === FALSE && strpos($value, '<div') === FALSE
+					))
 				{
 					// Encode special chars so they don't break the file
-					$value = htmlspecialchars($value,ENT_NOQUOTES);
+					$value = htmlspecialchars($value, ENT_NOQUOTES);
 				}
-				else if (is_string($value) && (strpos($value,'<') !== false))
+				else
 				{
-					// Clean HTML, if it's being kept
-					if($replace_tags && extension_loaded('tidy')) {
-						$tidy = new tidy();
-						$cleaned = $tidy->repairString($value, self::$tidy_config, 'utf8');
-						// Found errors. Strip it all so there's some output
-						if($tidy->getStatus() == 2)
-						{
-							error_log($tidy->errorBuffer);
-							$value = strip_tags($value);
-						}
-						else
-						{
-							$value = $cleaned;
-						}
-					}
-					// replace </p> and <br /> with CRLF (remove <p> and CRLF)
-					$value = strip_tags(str_replace(array("\r","\n",'<p>','</p>','<div>','</div>','<br />'),
-						array('','','',"\r\n",'',"\r\n","\r\n"), $value),
-						implode('', $replace_tags));
-
-					// Change <tag>...\r\n</tag> to <tag>...</tag>\r\n or simplistic line break below will mangle it
-					// Loop to catch things like <b><span>Break:\r\n</span></b>
-					if($mso_application_progid)
+					if(is_string($value) && (strpos($value, '<') !== false))
 					{
-						$count = $i = 0;
-						do
+						// Clean HTML, if it's being kept
+						if($replace_tags && extension_loaded('tidy'))
 						{
-							$value = preg_replace('/<(b|strong|i|em|u|span)\b([^>]*?)>(.*?)'."\r\n".'<\/\1>/u', '<$1$2>$3</$1>'."\r\n",$value,-1,$count);
-							$i++;
-						} while($count > 0 && $i < 10); // Limit of 10 chosen arbitrarily just in case
+							$tidy = new tidy();
+							$cleaned = $tidy->repairString($value, self::$tidy_config, 'utf8');
+							// Found errors. Strip it all so there's some output
+							if($tidy->getStatus() == 2)
+							{
+								error_log($tidy->errorBuffer);
+								$value = strip_tags($value);
+							}
+							else
+							{
+								$value = $cleaned;
+							}
+						}
+						// replace </p> and <br /> with CRLF (remove <p> and CRLF)
+						$value = strip_tags(str_replace(array("\r", "\n", '<p>', '</p>', '<div>', '</div>', '<br />'),
+														array('', '', '', "\r\n", '', "\r\n", "\r\n"), $value
+											),
+											implode('', $replace_tags)
+						);
+
+						// Change <tag>...\r\n</tag> to <tag>...</tag>\r\n or simplistic line break below will mangle it
+						// Loop to catch things like <b><span>Break:\r\n</span></b>
+						if($mso_application_progid)
+						{
+							$count = $i = 0;
+							do
+							{
+								$value = preg_replace('/<(b|strong|i|em|u|span)\b([^>]*?)>(.*?)' . "\r\n" . '<\/\1>/u', '<$1$2>$3</$1>' . "\r\n", $value, -1, $count);
+								$i++;
+							}
+							while($count > 0 && $i < 10); // Limit of 10 chosen arbitrarily just in case
+						}
 					}
 				}
 				// replace all control chars (C0+C1) but CR (\015), LF (\012) and TAB (\011) (eg. vertical tabulators) with space
 				// as they are not allowed in xml
-				$value = preg_replace('/[\000-\010\013\014\016-\037\177-\237\x{FFF0}-\x{FFFD}]/u',' ',$value);
+				$value = preg_replace('/[\000-\010\013\014\016-\037\177-\237\x{FFF0}-\x{FFFD}]/u', ' ', $value);
 				if(is_numeric($value) && $name != '$$user/account_id$$') // account_id causes problems with the preg_replace below
 				{
-					$names[] = preg_quote($name,'/');
+					$names[] = preg_quote($name, '/');
 				}
 			}
 
 			// Look for numbers, set their value if needed
-			if(property_exists($this,'numeric_fields') || count($names))
+			if(property_exists($this, 'numeric_fields') || count($names))
 			{
 				foreach($this->numeric_fields as $fieldname)
 				{
-					$names[] = preg_quote($fieldname,'/');
+					$names[] = preg_quote($fieldname, '/');
 				}
-				$this->format_spreadsheet_numbers($content, $names, $mimetype.$mso_application_progid);
+				$this->format_spreadsheet_numbers($content, $names, $mimetype . $mso_application_progid);
 			}
 
 			// Look for dates, set their value if needed
 			if($this->date_fields || count($names))
 			{
 				$names = array();
-				foreach((array)$this->date_fields as $fieldname) {
+				foreach((array)$this->date_fields as $fieldname)
+				{
 					$names[] = $fieldname;
 				}
-				$this->format_spreadsheet_dates($content, $names, $replacements, $mimetype.$mso_application_progid);
+				$this->format_spreadsheet_dates($content, $names, $replacements, $mimetype . $mso_application_progid);
 			}
 
 			// replace CRLF with linebreak tag of given type
-			switch($mimetype.$mso_application_progid)
+			switch($mimetype . $mso_application_progid)
 			{
-				case 'application/vnd.oasis.opendocument.text':		// open office writer
+				case 'application/vnd.oasis.opendocument.text':        // open office writer
 				case 'application/vnd.oasis.opendocument.text-template':
 				case 'application/vnd.oasis.opendocument.presentation':
 				case 'application/vnd.oasis.opendocument.presentation-template':
 					$break = '<text:line-break/>';
 					break;
-				case 'application/vnd.oasis.opendocument.spreadsheet':		// open office calc
+				case 'application/vnd.oasis.opendocument.spreadsheet':        // open office calc
 				case 'application/vnd.oasis.opendocument.spreadsheet-template':
 					$break = '</text:p><text:p>';
 					break;
-				case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms word 2007
+				case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms word 2007
 				case 'application/vnd.ms-word.document.macroenabled.12':
 					$break = '</w:t><w:br/><w:t>';
 					break;
-				case 'application/xmlExcel.Sheet':	// Excel 2003
+				case 'application/xmlExcel.Sheet':    // Excel 2003
 					$break = '&#10;';
 					break;
-				case 'application/xmlWord.Document':	// Word 2003*/
+				case 'application/xmlWord.Document':    // Word 2003*/
 					$break = '</w:t><w:br/><w:t>';
 					break;
 				case 'text/html':
 					$break = '<br/>';
 					break;
-				case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':	// ms excel 2007
+				case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':    // ms excel 2007
 				case 'application/vnd.ms-excel.sheet.macroenabled.12':
 				default:
 					$break = "\r\n";
@@ -1348,40 +1449,43 @@ abstract class Merge
 			// Check for encoded >< getting double-encoded
 			if($this->parse_html_styles)
 			{
-				$replacements = str_replace(array('&',"\r","\n",'&amp;lt;','&amp;gt;'),array('&amp;','',$break,'&lt;','&gt;'),$replacements);
+				$replacements = str_replace(array('&', "\r", "\n", '&amp;lt;', '&amp;gt;'), array('&amp;', '', $break,
+																								  '&lt;',
+																								  '&gt;'), $replacements);
 			}
 			else
 			{
 				// Need to at least handle new lines, or it'll be run together on one line
-				$replacements = str_replace(array("\r","\n"),array('',$break),$replacements);
+				$replacements = str_replace(array("\r", "\n"), array('', $break), $replacements);
 			}
 		}
-		if ($mimetype == 'application/x-yaml')
+		if($mimetype == 'application/x-yaml')
 		{
-			$content = preg_replace_callback('/^( +)([^$\n]*)(\$\$.+?\$\$)/m', function($matches) use ($replacements)
+			$content = preg_replace_callback('/^( +)([^$\n]*)(\$\$.+?\$\$)/m', function ($matches) use ($replacements)
 			{
 				// allow with {{name/replace/with}} syntax to replace eg. commas with linebreaks: "{{name/, */\n}}"
 				$parts = null;
-				if (preg_match('|^\$\$([^/]+)/([^/]+)/([^$]*)\$\$$|', $matches[3], $parts) && isset($replacements['$$'.$parts[1].'$$']))
+				if(preg_match('|^\$\$([^/]+)/([^/]+)/([^$]*)\$\$$|', $matches[3], $parts) && isset($replacements['$$' . $parts[1] . '$$']))
 				{
-					$replacement =& $replacements['$$'.$parts[1].'$$'];
-					$replacement = preg_replace('/'.$parts[2].'/', strtr($parts[3], array(
+					$replacement =& $replacements['$$' . $parts[1] . '$$'];
+					$replacement = preg_replace('/' . $parts[2] . '/', strtr($parts[3], array(
 						'\\n' => "\n", '\\r' => "\r", '\\t' => "\t", '\\v' => "\v", '\\\\' => '\\', '\\f' => "\f",
-					)), $replacement);
+					)),                         $replacement
+					);
 				}
 				else
 				{
 					$replacement =& $replacements[$matches[3]];
 				}
 				// replacement with multiple lines --> add same number of space as before placeholder
-				if (isset($replacement))
+				if(isset($replacement))
 				{
-					return $matches[1].$matches[2].implode("\n".$matches[1], preg_split("/\r?\n/", $replacement));
+					return $matches[1] . $matches[2] . implode("\n" . $matches[1], preg_split("/\r?\n/", $replacement));
 				}
-				return $matches[0];	// regular replacement below
-			}, $content);
+				return $matches[0];    // regular replacement below
+			},                               $content);
 		}
-		return str_replace(array_keys($replacements),array_values($replacements),$content);
+		return str_replace(array_keys($replacements), array_values($replacements), $content);
 	}
 
 	/**
@@ -1391,39 +1495,43 @@ abstract class Merge
 	{
 		foreach($this->numeric_fields as $fieldname)
 		{
-			$names[] = preg_quote($fieldname,'/');
+			$names[] = preg_quote($fieldname, '/');
 		}
 		switch($mimetype)
 		{
-			case 'application/vnd.oasis.opendocument.spreadsheet':		// open office calc
+			case 'application/vnd.oasis.opendocument.spreadsheet':        // open office calc
 			case 'application/vnd.oasis.opendocument.spreadsheet-template':
-				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)(?:calcext:value-type="[^"]+")?>.?<([a-z].*?)[^>]*>('.implode('|',$names).')<\/\3>.?<\/table:table-cell>/s';
+				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)(?:calcext:value-type="[^"]+")?>.?<([a-z].*?)[^>]*>(' . implode('|', $names) . ')<\/\3>.?<\/table:table-cell>/s';
 				$replacement = '<table:table-cell$1office:value-type="float" office:value="$4"$2><$3>$4</$3></table:table-cell>';
 				break;
-			case 'application/vnd.oasis.opendocument.text':		// tables in open office writer
+			case 'application/vnd.oasis.opendocument.text':        // tables in open office writer
 			case 'application/vnd.oasis.opendocument.presentation':
 			case 'application/vnd.oasis.opendocument.text-template':
 			case 'application/vnd.oasis.opendocument.presentation-template':
-				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)>.?<([a-z].*?)[^>]*>('.implode('|',$names).')<\/\3>.?<\/table:table-cell>/s';
+				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)>.?<([a-z].*?)[^>]*>(' . implode('|', $names) . ')<\/\3>.?<\/table:table-cell>/s';
 				$replacement = '<table:table-cell$1office:value-type="float" office:value="$4"$2><text:p text:style-name="Standard">$4</text:p></table:table-cell>';
 				break;
-			case 'application/vnd.oasis.opendocument.text':		// open office writer
-			case 'application/xmlExcel.Sheet':	// Excel 2003
-				$format = '/'.preg_quote('<Data ss:Type="String">','/').'('.implode('|',$names).')'.preg_quote('</Data>','/').'/';
+			case 'application/vnd.oasis.opendocument.text':        // open office writer
+			case 'application/xmlExcel.Sheet':    // Excel 2003
+				$format = '/' . preg_quote('<Data ss:Type="String">', '/') . '(' . implode('|', $names) . ')' . preg_quote('</Data>', '/') . '/';
 				$replacement = '<Data ss:Type="Number">$1</Data>';
 
 				break;
 		}
-		if (!empty($format) && $names)
+		if(!empty($format) && $names)
 		{
 			// Dealing with backtrack limit per AmigoJack 10-Jul-2010 comment on php.net preg-replace docs
-			do {
+			do
+			{
 				$result = preg_replace($format, $replacement, $content, -1);
 			}
-			// try to increase/double pcre.backtrack_limit failure
+				// try to increase/double pcre.backtrack_limit failure
 			while(preg_last_error() == PREG_BACKTRACK_LIMIT_ERROR && self::increase_backtrack_limit());
 
-			if ($result) $content = $result;  // On failure $result would be NULL
+			if($result)
+			{
+				$content = $result;
+			}  // On failure $result would be NULL
 		}
 	}
 
@@ -1434,24 +1542,27 @@ abstract class Merge
 	 */
 	protected static function increase_backtrack_limit()
 	{
-		static $backtrack_limit=null,$memory_limit=null;
-		if (!isset($backtrack_limit))
+		static $backtrack_limit = null, $memory_limit = null;
+		if(!isset($backtrack_limit))
 		{
 			$backtrack_limit = ini_get('pcre.backtrack_limit');
 		}
-		if (!isset($memory_limit))
+		if(!isset($memory_limit))
 		{
 			$memory_limit = ini_get('memory_limit');
 			switch(strtoupper(substr($memory_limit, -1)))
 			{
-				case 'G': $memory_limit *= 1024;
-				case 'M': $memory_limit *= 1024;
-				case 'K': $memory_limit *= 1024;
+				case 'G':
+					$memory_limit *= 1024;
+				case 'M':
+					$memory_limit *= 1024;
+				case 'K':
+					$memory_limit *= 1024;
 			}
 		}
-		if ($backtrack_limit < $memory_limit/8)
+		if($backtrack_limit < $memory_limit / 8)
 		{
-			ini_set( 'pcre.backtrack_limit', $backtrack_limit*=2);
+			ini_set('pcre.backtrack_limit', $backtrack_limit *= 2);
 			return true;
 		}
 		error_log("pcre.backtrack_limit exceeded @ $backtrack_limit, some cells left as text.");
@@ -1464,25 +1575,28 @@ abstract class Merge
 	protected function format_spreadsheet_dates(&$content, $names, &$values, $mimetype)
 	{
 		if(!in_array($mimetype, array(
-			'application/vnd.oasis.opendocument.spreadsheet',		// open office calc
-			'application/xmlExcel.Sheet',					// Excel 2003
+			'application/vnd.oasis.opendocument.spreadsheet',        // open office calc
+			'application/xmlExcel.Sheet',                    // Excel 2003
 			//'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'//Excel WTF
-		))) return;
+		)))
+		{
+			return;
+		}
 
 		// Some different formats dates could be in, depending what they've been through
 		$formats = array(
-			'!'.Api\DateTime::$user_dateformat . ' ' .Api\DateTime::$user_timeformat.':s',
-			'!'.Api\DateTime::$user_dateformat . '*' .Api\DateTime::$user_timeformat.':s',
-			'!'.Api\DateTime::$user_dateformat . '* ' .Api\DateTime::$user_timeformat,
-			'!'.Api\DateTime::$user_dateformat . '*',
-			'!'.Api\DateTime::$user_dateformat,
+			'!' . Api\DateTime::$user_dateformat . ' ' . Api\DateTime::$user_timeformat . ':s',
+			'!' . Api\DateTime::$user_dateformat . '*' . Api\DateTime::$user_timeformat . ':s',
+			'!' . Api\DateTime::$user_dateformat . '* ' . Api\DateTime::$user_timeformat,
+			'!' . Api\DateTime::$user_dateformat . '*',
+			'!' . Api\DateTime::$user_dateformat,
 			'!Y-m-d\TH:i:s'
 		);
 
 		// Properly format values for spreadsheet
 		foreach($names as $idx => &$field)
 		{
-			$key = '$$'.$field.'$$';
+			$key = '$$' . $field . '$$';
 			$field = preg_quote($field, '/');
 			if($values[$key])
 			{
@@ -1491,13 +1605,13 @@ abstract class Merge
 					$mimetype == 'application/vnd.ms-excel.sheet.macroenabled.12')//Excel WTF
 				{
 					$interval = $date->diff(new Api\DateTime('1900-01-00 0:00'));
-					$values[$key] = $interval->format('%a')+1;// 1900-02-29 did not exist
+					$values[$key] = $interval->format('%a') + 1;// 1900-02-29 did not exist
 					// 1440 minutes in a day - fractional part
-					$values[$key] += ($date->format('H') * 60 + $date->format('i'))/1440;
+					$values[$key] += ($date->format('H') * 60 + $date->format('i')) / 1440;
 				}
 				else
 				{
-					$values[$key] = date('Y-m-d\TH:i:s',Api\DateTime::to($date,'ts'));
+					$values[$key] = date('Y-m-d\TH:i:s', Api\DateTime::to($date, 'ts'));
 				}
 			}
 			else
@@ -1508,15 +1622,15 @@ abstract class Merge
 
 		switch($mimetype)
 		{
-			case 'application/vnd.oasis.opendocument.spreadsheet':		// open office calc
+			case 'application/vnd.oasis.opendocument.spreadsheet':        // open office calc
 				// Removing these forces calc to respect our set value-type
-				$content = str_ireplace('calcext:value-type="string"','',$content);
+				$content = str_ireplace('calcext:value-type="string"', '', $content);
 
-				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)>.?<([a-z].*?)[^>]*>\$\$('.implode('|',$names).')\$\$<\/\3>.?<\/table:table-cell>/s';
+				$format = '/<table:table-cell([^>]+?)office:value-type="[^"]+"([^>]*?)>.?<([a-z].*?)[^>]*>\$\$(' . implode('|', $names) . ')\$\$<\/\3>.?<\/table:table-cell>/s';
 				$replacement = '<table:table-cell$1office:value-type="date" office:date-value="\$\$$4\$\$"$2><$3>\$\$$4\$\$</$3></table:table-cell>';
 				break;
-			case 'application/xmlExcel.Sheet':	// Excel 2003
-				$format = '/'.preg_quote('<Data ss:Type="String">','/').'..('.implode('|',$names).')..'.preg_quote('</Data>','/').'/';
+			case 'application/xmlExcel.Sheet':    // Excel 2003
+				$format = '/' . preg_quote('<Data ss:Type="String">', '/') . '..(' . implode('|', $names) . ')..' . preg_quote('</Data>', '/') . '/';
 				$replacement = '<Data ss:Type="DateTime">\$\$$1\$\$</Data>';
 
 				break;
@@ -1527,13 +1641,17 @@ abstract class Merge
 		if($format && $names)
 		{
 			// Dealing with backtrack limit per AmigoJack 10-Jul-2010 comment on php.net preg-replace docs
-			do {
+			do
+			{
 				$result = preg_replace($format, $replacement, $content, -1);
 			}
-			// try to increase/double pcre.backtrack_limit failure
+				// try to increase/double pcre.backtrack_limit failure
 			while(preg_last_error() == PREG_BACKTRACK_LIMIT_ERROR && self::increase_backtrack_limit());
 
-			if ($result) $content = $result;  // On failure $result would be NULL
+			if($result)
+			{
+				$content = $result;
+			}  // On failure $result would be NULL
 		}
 	}
 
@@ -1545,7 +1663,7 @@ abstract class Merge
 	{
 		if($app == null)
 		{
-			$app = str_replace('_merge','',get_class($this));
+			$app = str_replace('_merge', '', get_class($this));
 		}
 		$cfs = Api\Storage\Customfields::get($app);
 
@@ -1563,9 +1681,9 @@ abstract class Merge
 		$expand_sub_cfs = [];
 		foreach($sub as $index => $cf_sub)
 		{
-			if(strpos($cf_sub, '#') === 0)
+			if(str_starts_with($cf_sub, '#'))
 			{
-				$expand_sub_cfs[$cf[$index]] .= '$$'.$cf_sub . '$$ ';
+				$expand_sub_cfs[$cf[$index]] .= '$$' . $cf_sub . '$$ ';
 			}
 		}
 
@@ -1573,25 +1691,31 @@ abstract class Merge
 		{
 			if($cfs[$field])
 			{
-				if(in_array($cfs[$field]['type'],array_keys($GLOBALS['egw_info']['apps'])))
+				if(in_array($cfs[$field]['type'], array_keys($GLOBALS['egw_info']['apps'])))
 				{
 					$field_app = $cfs[$field]['type'];
 				}
-				else if ($cfs[$field]['type'] == 'api-accounts' || $cfs[$field]['type'] == 'select-account')
-				{
-					// Special case for api-accounts -> contact
-					$field_app = 'addressbook';
-					$account = $GLOBALS['egw']->accounts->read($values['#'.$field]);
-					$app_replacements[$field] = $this->contact_replacements($account['person_id']);
-				}
-				else if (($list = explode('-',$cfs[$field]['type'])) && in_array($list[0], array_keys($GLOBALS['egw_info']['apps'])))
-				{
-					// Sub-type - use app
-					$field_app = $list[0];
-				}
 				else
 				{
-					continue;
+					if($cfs[$field]['type'] == 'api-accounts' || $cfs[$field]['type'] == 'select-account')
+					{
+						// Special case for api-accounts -> contact
+						$field_app = 'addressbook';
+						$account = $GLOBALS['egw']->accounts->read($values['#' . $field]);
+						$app_replacements[$field] = $this->contact_replacements($account['person_id']);
+					}
+					else
+					{
+						if(($list = explode('-', $cfs[$field]['type'])) && in_array($list[0], array_keys($GLOBALS['egw_info']['apps'])))
+						{
+							// Sub-type - use app
+							$field_app = $list[0];
+						}
+						else
+						{
+							continue;
+						}
+					}
 				}
 
 				// Get replacements for that application
@@ -1600,13 +1724,16 @@ abstract class Merge
 					// If we send the real content it can result in infinite loop of lookups
 					// so we send only the used fields
 					$content = $expand_sub_cfs[$field] ? $expand_sub_cfs[$field] : '';
-					$app_replacements[$field] = $this->get_app_replacements($field_app, $values['#'.$field], $content);
+					$app_replacements[$field] = $this->get_app_replacements($field_app, $values['#' . $field], $content);
 				}
-				$replacements[$placeholders[$index]] = $app_replacements[$field]['$$'.$sub[$index].'$$'];
+				$replacements[$placeholders[$index]] = $app_replacements[$field]['$$' . $sub[$index] . '$$'];
 			}
 			else
 			{
-				if ($cfs[$field]['type'] == 'date' || $cfs[$field]['type'] == 'date-time') $this->date_fields[] = '#'.$field;
+				if($cfs[$field]['type'] == 'date' || $cfs[$field]['type'] == 'date-time')
+				{
+					$this->date_fields[] = '#' . $field;
+				}
 			}
 		}
 	}
@@ -1618,13 +1745,13 @@ abstract class Merge
 	 */
 	protected function get_app()
 	{
-		switch (get_class($this))
+		switch(get_class($this))
 		{
 			case 'EGroupware\Api\Contacts\Merge':
 				$app = 'addressbook';
 				break;
 			default:
-				$app = str_replace('_merge','',get_class($this));
+				$app = str_replace('_merge', '', get_class($this));
 				if(!in_array($app, array_keys($GLOBALS['egw_info']['apps'])))
 				{
 					$app = false;
@@ -1644,7 +1771,7 @@ abstract class Merge
 	public static function get_app_class($appname)
 	{
 		$classname = "{$appname}_merge";
-		if(class_exists($classname, false) && is_subclass_of($classname, 'EGroupware\\Api\\Storage\\Merge'))
+		if(class_exists($classname) && is_subclass_of($classname, 'EGroupware\\Api\\Storage\\Merge'))
 		{
 			$document_merge = new $classname();
 		}
@@ -1658,7 +1785,7 @@ abstract class Merge
 	/**
 	 * Get the replacements for any entry specified by app & id
 	 *
-	 * @param stribg $app
+	 * @param string $app
 	 * @param string $id
 	 * @param string $content
 	 * @return array
@@ -1666,6 +1793,10 @@ abstract class Merge
 	public function get_app_replacements($app, $id, $content, $prefix = '')
 	{
 		$replacements = array();
+		if(!$app || $id || !$content)
+		{
+			return $replacements;
+		}
 		if($app == 'addressbook')
 		{
 			return $this->contact_replacements($id, $prefix, false, $content);
@@ -1719,40 +1850,44 @@ abstract class Merge
 	/**
 	 * Process special flags, such as IF or NELF
 	 *
-	 * @param content Text to be examined and changed
-	 * @param replacements array of markers => replacement
+	 * @param string content Text to be examined and changed
+	 * @param array replacements array of markers => replacement
 	 *
-	 * @return changed content
+	 * @return string changed content
 	 */
 	private function process_commands($content, $replacements)
 	{
-		if (strpos($content,'$$IF') !== false)
-		{	//Example use to use: $$IF n_prefix~Herr~Sehr geehrter~Sehr geehrte$$
+		if(strpos($content, '$$IF') !== false)
+		{    //Example use to use: $$IF n_prefix~Herr~Sehr geehrter~Sehr geehrte$$
 			$this->replacements =& $replacements;
-			$content = preg_replace_callback('/\$\$IF ([#0-9a-z_\/-]+)~(.*)~(.*)~(.*)\$\$/imU',Array($this,'replace_callback'),$content);
+			$content = preg_replace_callback('/\$\$IF ([#0-9a-z_\/-]+)~(.*)~(.*)~(.*)\$\$/imU', array($this,
+																									  'replace_callback'), $content);
 			unset($this->replacements);
 		}
-		if (strpos($content,'$$NELF') !== false)
-		{	//Example: $$NEPBR org_unit$$ sets a LF and value of org_unit, only if there is a value
+		if(strpos($content, '$$NELF') !== false)
+		{    //Example: $$NEPBR org_unit$$ sets a LF and value of org_unit, only if there is a value
 			$this->replacements =& $replacements;
-			$content = preg_replace_callback('/\$\$NELF ([#0-9a-z_\/-]+)\$\$/imU',Array($this,'replace_callback'),$content);
+			$content = preg_replace_callback('/\$\$NELF ([#0-9a-z_\/-]+)\$\$/imU', array($this,
+																						 'replace_callback'), $content);
 			unset($this->replacements);
 		}
-		if (strpos($content,'$$NENVLF') !== false)
-		{	//Example: $$NEPBRNV org_unit$$ sets only a LF if there is a value for org_units, but did not add any value
+		if(strpos($content, '$$NENVLF') !== false)
+		{    //Example: $$NEPBRNV org_unit$$ sets only a LF if there is a value for org_units, but did not add any value
 			$this->replacements =& $replacements;
-			$content = preg_replace_callback('/\$\$NENVLF ([#0-9a-z_\/-]+)\$\$/imU',Array($this,'replace_callback'),$content);
+			$content = preg_replace_callback('/\$\$NENVLF ([#0-9a-z_\/-]+)\$\$/imU', array($this,
+																						   'replace_callback'), $content);
 			unset($this->replacements);
 		}
-		if (strpos($content,'$$LETTERPREFIX$$') !== false)
-		{	//Example use to use: $$LETTERPREFIX$$
+		if(strpos($content, '$$LETTERPREFIX$$') !== false)
+		{    //Example use to use: $$LETTERPREFIX$$
 			$LETTERPREFIXCUSTOM = '$$LETTERPREFIXCUSTOM n_prefix title n_family$$';
-			$content = str_replace('$$LETTERPREFIX$$',$LETTERPREFIXCUSTOM,$content);
+			$content = str_replace('$$LETTERPREFIX$$', $LETTERPREFIXCUSTOM, $content);
 		}
-		if (strpos($content,'$$LETTERPREFIXCUSTOM') !== false)
-		{	//Example use to use for a custom Letter Prefix: $$LETTERPREFIX n_prefix title n_family$$
+		if(strpos($content, '$$LETTERPREFIXCUSTOM') !== false)
+		{    //Example use to use for a custom Letter Prefix: $$LETTERPREFIX n_prefix title n_family$$
 			$this->replacements =& $replacements;
-			$content = preg_replace_callback('/\$\$LETTERPREFIXCUSTOM ([#0-9a-z_-]+)(.*)\$\$/imU',Array($this,'replace_callback'),$content);
+			$content = preg_replace_callback('/\$\$LETTERPREFIXCUSTOM ([#0-9a-z_-]+)(.*)\$\$/imU', array($this,
+																										 'replace_callback'), $content);
 			unset($this->replacements);
 		}
 		return $content;
@@ -1766,24 +1901,30 @@ abstract class Merge
 	 */
 	private function replace_callback($param)
 	{
-		if (array_key_exists('$$'.$param[4].'$$',$this->replacements)) $param[4] = $this->replacements['$$'.$param[4].'$$'];
-		if (array_key_exists('$$'.$param[3].'$$',$this->replacements)) $param[3] = $this->replacements['$$'.$param[3].'$$'];
+		if(array_key_exists('$$' . $param[4] . '$$', $this->replacements))
+		{
+			$param[4] = $this->replacements['$$' . $param[4] . '$$'];
+		}
+		if(array_key_exists('$$' . $param[3] . '$$', $this->replacements))
+		{
+			$param[3] = $this->replacements['$$' . $param[3] . '$$'];
+		}
 
-		$pattern = '/'.preg_quote($param[2], '/').'/';
-		if (strpos($param[0],'$$IF') === 0 && (trim($param[2]) == "EMPTY" || $param[2] === ''))
+		$pattern = '/' . preg_quote($param[2], '/') . '/';
+		if(strpos($param[0], '$$IF') === 0 && (trim($param[2]) == "EMPTY" || $param[2] === ''))
 		{
 			$pattern = '/^$/';
 		}
-		$replace = preg_match($pattern,$this->replacements['$$'.$param[1].'$$']) ? $param[3] : $param[4];
+		$replace = preg_match($pattern, $this->replacements['$$' . $param[1] . '$$']) ? $param[3] : $param[4];
 		switch($this->mimetype)
 		{
-			case 'application/vnd.oasis.opendocument.text':		// open office
+			case 'application/vnd.oasis.opendocument.text':        // open office
 			case 'application/vnd.oasis.opendocument.spreadsheet':
 			case 'application/vnd.oasis.opendocument.presentation':
 			case 'application/vnd.oasis.opendocument.text-template':
 			case 'application/vnd.oasis.opendocument.spreadsheet-template':
 			case 'application/vnd.oasis.opendocument.presentation-template':
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':	// ms office 2007
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':    // ms office 2007
 			case 'application/vnd.ms-word.document.macroenabled.12':
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 			case 'application/vnd.ms-excel.sheet.macroenabled.12':
@@ -1795,57 +1936,69 @@ abstract class Merge
 		}
 
 		switch($this->mimetype)
+		{
+			case 'application/rtf':
+			case 'text/rtf':
+				$LF = '}\par \pard\plain{';
+				break;
+			case 'application/vnd.oasis.opendocument.text':
+			case 'application/vnd.oasis.opendocument.presentation':
+			case 'application/vnd.oasis.opendocument.text-template':
+			case 'application/vnd.oasis.opendocument.presentation-template':
+				$LF = '<text:line-break/>';
+				break;
+			case 'application/vnd.oasis.opendocument.spreadsheet':        // open office calc
+			case 'application/vnd.oasis.opendocument.spreadsheet-template':
+				$LF = '</text:p><text:p>';
+				break;
+			case 'application/xmlExcel.Sheet':    // Excel 2003
+				$LF = '&#10;';
+				break;
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+			case 'application/vnd.ms-word.document.macroenabled.12':
+			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+			case 'application/vnd.ms-excel.sheet.macroenabled.12':
+				$LF = '</w:t></w:r></w:p><w:p><w:r><w:t>';
+				break;
+			case 'application/xml';
+				$LF = '</w:t></w:r><w:r><w:br w:type="text-wrapping" w:clear="all"/></w:r><w:r><w:t>';
+				break;
+			case 'text/html':
+				$LF = "<br/>";
+				break;
+			default:
+				$LF = "\n";
+		}
+		if($is_xml)
+		{
+			$this->replacements = str_replace(array('&', '&amp;amp;', '<', '>', "\r", "\n"), array('&amp;', '&amp;',
+																								   '&lt;', '&gt;', '',
+																								   $LF), $this->replacements);
+		}
+		if(strpos($param[0], '$$NELF') === 0)
+		{    //sets a Pagebreak and value, only if the field has a value
+			if($this->replacements['$$' . $param[1] . '$$'] != '')
 			{
-				case 'application/rtf':
-				case 'text/rtf':
-					$LF = '}\par \pard\plain{';
-					break;
-				case 'application/vnd.oasis.opendocument.text':
-				case 'application/vnd.oasis.opendocument.presentation':
-				case 'application/vnd.oasis.opendocument.text-template':
-				case 'application/vnd.oasis.opendocument.presentation-template':
-					$LF ='<text:line-break/>';
-					break;
-				case 'application/vnd.oasis.opendocument.spreadsheet':		// open office calc
-				case 'application/vnd.oasis.opendocument.spreadsheet-template':
-					$LF = '</text:p><text:p>';
-					break;
-				case 'application/xmlExcel.Sheet':	// Excel 2003
-					$LF = '&#10;';
-					break;
-				case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
-				case 'application/vnd.ms-word.document.macroenabled.12':
-				case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
-				case 'application/vnd.ms-excel.sheet.macroenabled.12':
-					$LF ='</w:t></w:r></w:p><w:p><w:r><w:t>';
-					break;
-				case 'application/xml';
-					$LF ='</w:t></w:r><w:r><w:br w:type="text-wrapping" w:clear="all"/></w:r><w:r><w:t>';
-					break;
-				case 'text/html':
-					$LF = "<br/>";
-					break;
-				default:
-					$LF = "\n";
+				$replace = $LF . $this->replacements['$$' . $param[1] . '$$'];
 			}
-		if($is_xml) {
-			$this->replacements = str_replace(array('&','&amp;amp;','<','>',"\r","\n"),array('&amp;','&amp;','&lt;','&gt;','',$LF),$this->replacements);
 		}
-		if (strpos($param[0],'$$NELF') === 0)
-		{	//sets a Pagebreak and value, only if the field has a value
-			if ($this->replacements['$$'.$param[1].'$$'] !='') $replace = $LF.$this->replacements['$$'.$param[1].'$$'];
-		}
-		if (strpos($param[0],'$$NENVLF') === 0)
-		{	//sets a Pagebreak without any value, only if the field has a value
-			if ($this->replacements['$$'.$param[1].'$$'] !='') $replace = $LF;
-		}
-		if (strpos($param[0],'$$LETTERPREFIXCUSTOM') === 0)
-		{	//sets a Letterprefix
-			$replaceprefixsort = array();
-			$replaceprefix = explode(' ',substr($param[0],21,-2));
-			foreach ($replaceprefix as $nameprefix)
+		if(strpos($param[0], '$$NENVLF') === 0)
+		{    //sets a Pagebreak without any value, only if the field has a value
+			if($this->replacements['$$' . $param[1] . '$$'] != '')
 			{
-				if ($this->replacements['$$'.$nameprefix.'$$'] !='') $replaceprefixsort[] = $this->replacements['$$'.$nameprefix.'$$'];
+				$replace = $LF;
+			}
+		}
+		if(strpos($param[0], '$$LETTERPREFIXCUSTOM') === 0)
+		{    //sets a Letterprefix
+			$replaceprefixsort = array();
+			$replaceprefix = explode(' ', substr($param[0], 21, -2));
+			foreach($replaceprefix as $nameprefix)
+			{
+				if($this->replacements['$$' . $nameprefix . '$$'] != '')
+				{
+					$replaceprefixsort[] = $this->replacements['$$' . $nameprefix . '$$'];
+				}
 			}
 			$replace = implode(' ', $replaceprefixsort);
 		}
@@ -1861,14 +2014,14 @@ abstract class Merge
 	 * @param string $dirs comma or whitespace separated directories, used if $document is a relative path
 	 * @return string with error-message on error, otherwise it does NOT return
 	 */
-	public function download($document, $ids, $name='', $dirs='')
+	public function download($document, $ids, $name = '', $dirs = '')
 	{
 		$result = $this->merge_file($document, $ids, $name, $dirs, $header);
 
 		if(is_file($result) && is_readable($result))
 		{
-			Api\Header\Content::type($header['name'],$header['mime'],$header['filesize']);
-			readfile($result,'r');
+			Api\Header\Content::type($header['name'], $header['mime'], $header['filesize']);
+			readfile($result, 'r');
 			exit;
 		}
 
@@ -1882,20 +2035,20 @@ abstract class Merge
 	 * @param array $ids array with contact id(s)
 	 * @param string $name ='' name to use for downloaded document
 	 * @param string $dirs comma or whitespace separated directories, used if $document is a relative path
-	 * @param Array $header File name, mime & filesize if you want to send a header
+	 * @param array $header File name, mime & filesize if you want to send a header
 	 *
 	 * @return string with error-message on error
 	 * @throws Api\Exception
 	 */
-	public function merge_file($document, $ids, &$name='', $dirs='', &$header)
+	public function merge_file($document, $ids, &$name = '', $dirs = '', &$header)
 	{
 		//error_log(__METHOD__."('$document', ".array2string($ids).", '$name', dirs='$dirs') ->".function_backtrace());
-		if (($error = $this->check_document($document, $dirs)))
+		if(($error = $this->check_document($document, $dirs)))
 		{
 			return $error;
 		}
-		$content_url = Api\Vfs::PREFIX.$document;
-		switch (($mimetype = Api\Vfs::mime_content_type($document)))
+		$content_url = Api\Vfs::PREFIX . $document;
+		switch(($mimetype = Api\Vfs::mime_content_type($document)))
 		{
 			case 'message/rfc822':
 				//error_log(__METHOD__."('$document', ".array2string($ids).", '$name', dirs='$dirs')=>$content_url ->".function_backtrace());
@@ -1913,13 +2066,19 @@ abstract class Merge
 				}
 				//error_log(__METHOD__.__LINE__.' Message after importMessageToMergeAndSend:'.array2string($msgs));
 				$retString = '';
-				if (count($msgs['success'])>0) $retString .= count($msgs['success']).' '.(count($msgs['success'])+count($msgs['failed'])==1?lang('Message prepared for sending.'):lang('Message(s) send ok.'));//implode('<br />',$msgs['success']);
-				//if (strlen($retString)>0) $retString .= '<br />';
-				foreach($msgs['failed'] as $c =>$e)
+				if(count($msgs['success']) > 0)
 				{
-					$errorString .= lang('contact').' '.lang('id').':'.$c.'->'.$e.'.';
+					$retString .= count($msgs['success']) . ' ' . (count($msgs['success']) + count($msgs['failed']) == 1 ? lang('Message prepared for sending.') : lang('Message(s) send ok.'));
+				}//implode('<br />',$msgs['success']);
+				//if (strlen($retString)>0) $retString .= '<br />';
+				foreach($msgs['failed'] as $c => $e)
+				{
+					$errorString .= lang('contact') . ' ' . lang('id') . ':' . $c . '->' . $e . '.';
+				}
+				if(count($msgs['failed']) > 0)
+				{
+					$retString .= count($msgs['failed']) . ' ' . lang('Message(s) send failed!') . '=>' . $errorString;
 				}
-				if (count($msgs['failed'])>0) $retString .= count($msgs['failed']).' '.lang('Message(s) send failed!').'=>'.$errorString;
 				return $retString;
 			case 'application/vnd.oasis.opendocument.text':
 			case 'application/vnd.oasis.opendocument.spreadsheet':
@@ -1929,64 +2088,76 @@ abstract class Merge
 			case 'application/vnd.oasis.opendocument.presentation-template':
 				switch($mimetype)
 				{
-					case 'application/vnd.oasis.opendocument.text':	$ext = '.odt'; break;
-					case 'application/vnd.oasis.opendocument.spreadsheet': $ext = '.ods'; break;
-					case 'application/vnd.oasis.opendocument.presentation': $ext = '.odp'; break;
-					case 'application/vnd.oasis.opendocument.text-template': $ext = '.ott'; break;
-					case 'application/vnd.oasis.opendocument.spreadsheet-template': $ext = '.ots'; break;
-					case 'application/vnd.oasis.opendocument.presentation-template': $ext = '.otp'; break;
+					case 'application/vnd.oasis.opendocument.text':
+						$ext = '.odt';
+						break;
+					case 'application/vnd.oasis.opendocument.spreadsheet':
+						$ext = '.ods';
+						break;
+					case 'application/vnd.oasis.opendocument.presentation':
+						$ext = '.odp';
+						break;
+					case 'application/vnd.oasis.opendocument.text-template':
+						$ext = '.ott';
+						break;
+					case 'application/vnd.oasis.opendocument.spreadsheet-template':
+						$ext = '.ots';
+						break;
+					case 'application/vnd.oasis.opendocument.presentation-template':
+						$ext = '.otp';
+						break;
 				}
-				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document,$ext).'-').$ext;
-				copy($content_url,$archive);
-				$content_url = 'zip://'.$archive.'#'.($content_file = 'content.xml');
+				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document, $ext) . '-') . $ext;
+				copy($content_url, $archive);
+				$content_url = 'zip://' . $archive . '#' . ($content_file = 'content.xml');
 				$this->parse_html_styles = true;
 				break;
-			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.d':	// mimetypes in vfs are limited to 64 chars
+			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.d':    // mimetypes in vfs are limited to 64 chars
 				$mimetype = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
 			case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
 			case 'application/vnd.ms-word.document.macroenabled.12':
-				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document,'.docx').'-').'.docx';
-				copy($content_url,$archive);
-				$content_url = 'zip://'.$archive.'#'.($content_file = 'word/document.xml');
-				$fix = array(		// regular expression to fix garbled placeholders
-					'/'.preg_quote('$$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>','/').'([a-z0-9_]+)'.
-						preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>','/').'/i' => '$$\\1$$',
-					'/'.preg_quote('$$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:rPr><w:lang w:val="','/').
-						'([a-z]{2}-[A-Z]{2})'.preg_quote('"/></w:rPr><w:t>','/').'([a-z0-9_]+)'.
-						preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:rPr><w:lang w:val="','/').
-						'([a-z]{2}-[A-Z]{2})'.preg_quote('"/></w:rPr><w:t>$$','/').'/i' => '$$\\2$$',
-					'/'.preg_quote('$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>','/').'([a-z0-9_]+)'.
-						preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>','/').'/i' => '$\\1$',
-					'/'.preg_quote('$ $</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>','/').'([a-z0-9_]+)'.
-						preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>','/').'/i' => '$ $\\1$ $',
+				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document, '.docx') . '-') . '.docx';
+				copy($content_url, $archive);
+				$content_url = 'zip://' . $archive . '#' . ($content_file = 'word/document.xml');
+				$fix = array(        // regular expression to fix garbled placeholders
+									 '/' . preg_quote('$$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>', '/') . '([a-z0-9_]+)' .
+									 preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>', '/') . '/i' => '$$\\1$$',
+									 '/' . preg_quote('$$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:rPr><w:lang w:val="', '/') .
+									 '([a-z]{2}-[A-Z]{2})' . preg_quote('"/></w:rPr><w:t>', '/') . '([a-z0-9_]+)' .
+									 preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:rPr><w:lang w:val="', '/') .
+									 '([a-z]{2}-[A-Z]{2})' . preg_quote('"/></w:rPr><w:t>$$', '/') . '/i'            => '$$\\2$$',
+									 '/' . preg_quote('$</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>', '/') . '([a-z0-9_]+)' .
+									 preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>', '/') . '/i' => '$\\1$',
+									 '/' . preg_quote('$ $</w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>', '/') . '([a-z0-9_]+)' .
+									 preg_quote('</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t>', '/') . '/i' => '$ $\\1$ $',
 				);
 				break;
 			case 'application/xml':
-				$fix = array(	// hack to get Excel 2003 to display additional rows in tables
-					'/ss:ExpandedRowCount="\d+"/' => 'ss:ExpandedRowCount="9999"',
+				$fix = array(    // hack to get Excel 2003 to display additional rows in tables
+								 '/ss:ExpandedRowCount="\d+"/' => 'ss:ExpandedRowCount="9999"',
 				);
 				break;
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.shee':
 				$mimetype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
 			case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
 			case 'application/vnd.ms-excel.sheet.macroenabled.12':
-				$fix = array(	// hack to get Excel 2007 to display additional rows in tables
-					'/ss:ExpandedRowCount="\d+"/' => 'ss:ExpandedRowCount="9999"',
+				$fix = array(    // hack to get Excel 2007 to display additional rows in tables
+								 '/ss:ExpandedRowCount="\d+"/' => 'ss:ExpandedRowCount="9999"',
 				);
-				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document,'.xlsx').'-').'.xlsx';
-				copy($content_url,$archive);
-				$content_url = 'zip://'.$archive.'#'.($content_file = 'xl/sharedStrings.xml');
+				$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document, '.xlsx') . '-') . '.xlsx';
+				copy($content_url, $archive);
+				$content_url = 'zip://' . $archive . '#' . ($content_file = 'xl/sharedStrings.xml');
 				break;
 		}
 		$err = null;
-		if (!($merged =& $this->merge($content_url,$ids,$err,$mimetype,$fix)))
+		if(!($merged =& $this->merge($content_url, $ids, $err, $mimetype, $fix)))
 		{
 			//error_log(__METHOD__."() !this->merge() err=$err");
 			return $err;
 		}
 		// Apply HTML formatting to target document, if possible
 		// check if we can use the XSL extension, to not give a fatal error and rendering whole merge-print non-functional
-		if (class_exists('XSLTProcessor') && class_exists('DOMDocument') && $this->parse_html_styles)
+		if(class_exists('XSLTProcessor') && class_exists('DOMDocument') && $this->parse_html_styles)
 		{
 			try
 			{
@@ -1996,22 +2167,25 @@ abstract class Merge
 			{
 				// Error converting HTML styles over
 				error_log($e->getMessage());
-				error_log("Target document: $content_url, IDs: ". array2string($ids));
+				error_log("Target document: $content_url, IDs: " . array2string($ids));
 
 				// Try again, but strip HTML so user gets something
 				$this->parse_html_styles = false;
-				if (!($merged =& $this->merge($content_url,$ids,$err,$mimetype,$fix)))
+				if(!($merged =& $this->merge($content_url, $ids, $err, $mimetype, $fix)))
 				{
 					return $err;
 				}
 			}
-			if ($this->report_memory_usage) error_log(__METHOD__."() after HTML processing ".Api\Vfs::hsize(memory_get_peak_usage(true)));
+			if($this->report_memory_usage)
+			{
+				error_log(__METHOD__ . "() after HTML processing " . Api\Vfs::hsize(memory_get_peak_usage(true)));
+			}
 		}
 		if(!empty($name))
 		{
 			if(empty($ext))
 			{
-				$ext = '.'.pathinfo($document,PATHINFO_EXTENSION);
+				$ext = '.' . pathinfo($document, PATHINFO_EXTENSION);
 			}
 			$name .= $ext;
 		}
@@ -2020,33 +2194,45 @@ abstract class Merge
 			$name = basename($document);
 		}
 		$header = array('name' => $name, 'mime' => $mimetype);
-		if (isset($archive))
+		if(isset($archive))
 		{
 			$zip = new ZipArchive;
-			if ($zip->open($archive, ZipArchive::CHECKCONS) !== true)
+			if($zip->open($archive, ZipArchive::CHECKCONS) !== true)
 			{
-				error_log(__METHOD__.__LINE__." !ZipArchive::open('$archive',ZIPARCHIVE"."::CHECKCONS) failed. Trying open without validating");
-				if ($zip->open($archive) !== true) throw new Api\Exception("!ZipArchive::open('$archive',|ZIPARCHIVE::CHECKCONS)");
+				error_log(__METHOD__ . __LINE__ . " !ZipArchive::open('$archive',ZIPARCHIVE" . "::CHECKCONS) failed. Trying open without validating");
+				if($zip->open($archive) !== true)
+				{
+					throw new Api\Exception("!ZipArchive::open('$archive',|ZIPARCHIVE::CHECKCONS)");
+				}
+			}
+			if($zip->addFromString($content_file, $merged) !== true)
+			{
+				throw new Api\Exception("!ZipArchive::addFromString('$content_file',\$merged)");
+			}
+			if($zip->close() !== true)
+			{
+				throw new Api\Exception("!ZipArchive::close()");
 			}
-			if ($zip->addFromString($content_file,$merged) !== true) throw new Api\Exception("!ZipArchive::addFromString('$content_file',\$merged)");
-			if ($zip->close() !== true) throw new Api\Exception("!ZipArchive::close()");
 			unset($zip);
 			unset($merged);
-			if ($this->report_memory_usage) error_log(__METHOD__."() after ZIP processing ".Api\Vfs::hsize(memory_get_peak_usage(true)));
+			if($this->report_memory_usage)
+			{
+				error_log(__METHOD__ . "() after ZIP processing " . Api\Vfs::hsize(memory_get_peak_usage(true)));
+			}
 			$header['filesize'] = filesize($archive);
 		}
 		else
 		{
-			$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document,'.'.$ext).'-').'.'.$ext;
-			if ($mimetype == 'application/xml')
+			$archive = tempnam($GLOBALS['egw_info']['server']['temp_dir'], basename($document, '.' . $ext) . '-') . '.' . $ext;
+			if($mimetype == 'application/xml')
 			{
-				if (strpos($merged,'<?mso-application progid="Word.Document"?>') !== false)
+				if(strpos($merged, '<?mso-application progid="Word.Document"?>') !== false)
 				{
-					$header['mimetype'] = 'application/msword';	// to open it automatically in word or oowriter
+					$header['mimetype'] = 'application/msword';    // to open it automatically in word or oowriter
 				}
-				elseif (strpos($merged,'<?mso-application progid="Excel.Sheet"?>') !== false)
+				elseif(strpos($merged, '<?mso-application progid="Excel.Sheet"?>') !== false)
 				{
-					$header['mimetype'] = 'application/vnd.ms-excel';	// to open it automatically in excel or oocalc
+					$header['mimetype'] = 'application/vnd.ms-excel';    // to open it automatically in excel or oocalc
 				}
 			}
 			$handle = fopen($archive, 'w');
@@ -2068,13 +2254,22 @@ abstract class Merge
 	 */
 	public function download_by_request()
 	{
-		if(empty($_POST['data_document_name'])) return false;
-		if(empty($_POST['data_document_dir'])) return false;
-		if(empty($_POST['data_checked'])) return false;
+		if(empty($_POST['data_document_name']))
+		{
+			return false;
+		}
+		if(empty($_POST['data_document_dir']))
+		{
+			return false;
+		}
+		if(empty($_POST['data_checked']))
+		{
+			return false;
+		}
 
 		return $this->download(
 			$_POST['data_document_name'],
-			explode(',',$_POST['data_checked']),
+			explode(',', $_POST['data_checked']),
 			'',
 			$_POST['data_document_dir']
 		);
@@ -2084,27 +2279,33 @@ abstract class Merge
 	 * Get a list of document actions / files from the given directory
 	 *
 	 * @param string $dirs Directory(s comma or space separated) to search
-	 * @param string $prefix='document_' prefix for array keys
-	 * @param array|string $mime_filter=null allowed mime type(s), default all, negative filter if $mime_filter[0] === '!'
+	 * @param string $prefix ='document_' prefix for array keys
+	 * @param array|string $mime_filter =null allowed mime type(s), default all, negative filter if $mime_filter[0] === '!'
 	 * @return array List of documents, suitable for a selectbox.  The key is document_<filename>.
 	 */
-	public static function get_documents($dirs, $prefix='document_', $mime_filter=null, $app='')
+	public static function get_documents($dirs, $prefix = 'document_', $mime_filter = null, $app = '')
 	{
-		$export_limit=self::getExportLimit($app);
-		if (!$dirs || (!self::hasExportLimit($export_limit,'ISALLOWED') && !self::is_export_limit_excepted())) return array();
+		$export_limit = self::getExportLimit($app);
+		if(!$dirs || (!self::hasExportLimit($export_limit, 'ISALLOWED') && !self::is_export_limit_excepted()))
+		{
+			return array();
+		}
 
 		// split multiple comma or whitespace separated directories
 		// to still allow space or comma in dirnames, we also use the trailing slash of all pathes to split
-		if (count($dirs = preg_split('/[,\s]+\//', $dirs)) > 1)
+		if(count($dirs = preg_split('/[,\s]+\//', $dirs)) > 1)
 		{
 			foreach($dirs as $n => &$d)
 			{
-				if ($n) $d = '/'.$d;	// re-adding trailing slash removed by split
+				if($n)
+				{
+					$d = '/' . $d;
+				}    // re-adding trailing slash removed by split
 			}
 		}
-		if ($mime_filter && ($negativ_filter = $mime_filter[0] === '!'))
+		if($mime_filter && ($negativ_filter = $mime_filter[0] === '!'))
 		{
-			if (is_array($mime_filter))
+			if(is_array($mime_filter))
 			{
 				unset($mime_filter[0]);
 			}
@@ -2116,15 +2317,21 @@ abstract class Merge
 		$list = array();
 		foreach($dirs as $dir)
 		{
-			if (($files = Api\Vfs::find($dir,array('need_mime'=>true),true)))
+			if(($files = Api\Vfs::find($dir, array('need_mime' => true), true)))
 			{
 				foreach($files as $file)
 				{
 					// return only the mime-types we support
-					$parts = explode('.',$file['name']);
-					if (!self::is_implemented($file['mime'],'.'.array_pop($parts))) continue;
-					if ($mime_filter && $negativ_filter === in_array($file['mime'], (array)$mime_filter)) continue;
-					$list[$prefix.$file['name']] = Api\Vfs::decodePath($file['name']);
+					$parts = explode('.', $file['name']);
+					if(!self::is_implemented($file['mime'], '.' . array_pop($parts)))
+					{
+						continue;
+					}
+					if($mime_filter && $negativ_filter === in_array($file['mime'], (array)$mime_filter))
+					{
+						continue;
+					}
+					$list[$prefix . $file['name']] = Api\Vfs::decodePath($file['name']);
 				}
 			}
 		}
@@ -2149,17 +2356,21 @@ abstract class Merge
 	 * @param int|string $export_limit =null export-limit, default $GLOBALS['egw_info']['server']['export_limit']
 	 * @return array see nextmatch_widget::egw_actions
 	 */
-	public static function document_action($dirs, $group=0, $caption='Insert in document', $prefix='document_', $default_doc='',
-		$export_limit=null)
+	public static function document_action($dirs, $group = 0, $caption = 'Insert in document', $prefix = 'document_', $default_doc = '',
+										   $export_limit = null)
 	{
 		$documents = array();
-		if ($export_limit == null) $export_limit = self::getExportLimit(); // check if there is a globalsetting
+		if($export_limit == null)
+		{
+			$export_limit = self::getExportLimit();
+		} // check if there is a globalsetting
 
-		try {
-			if (class_exists('EGroupware\\collabora\\Bo') &&
-					$GLOBALS['egw_info']['user']['apps']['collabora'] &&
-					($discovery = \EGroupware\collabora\Bo::discover()) &&
-					$GLOBALS['egw_info']['user']['preferences']['filemanager']['merge_open_handler'] != 'download'
+		try
+		{
+			if(class_exists('EGroupware\\collabora\\Bo') &&
+				$GLOBALS['egw_info']['user']['apps']['collabora'] &&
+				($discovery = \EGroupware\collabora\Bo::discover()) &&
+				$GLOBALS['egw_info']['user']['preferences']['filemanager']['merge_open_handler'] != 'download'
 			)
 			{
 				$editable_mimes = $discovery;
@@ -2170,7 +2381,7 @@ abstract class Merge
 			// ignore failed discovery
 			unset($e);
 		}
-		if ($default_doc && ($file = Api\Vfs::stat($default_doc)))	// put default document on top
+		if($default_doc && ($file = Api\Vfs::stat($default_doc)))    // put default document on top
 		{
 			if(!$file['mime'])
 			{
@@ -2183,31 +2394,34 @@ abstract class Merge
 				'group'   => 1
 			);
 			self::document_editable_action($documents['document'], $file);
-			if ($file['mime'] == 'message/rfc822')
+			if($file['mime'] == 'message/rfc822')
 			{
 				self::document_mail_action($documents['document'], $file);
 			}
 		}
 
 		$files = array();
-		if ($dirs)
+		if($dirs)
 		{
 			// split multiple comma or whitespace separated directories
 			// to still allow space or comma in dirnames, we also use the trailing slash of all pathes to split
-			if (count($dirs = preg_split('/[,\s]+\//', $dirs)) > 1)
+			if(count($dirs = preg_split('/[,\s]+\//', $dirs)) > 1)
 			{
 				foreach($dirs as $n => &$d)
 				{
-					if ($n) $d = '/'.$d;	// re-adding trailing slash removed by split
+					if($n)
+					{
+						$d = '/' . $d;
+					}    // re-adding trailing slash removed by split
 				}
 			}
 			foreach($dirs as $dir)
 			{
-				$files += Api\Vfs::find($dir,array(
+				$files += Api\Vfs::find($dir, array(
 					'need_mime' => true,
-					'order' => 'fs_name',
-					'sort' => 'ASC',
-				),true);
+					'order'     => 'fs_name',
+					'sort'      => 'ASC',
+				),                      true);
 			}
 		}
 
@@ -2215,10 +2429,10 @@ abstract class Merge
 		foreach($files as $key => $file)
 		{
 			// use only the mime-types we support
-			$parts = explode('.',$file['name']);
-			if (!self::is_implemented($file['mime'],'.'.array_pop($parts)) ||
-				!Api\Vfs::check_access($file['path'], Api\Vfs::READABLE, $file) ||	// remove files not readable by user
-				$file['path'] === $default_doc)	// default doc already added
+			$parts = explode('.', $file['name']);
+			if(!self::is_implemented($file['mime'], '.' . array_pop($parts)) ||
+				!Api\Vfs::check_access($file['path'], Api\Vfs::READABLE, $file) ||    // remove files not readable by user
+				$file['path'] === $default_doc)    // default doc already added
 			{
 				unset($files[$key]);
 			}
@@ -2231,13 +2445,13 @@ abstract class Merge
 				}
 				else
 				{
-					$dircount[$dirname] ++;
+					$dircount[$dirname]++;
 				}
 			}
 		}
 		foreach($files as $file)
 		{
-			if (count($dircount) > 1)
+			if(count($dircount) > 1)
 			{
 				$name_arr = explode('/', $file['name']);
 				$current_level = &$documents;
@@ -2249,21 +2463,24 @@ abstract class Merge
 					}
 					else
 					{
-						$current_level = &$current_level[$prefix.$name_arr[($count-1)]]['children'];
+						$current_level = &$current_level[$prefix . $name_arr[($count - 1)]]['children'];
 					}
 					switch($count)
 					{
 						case (count($name_arr) - 1):
-							$current_level[$prefix . $file['name']];
+							if(!isset($current_level[$prefix . $file['name']]))
+							{
+								$current_level[$prefix . $file['name']] = [];
+							}
 							self::document_editable_action($current_level[$prefix . $file['name']], $file);
-							if($file['mime'] == 'message/rfc822')
+							if($file['mime'] === 'message/rfc822')
 							{
 								self::document_mail_action($current_level[$prefix . $file['name']], $file);
 							}
 							break;
 
 						default:
-							if(!is_array($current_level[$prefix . $name_arr[$count]]))
+							if(!isset($current_level[$prefix . $name_arr[$count]]))
 							{
 								// create parent folder
 								$current_level[$prefix . $name_arr[$count]] = array(
@@ -2277,31 +2494,34 @@ abstract class Merge
 					}
 				}
 			}
-			else if (count($files) >= self::SHOW_DOCS_BY_MIME_LIMIT)
-			{
-				if(!isset($documents[$file['mime']]))
-				{
-					$documents[$file['mime']] = array(
-						'icon'     => Api\Vfs::mime_icon($file['mime']),
-						'caption'  => Api\MimeMagic::mime2label($file['mime']),
-						'group'    => 2,
-						'children' => array(),
-					);
-				}
-				$documents[$file['mime']]['children'][$prefix . $file['name']] = array();
-				self::document_editable_action($documents[$file['mime']]['children'][$prefix . $file['name']], $file);
-				if($file['mime'] == 'message/rfc822')
-				{
-					self::document_mail_action($documents[$file['mime']]['children'][$prefix . $file['name']], $file);
-				}
-			}
 			else
 			{
-				$documents[$prefix . $file['name']] = array();
-				self::document_editable_action($documents[$prefix . $file['name']], $file);
-				if($file['mime'] == 'message/rfc822')
+				if(count($files) >= self::SHOW_DOCS_BY_MIME_LIMIT)
 				{
-					self::document_mail_action($documents[$prefix . $file['name']], $file);
+					if(!isset($documents[$file['mime']]))
+					{
+						$documents[$file['mime']] = array(
+							'icon'     => Api\Vfs::mime_icon($file['mime']),
+							'caption'  => Api\MimeMagic::mime2label($file['mime']),
+							'group'    => 2,
+							'children' => array(),
+						);
+					}
+					$documents[$file['mime']]['children'][$prefix . $file['name']] = array();
+					self::document_editable_action($documents[$file['mime']]['children'][$prefix . $file['name']], $file);
+					if($file['mime'] == 'message/rfc822')
+					{
+						self::document_mail_action($documents[$file['mime']]['children'][$prefix . $file['name']], $file);
+					}
+				}
+				else
+				{
+					$documents[$prefix . $file['name']] = array();
+					self::document_editable_action($documents[$prefix . $file['name']], $file);
+					if($file['mime'] == 'message/rfc822')
+					{
+						self::document_mail_action($documents[$prefix . $file['name']], $file);
+					}
 				}
 			}
 		}
@@ -2330,11 +2550,11 @@ abstract class Merge
 	 * back to the client like the other documents.  Merging for a single selected
 	 * contact opens a compose window, multiple contacts just sends.
 	 *
-	 * @param Array &$action Action to be modified for mail
-	 * @param Array $file Array of information about the document from Api\Vfs::find
+	 * @param array &$action Action to be modified for mail
+	 * @param array $file Array of information about the document from Api\Vfs::find
 	 * @return void
 	 */
-	private static function document_mail_action(Array &$action, $file)
+	private static function document_mail_action(array &$action, $file)
 	{
 		unset($action['postSubmit']);
 		unset($action['onExecute']);
@@ -2345,30 +2565,30 @@ abstract class Merge
 		// These parameters trigger compose + merge - only if 1 row
 		$extra = array(
 			'from=merge',
-			'document='.$file['path'],
-			'merge='.get_called_class()
+			'document=' . $file['path'],
+			'merge=' . get_called_class()
 		);
 
 		// egw.open() used if only 1 row selected
-		$action['egw_open'] = 'edit-mail--'.implode('&',$extra);
-		$action['target'] = 'compose_' .$file['path'];
+		$action['egw_open'] = 'edit-mail--' . implode('&', $extra);
+		$action['target'] = 'compose_' . $file['path'];
 
 		// long_task runs menuaction once for each selected row
 		$action['nm_action'] = 'long_task';
 		$action['popup'] = Api\Link::get_registry('mail', 'edit_popup');
-		$action['message'] = lang('insert in %1',Api\Vfs::decodePath($file['name']));
-		$action['menuaction'] = 'mail.mail_compose.ajax_merge&document='.$file['path'].'&merge='. get_called_class();
+		$action['message'] = lang('insert in %1', Api\Vfs::decodePath($file['name']));
+		$action['menuaction'] = 'mail.mail_compose.ajax_merge&document=' . $file['path'] . '&merge=' . get_called_class();
 	}
 
 	/**
 	 * Set up a document action so the generated file is saved and opened in
 	 * the collabora editor (if collabora is available)
 	 *
-	 * @param Array &$action Action to be modified for editor
-	 * @param Array $file Array of information about the document from Api\Vfs::find
+	 * @param array &$action Action to be modified for editor
+	 * @param array $file Array of information about the document from Api\Vfs::find
 	 * @return void
 	 */
-	private static function document_editable_action(Array &$action, $file)
+	private static function document_editable_action(array &$action, $file)
 	{
 		static $action_base = array(
 			// The same for every file
@@ -2409,25 +2629,28 @@ abstract class Merge
 		{
 			// split multiple comma or whitespace separated directories
 			// to still allow space or comma in dirnames, we also use the trailing slash of all pathes to split
-			if ($dirs && ($dirs = preg_split('/[,\s]+\//', $dirs)))
+			if($dirs && ($dirs = preg_split('/[,\s]+\//', $dirs)))
 			{
 				foreach($dirs as $n => $dir)
 				{
-					if ($n) $dir = '/'.$dir;	// re-adding trailing slash removed by split
-					if (Api\Vfs::stat($dir.'/'.$document) && Api\Vfs::is_readable($dir.'/'.$document))
+					if($n)
 					{
-						$document = $dir.'/'.$document;
+						$dir = '/' . $dir;
+					}    // re-adding trailing slash removed by split
+					if(Api\Vfs::stat($dir . '/' . $document) && Api\Vfs::is_readable($dir . '/' . $document))
+					{
+						$document = $dir . '/' . $document;
 						return false;
 					}
 				}
 			}
 		}
-		elseif (Api\Vfs::stat($document) && Api\Vfs::is_readable($document))
+		elseif(Api\Vfs::stat($document) && Api\Vfs::is_readable($document))
 		{
 			return false;
 		}
 		//error_log(__METHOD__."('$document', dirs='$dirs') returning 'Document '$document' does not exist or is not readable for you!'");
-		return lang("Document '%1' does not exist or is not readable for you!",$document);
+		return lang("Document '%1' does not exist or is not readable for you!", $document);
 	}
 
 	/**
@@ -2451,7 +2674,7 @@ abstract class Merge
 			$document_merge = new Api\Contacts\Merge();
 		}
 
-		if(($error = $document_merge->check_document($_REQUEST['document'],'')))
+		if(($error = $document_merge->check_document($_REQUEST['document'], '')))
 		{
 			error_log(__METHOD__ . "({$_REQUEST['document']}) $error");
 			return;
@@ -2569,7 +2792,7 @@ abstract class Merge
 			$name = str_replace(
 				array_keys($placeholders),
 				array_values($placeholders),
-				is_array($pref) ? implode(' ', $pref) : $pref
+				is_array($pref) ? implode(' ', $pref) : str_replace(',', ', ', $pref)
 			);
 		}
 		return $name;
@@ -2609,7 +2832,7 @@ abstract class Merge
 		);
 
 		// Check for a configured preferred directory
-		if(($pref = $GLOBALS['egw_info']['user']['preferences']['filemanager'][Merge::PREF_STORE_LOCATION]) && Vfs::is_writable($pref))
+		if(($pref = $GLOBALS['egw_info']['user']['preferences'][$this->get_app()][Merge::PREF_STORE_LOCATION]) && Vfs::is_writable($pref))
 		{
 			$target = $pref;
 		}
@@ -2622,15 +2845,15 @@ abstract class Merge
 	 *
 	 * @param Api\Contacts\Merge $merge App-specific merge object
 	 */
-	protected function get_all_ids(Api\Storage\Merge $merge)
+	protected static function get_all_ids(Api\Storage\Merge $merge)
 	{
 		$ids = array();
 		$locations = array('index', 'session_data');
 
 		// Get app
-		list($appname, $_merge) = explode('_',  get_class($merge));
+		list($appname, $_merge) = explode('_', get_class($merge));
 
-		if($merge instanceOf Api\Contacts\Merge)
+		if($merge instanceof Api\Contacts\Merge)
 		{
 			$appname = 'addressbook';
 		}
@@ -2653,13 +2876,13 @@ abstract class Merge
 		if(class_exists($ui_class))
 		{
 			$ui = new $ui_class();
-			if( method_exists($ui_class, 'get_all_ids') )
+			if(method_exists($ui_class, 'get_all_ids'))
 			{
 				return $ui->get_all_ids();
 			}
 
 			// Try cache, preferring get_rrows over get_rows
-			if (method_exists($ui_class, $get_rows='get_rrows') || method_exists($ui_class, $get_rows='get_rows'))
+			if(method_exists($ui_class, $get_rows = 'get_rrows') || method_exists($ui_class, $get_rows = 'get_rows'))
 			{
 				foreach($locations as $location)
 				{
@@ -2670,22 +2893,28 @@ abstract class Merge
 					}
 				}
 				$rows = $readonlys = array();
-				@set_time_limit(0);			// switch off the execution time limit, as it's for big selections to small
-				$session['num_rows'] = -1;	// all
+				@set_time_limit(0);            // switch off the execution time limit, as it's for big selections to small
+				$session['num_rows'] = -1;     // all
 				$ui->$get_rows($session, $rows, $readonlys);
 				foreach($rows as $row_number => $row)
 				{
-					if(!is_numeric($row_number)) continue;
+					if(!is_numeric($row_number))
+					{
+						continue;
+					}
 					$row_id = $row[$session['row_id'] ? $session['row_id'] : 'id'];
-					switch (get_class($merge))
+					switch(get_class($merge))
 					{
 						case \calendar_merge::class:
-							$explody = explode(':',$row_id);
+							$explody = explode(':', $row_id);
 							$ids[] = array('id' => $explody[0], 'recur_date' => $explody[1]);
 							break;
 						case \timesheet_merge::class:
 							// Skip the rows with totalss
-							if(!is_numeric($row_id)) continue 2;	// +1 for switch
+							if(!is_numeric($row_id))
+							{
+								continue 2;
+							}    // +1 for switch
 						// Fall through
 						default:
 							$ids[] = $row_id;
@@ -2714,17 +2943,20 @@ abstract class Merge
 	 * @param string $_mimetype =''
 	 * @return string
 	 */
-	static public function number_format($number,$num_decimal_places=2,$_mimetype='')
+	static public function number_format($number, $num_decimal_places = 2, $_mimetype = '')
 	{
-		if ((string)$number === '') return '';
+		if((string)$number === '')
+		{
+			return '';
+		}
 		//error_log(__METHOD__.$_mimetype);
 		switch($_mimetype)
 		{
-			case 'application/xml':	// Excel 2003
+			case 'application/xml':    // Excel 2003
 			case 'application/vnd.oasis.opendocument.spreadsheet': // OO.o spreadsheet
-				return number_format(str_replace(' ','',$number),$num_decimal_places,'.','');
+				return number_format(str_replace(' ', '', $number), $num_decimal_places, '.', '');
 		}
-		return Api\Etemplate::number_format($number,$num_decimal_places);
+		return Api\Etemplate::number_format($number, $num_decimal_places);
 	}
 
 	/**
@@ -2852,12 +3084,13 @@ abstract class Merge
 	{
 		foreach(Customfields::get($this->get_app()) as $name => $field)
 		{
-			if(array_key_exists($field['type'], Api\Link::app_list()))
+			// Avoid recursing between custom fields of different apps
+			if(array_key_exists($field['type'], Api\Link::app_list()) && substr_count($prefix, '#') == 0)
 			{
 				$app = self::get_app_class($field['type']);
 				if($app)
 				{
-					$this->add_linked_placeholders($placeholders, $name, $app->get_placeholder_list('#' . $name));
+					$this->add_linked_placeholders($placeholders, $name, $app->get_placeholder_list(($prefix ? $prefix . '/' : '') . '#' . $name));
 				}
 			}
 			else
@@ -2879,7 +3112,6 @@ abstract class Merge
 	public function get_placeholder_list($prefix = '')
 	{
 		$placeholders = [
-			'placeholders' => []
 		];
 
 		$this->add_customfield_placeholders($placeholders, $prefix);
@@ -2911,4 +3143,155 @@ abstract class Merge
 		*/
 		$placeholder_list[$base_name] = $add_placeholder_groups;
 	}
+
+	/**
+	 * Get preference settings
+	 *
+	 * Merge has some preferences that the same across apps, but can have different values for each app:
+	 * - Default document
+	 * - Document template directory
+	 * - Filename customization
+	 * - Generated document target directory
+	 */
+	public function merge_preferences()
+	{
+		$settings = array();
+
+		switch($this->get_app())
+		{
+			case 'addressbook':
+				// lang() will mangle the %5C encoded \ in api.EGroupware\\Api\\Contacts\\Merge.show_replacements
+				$pref_list_link = Api\Html::a_href('', Api\Framework::link('/index.php', [
+					'menuaction' => 'addressbook.addressbook_merge.show_replacements'
+				],                                                         $this->get_app())
+				);
+				break;
+			default:
+				$pref_list_link = Api\Html::a_href('', Api\Framework::link('/index.php', [
+					'menuaction' => $this->get_app() . '.' . get_class($this) . '.show_replacements'
+				],                                                         $this->get_app())
+				);
+		}
+		$pref_list_link = str_replace('</a>', '', $pref_list_link);
+		$settings[self::PREF_DEFAULT_TEMPLATE] = array(
+			'type'     => 'vfs_file',
+			'size'     => 60,
+			'label'    => 'Default document to insert entries',
+			'name'     => self::PREF_DEFAULT_TEMPLATE,
+			'help'     => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.', lang($this->get_app())) . ' ' .
+				lang('the document can contain placeholder like {{%3}}, to be replaced with the data (%1full list of placeholder names%2).', $pref_list_link, '</a>', 'name') . ' <br/>' .
+				lang('The following document-types are supported:') . implode(',', self::get_file_extensions()),
+			'run_lang' => false,
+			'xmlrpc'   => True,
+			'admin'    => False,
+		);
+		$settings[self::PREF_TEMPLATE_DIR] = array(
+			'type'     => 'vfs_dirs',
+			'size'     => 60,
+			'label'    => 'Directory with documents to insert entries',
+			'name'     => self::PREF_TEMPLATE_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($this->get_app())) . ' ' .
+				lang('the document can contain placeholder like {{%3}}, to be replaced with the data (%1full list of placeholder names%2).', $pref_list_link, '</a>', 'name') . ' <br/>' .
+				lang('The following document-types are supported:') . implode(',', self::get_file_extensions()),
+			'run_lang' => false,
+			'xmlrpc'   => True,
+			'admin'    => False,
+			'default'  => '/templates/' . $this->get_app(),
+		);
+		$settings[self::PREF_STORE_LOCATION] = array(
+			'type'  => 'vfs_dir',
+			'size'  => 60,
+			'label' => 'Directory for storing merged documents',
+			'name'  => self::PREF_STORE_LOCATION,
+			'help'  => lang('When you merge entries into documents, they will be stored here.  If no directory is provided, they will be stored in your home directory (%1)', Vfs::get_home_dir())
+		);
+
+		$settings[self::PREF_DOCUMENT_FILENAME] = array(
+			'type'    => 'taglist',
+			'label'   => 'Merged document filename',
+			'name'    => self::PREF_DOCUMENT_FILENAME,
+			'values'  => self::DOCUMENT_FILENAME_OPTIONS,
+			'help'    => 'Choose the default filename for merged documents.',
+			'xmlrpc'  => True,
+			'admin'   => False,
+			'default' => '$$document$$',
+		);
+
+		return $settings;
+	}
+
+	/**
+	 * Show replacement placeholders for the app
+	 *
+	 * Generates a page that shows all the available placeholders for this appliction.  By default,
+	 * we have all placeholders generated in get_placeholder_list() (including any custom fields)
+	 * as well as the common and current user placeholders.
+	 *
+	 * By overridding show_replacements_hook(), extending classes can override without having to
+	 * re-implement everything.
+	 */
+	public function show_replacements()
+	{
+		$template_name = 'api.show_replacements';
+		$content = $sel_options = $readonlys = $preserve = array();
+
+		$content['appname'] = $this->get_app();
+		$content['placeholders'] = $this->remap_replacement_list($this->get_placeholder_list());
+		$content['extra'] = array();
+		$content['common'] = $this->remap_replacement_list($this->get_common_placeholder_list());
+		$content['user'] = $this->remap_replacement_list($this->get_user_placeholder_list());
+
+		$this->show_replacements_hook($template_name, $content, $sel_options, $readonlys);
+		$etemplate = new Api\Etemplate($template_name);
+
+		$etemplate->exec('filemanager.filemanager_ui.file', $content, $sel_options, $readonlys, $preserve, 2);
+	}
+
+	/**
+	 * Helper function for show_replacements() to change the output of get_placeholder_list() into somethig
+	 * more suited for etemplate repeating rows.
+	 *
+	 * @param $list
+	 * @return array
+	 */
+	protected function remap_replacement_list($list, $title_prefix = '')
+	{
+		$new_list = [];
+		foreach($list as $group_title => $group_placeholders)
+		{
+			if(is_array($group_placeholders) && !array_key_exists('0', $group_placeholders))
+			{
+				// Limit how far we go through linked entries
+				if($title_prefix)
+				{
+					continue;
+				}
+				$new_list = array_merge($new_list, $this->remap_replacement_list($group_placeholders, $group_title));
+			}
+			else
+			{
+				$new_list[] = [
+					'title'        => ($title_prefix ? $title_prefix . ': ' : '') . $group_title,
+					'placeholders' => $group_placeholders
+				];
+			}
+		}
+		return $new_list;
+	}
+
+	/**
+	 * Hook for extending apps to customise the replacements UI without having to override the whole method.
+	 *
+	 * This can include detailed descriptions or instructions, documentation of tables and custom stuff
+	 * Set $content['extra_template'] to a template ID with extra descriptions or instructions and it will be
+	 * added into the main template.
+	 *
+	 * @param string $template_name
+	 * @param $content
+	 * @param $sel_options
+	 * @param $readonlys
+	 */
+	protected function show_replacements_hook(&$template_name, &$content, &$sel_options, &$readonlys)
+	{
+	}
 }
diff --git a/api/src/Storage/Tracking.php b/api/src/Storage/Tracking.php
index 7a6bbbf1f7..90e3d365e8 100644
--- a/api/src/Storage/Tracking.php
+++ b/api/src/Storage/Tracking.php
@@ -283,7 +283,7 @@ abstract class Tracking
 				//error_log(__METHOD__."() $name: data['#$name']=".array2string($data['#'.$name]).", field[values]=".array2string($field['values']));
 				$details['#'.$name] = array(
 					'label' => $field['label'],
-					'value' => Customfields::format($field, $data['#'.$name]),
+					'value' => Customfields::format($field, $data['#'.$name] ?? null),
 				);
 				//error_log("--> details['#$name']=".array2string($details['#'.$name]));
 			}
@@ -636,13 +636,13 @@ abstract class Tracking
 		{
 			//error_log(__METHOD__."() data[$this->assigned_field]=".print_r($data[$this->assigned_field],true).", old[$this->assigned_field]=".print_r($old[$this->assigned_field],true));
 			$old_assignees = array();
-			$assignees = $assigned ? $assigned : array();
-			if ($data[$this->assigned_field])	// current assignments
+			$assignees = $assigned ?? array();
+			if (!empty($data[$this->assigned_field]))	// current assignments
 			{
 				$assignees = is_array($data[$this->assigned_field]) ?
 					$data[$this->assigned_field] : explode(',',$data[$this->assigned_field]);
 			}
-			if ($old && $old[$this->assigned_field])
+			if ($old && !empty($old[$this->assigned_field]))
 			{
 				$old_assignees = is_array($old[$this->assigned_field]) ?
 					$old[$this->assigned_field] : explode(',',$old[$this->assigned_field]);
@@ -1050,7 +1050,7 @@ abstract class Tracking
 			// remove the session-id in the notification mail!
 			$link = preg_replace('/(sessionid|kp3|domain)=[^&]+&?/','',$link);
 
-			if ($popup) $link .= '&nopopup=1';
+			if (!empty($popup)) $link .= '&nopopup=1';
 		}
 		//error_log(__METHOD__."(..., $allow_popup, $receiver) returning ".array2string($allow_popup ? array($link,$popup) : $link));
 		return $allow_popup ? array($link,$popup) : $link;
@@ -1123,22 +1123,22 @@ abstract class Tracking
 		{
 			// if there's no old entry, the entry is not modified by definition
 			// if both values are '', 0 or null, we count them as equal too
-			$modified = $old && $data[$name] != $old[$name] && !(!$data[$name] && !$old[$name]);
+			$modified = $old && ($data[$name] ?? null) != ($old[$name] ?? null) && !(empty($data[$name]) && empty($old[$name]));
 			//if ($modified) error_log("data[$name]=".print_r($data[$name],true).", old[$name]=".print_r($old[$name],true)." --> modified=".(int)$modified);
 			if (empty($detail['value']) && !$modified) continue;	// skip unchanged, empty values
 
-			$body .= $this->format_line($html_email,$detail['type'],$modified,
-				$detail['label'] ? $detail['label'] : '', $detail['value']);
+			$body .= $this->format_line($html_email, $detail['type'] ?? null, $modified,
+				$detail['label'] ?? '', $detail['value']);
 		}
 		if ($html_email)
 		{
 			$body .= "</table>\n";
 		}
-		if(($sig = $this->get_signature($data,$old,$receiver)))
+		if (($sig = $this->get_signature($data,$old,$receiver)))
 		{
 			$body .= ($html_email ? '<br />':'') . "\n$sig";
 		}
-		if (!$html_email && $data['tr_edit_mode'] == 'html')
+		if (!$html_email && isset($data['tr_edit_mode']) && $data['tr_edit_mode'] === 'html')
 		{
 			$body = Api\Mail\Html::convertHTMLToText($body);
 		}
@@ -1271,7 +1271,7 @@ abstract class Tracking
 			$merge_class = $this->app.'_merge';
 			$merge = new $merge_class();
 			$error = null;
-			$sig = $merge->merge_string($config['signature'], array($data[$this->id_field]), $error, 'text/html');
+			$sig = $merge->merge_string($config['signature']??null, array($data[$this->id_field]), $error, 'text/html');
 			if($error)
 			{
 				error_log($error);
diff --git a/api/src/Vfs/Base.php b/api/src/Vfs/Base.php
index 9f9eea7486..1521ce3eb0 100644
--- a/api/src/Vfs/Base.php
+++ b/api/src/Vfs/Base.php
@@ -335,21 +335,20 @@ class Base
 					$url = str_replace($matches[0], $matches[1] . Vfs::concat($matches[2], substr($parts['path'], strlen($mounted))), $url);
 				}
 
-				if($replace_user_pass_host)
+				if ($replace_user_pass_host)
 				{
-					$url = str_replace(array('$user',
-											 '$pass',
-											 '$host',
-											 '$home'), array($parts['user'],
-															 $parts['pass'],
-															 $parts['host'],
-															 $parts['home']), $url);
+					$url = strtr($url, [
+						'$user' => $parts['user'],
+						'$pass' => $parts['pass'],
+						'$host' => $parts['host'],
+						'$home' => $parts['home'],
+					]);
 				}
-				if($parts['query'])
+				if (isset($parts['query']))
 				{
 					$url .= '?' . $parts['query'];
 				}
-				if($parts['fragment'])
+				if (isset($parts['fragment']))
 				{
 					$url .= '#' . $parts['fragment'];
 				}
@@ -657,7 +656,7 @@ class Base
 				return false;
 			}
 			$k = (string)Vfs::parse_url($url, PHP_URL_SCHEME);
-			if(!(is_array($scheme2urls[$k])))
+			if (!isset($scheme2urls[$k]))
 			{
 				$scheme2urls[$k] = array();
 			}
diff --git a/api/src/Vfs/StreamWrapper.php b/api/src/Vfs/StreamWrapper.php
index acf8690b74..b3794d1a74 100644
--- a/api/src/Vfs/StreamWrapper.php
+++ b/api/src/Vfs/StreamWrapper.php
@@ -798,11 +798,11 @@ class StreamWrapper extends Base implements StreamWrapperIface
 		{
 			$stat['url'] = $url;
 		}
-		if (($stat['mode'] & 0222) && self::url_is_readonly($stat['url']))
+		if ($stat && ($stat['mode'] & 0222) && self::url_is_readonly($stat['url']))
 		{
 			$stat['mode'] &= ~0222;
 		}
-		if($stat['url'] && $query && strpos($stat['url'],'?'.$query)===false)
+		if ($stat && $stat['url'] && $query && strpos($stat['url'],'?'.$query) === false)
 		{
 			$stat['url'] .= '?'.$query;
 		}
@@ -998,7 +998,7 @@ class StreamWrapper extends Base implements StreamWrapperIface
 		}
 		else
 		{
-			$vfs_fstab = $GLOBALS['egw_info']['user']['preferences']['common']['vfs_fstab'];
+			$vfs_fstab = $GLOBALS['egw_info']['user']['preferences']['common']['vfs_fstab'] ?? [];
 		}
 		if (!empty($vfs_fstab) && is_array($vfs_fstab))
 		{
diff --git a/api/src/loader/exception.php b/api/src/loader/exception.php
index 8b95ede10d..43750b6afd 100755
--- a/api/src/loader/exception.php
+++ b/api/src/loader/exception.php
@@ -71,6 +71,7 @@ function _egw_log_exception($e,&$headline=null)
 	{
 		error_log($headline.($e instanceof egw_exception_warning ? ': ' : ' ('.get_class($e).'): ').
 			$e->getMessage().(!empty($e->details) ? ': '.$e->details : ''));
+		error_log('File: '.str_replace(EGW_SERVER_ROOT, '', $e->getFile()).', Line: '.$e->getLine());
 		foreach($trace as $line)
 		{
 			error_log($line);
@@ -103,6 +104,7 @@ function egw_exception_handler($e)
 	if(!isset($_SERVER['HTTP_HOST']) || $GLOBALS['egw_info']['flags']['no_exception_handler'] == 'cli')
 	{
 		echo ($headline ? $headline.': ' : '').$e->getMessage()."\n";
+		echo $e->getFile().' ('.$e->getLine().")\n";
 		if ($GLOBALS['egw_info']['server']['exception_show_trace'])
 		{
 			echo $e->getTraceAsString()."\n";
@@ -116,6 +118,8 @@ function egw_exception_handler($e)
 		$message = '<h3>'.Api\Html::htmlspecialchars($headline)."</h3>\n".
 			'<pre><b>'.Api\Html::htmlspecialchars($e->getMessage())."</b>\n\n";
 
+		echo $e->getFile().' ('.$e->getLine().")\n";
+
 		// only show trace (incl. function arguments) if explicitly enabled, eg. on a development system
 		if ($GLOBALS['egw_info']['server']['exception_show_trace'])
 		{
@@ -174,6 +178,7 @@ function egw_error_handler ($errno, $errstr, $errfile, $errline)
 	{
 		case E_RECOVERABLE_ERROR:
 		case E_USER_ERROR:
+			error_log(__METHOD__."($errno, '$errstr', '$errfile', $errline)");
 			throw new ErrorException($errstr, $errno, 0, $errfile, $errline);
 
 		case E_WARNING:
diff --git a/api/src/loader/security.php b/api/src/loader/security.php
index 6d7396076d..470c19d42c 100755
--- a/api/src/loader/security.php
+++ b/api/src/loader/security.php
@@ -175,6 +175,8 @@ function php_safe_unserialize($str)
  */
 function json_php_unserialize($str, $allow_not_serialized=false)
 {
+	if (!isset($str)) return $str;
+
 	if ((in_array($str[0], array('a', 'i', 's', 'b', 'O', 'C')) && $str[1] == ':' || $str === 'N;') &&
 		($arr = php_safe_unserialize($str)) !== false || $str === 'b:0;')
 	{
diff --git a/api/templates/default/show_replacements.xet b/api/templates/default/show_replacements.xet
new file mode 100644
index 0000000000..576563f3d8
--- /dev/null
+++ b/api/templates/default/show_replacements.xet
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2//EN" "http://www.egroupware.org/etemplate2.dtd">
+<!-- $Id$ -->
+<overlay>
+    <template id="api.show_replacements.placeholder_list">
+        <description id="title" class="title"/>
+        <grid id="placeholders" width="100%">
+            <columns>
+                <column width="30%"/>
+                <column/>
+            </columns>
+            <rows>
+                <row>
+                    <description id="${row}[value]"/>
+                    <description id="${row}[label]"/>
+                </row>
+            </rows>
+        </grid>
+    </template>
+    <template id="api.show_replacements" template="" lang="" group="0" version="21.1.001">
+        <vbox>
+            <description value="Placeholders" class="group title"/>
+            <box id="placeholders">
+                <box id="${row}">
+                    <template template="api.show_replacements.placeholder_list"/>
+                </box>
+            </box>
+            <template template="@extra_template"/>
+            <details title="Common">
+                <description value="Common" class="group title"/>
+                <box id="common">
+                    <box id="${row}">
+                        <template template="api.show_replacements.placeholder_list"/>
+                    </box>
+                </box>
+            </details>
+            <details title="Current user">
+                <description value="Current user" class="group title"/>
+                <box id="user">
+                    <box id="${row}">
+                        <template template="api.show_replacements.placeholder_list"/>
+                    </box>
+                </box>
+            </details>
+        </vbox>
+        <styles>
+            .et2_details_title, .title {
+            display: inline-block;
+            font-weight: bold;
+            font-size: 130%;
+            margin-top: 2ex;
+
+            }
+            .et2_details_title, .group {
+            margin-top: 3ex;
+            font-size: 150%;
+            }
+
+            /** Cosmetics **/
+            #api-show_replacements_title:first-letter, .title {
+            text-transform: capitalize;
+            }
+        </styles>
+    </template>
+</overlay>
diff --git a/calendar/inc/class.calendar_boupdate.inc.php b/calendar/inc/class.calendar_boupdate.inc.php
index 81f43a6811..b64b0cd269 100644
--- a/calendar/inc/class.calendar_boupdate.inc.php
+++ b/calendar/inc/class.calendar_boupdate.inc.php
@@ -1634,7 +1634,7 @@ class calendar_boupdate extends calendar_bo
 			$memberships = $GLOBALS['egw']->accounts->memberships($uid,true);
 		}
 		$memberships[] = $uid;
-		return array_intersect($memberships, array_keys($event['participants'])) && $this->check_perms(Acl::EDIT,0,$uid);
+		return array_intersect($memberships, array_keys($event['participants'] ?? [])) && $this->check_perms(Acl::EDIT,0,$uid);
 	}
 
 	/**
diff --git a/calendar/inc/class.calendar_hooks.inc.php b/calendar/inc/class.calendar_hooks.inc.php
index d20893b50d..2fc5b5d24d 100644
--- a/calendar/inc/class.calendar_hooks.inc.php
+++ b/calendar/inc/class.calendar_hooks.inc.php
@@ -660,31 +660,8 @@ class calendar_hooks
 		// Merge print
 		if ($GLOBALS['egw_info']['user']['apps']['filemanager'])
 		{
-			$settings['default_document'] = array(
-				'type'   => 'vfs_file',
-				'size'   => 60,
-				'label'  => 'Default document to insert entries',
-				'name'   => 'default_document',
-				'help'   => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.',lang('calendar')).' '.
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.','calendar_title').' '.
-					lang('The following document-types are supported:'). implode(',',Api\Storage\Merge::get_file_extensions()),
-				'run_lang' => false,
-				'xmlrpc' => True,
-				'admin'  => False,
-			);
-			$settings['document_dir'] = array(
-				'type'   => 'vfs_dirs',
-				'size'   => 60,
-				'label'  => 'Directory with documents to insert entries',
-				'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('calendar')).' '.
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.','calendar_title').' '.
-					lang('The following document-types are supported:'). implode(',',Api\Storage\Merge::get_file_extensions()),
-				'run_lang' => false,
-				'xmlrpc' => True,
-				'admin'  => False,
-				'default' => '/templates/calendar',
-			);
+			$merge = new calendar_merge();
+			$settings += $merge->merge_preferences();
 		}
 
 		$settings += array(
diff --git a/calendar/inc/class.calendar_ical.inc.php b/calendar/inc/class.calendar_ical.inc.php
index d5e2f3431a..7ff64226c3 100644
--- a/calendar/inc/class.calendar_ical.inc.php
+++ b/calendar/inc/class.calendar_ical.inc.php
@@ -3082,7 +3082,7 @@ class calendar_ical extends calendar_boupdate
 					// check if json_encoded attribute is to big for our table
 					if (($attributes['params'] || count($attributes['values']) > 1) &&
 						strlen($event['##'.$attributes['name']]) >
-							Api\Db::get_column_attribute('cal_extra_value', 'egw_cal_extra', 'calendar', 'precision'))
+							$GLOBALS['egw']->db->get_column_attribute('cal_extra_value', 'egw_cal_extra', 'calendar', 'precision'))
 					{
 						// store content compressed (Outlook/Exchange HTML garbadge is very good compressable)
 						if (function_exists('gzcompress'))
@@ -3093,7 +3093,7 @@ class calendar_ical extends calendar_boupdate
 						}
 						// if that's not enough --> unset it, as truncating the json gives nothing
 						if (strlen($event['##'.$attributes['name']]) >
-							Api\Db::get_column_attribute('cal_extra_value', 'egw_cal_extra', 'calendar', 'precision'))
+							$GLOBALS['egw']->db->get_column_attribute('cal_extra_value', 'egw_cal_extra', 'calendar', 'precision'))
 						{
 							unset($event['##'.$attributes['name']]);
 						}
diff --git a/calendar/inc/class.calendar_merge.inc.php b/calendar/inc/class.calendar_merge.inc.php
index c404871a74..af6969920d 100644
--- a/calendar/inc/class.calendar_merge.inc.php
+++ b/calendar/inc/class.calendar_merge.inc.php
@@ -25,9 +25,9 @@ class calendar_merge extends Api\Storage\Merge
 	 * @var array
 	 */
 	var $public_functions = array(
-		'download_by_request'	=> true,
-		'show_replacements'		=> true,
-		'merge_entries'		=> true
+		'download_by_request' => true,
+		'show_replacements'   => true,
+		'merge_entries'       => true
 	);
 
 	// Object for getting calendar info
@@ -50,10 +50,10 @@ class calendar_merge extends Api\Storage\Merge
 	 * If you use a range, these extra tags are available
 	 */
 	protected static $range_tags = array(
-		'start'	=> 'Y-m-d',
-		'end'	=> 'Y-m-d',
-		'month'	=> 'F',
-		'year'	=> 'Y'
+		'start' => 'Y-m-d',
+		'end'   => 'Y-m-d',
+		'month' => 'F',
+		'year'  => 'Y'
 	);
 
 	/**
@@ -91,10 +91,12 @@ class calendar_merge extends Api\Storage\Merge
 		{
 			$this->table_plugins[date('l', strtotime("+$i days"))] = 'day_plugin';
 		}
-		for($i = 1; $i <= 31; $i++) {
-			$this->table_plugins['day_'.$i] = 'day'; // Numerically by day number (1-31)
+		for($i = 1; $i <= 31; $i++)
+		{
+			$this->table_plugins['day_' . $i] = 'day'; // Numerically by day number (1-31)
 		}
-		foreach(self::$relative as $day) {
+		foreach(self::$relative as $day)
+		{
 			$this->table_plugins[$day] = 'day'; // Current day
 		}
 		$this->query = is_array($this->bo->cal_prefs['saved_states']) ?
@@ -116,11 +118,11 @@ class calendar_merge extends Api\Storage\Merge
 	 * @param array $ids array with contact id(s)
 	 * @param string &$err error-message on error
 	 * @param string $mimetype mimetype of complete document, eg. text/*, application/vnd.oasis.opendocument.text, application/rtf
-	 * @param array $fix=null regular expression => replacement pairs eg. to fix garbled placeholders
-	 * @param string $charset=null charset to override default set by mimetype or export charset
+	 * @param array $fix =null regular expression => replacement pairs eg. to fix garbled placeholders
+	 * @param string $charset =null charset to override default set by mimetype or export charset
 	 * @return string|boolean merged document or false on error
 	 */
-	function &merge_string($content, $ids, &$err, $mimetype, array $fix=null, $charset=null)
+	function &merge_string($content, $ids, &$err, $mimetype, array $fix = null, $charset = null)
 	{
 		$ids = $this->validate_ids((array)$ids, $content);
 
@@ -131,27 +133,29 @@ class calendar_merge extends Api\Storage\Merge
 	 * Get replacements
 	 *
 	 * @param int|array $id event-id array with id,recur_date, or array with search parameters
-	 * @param string &$content=null content to create some replacements only if they are used
+	 * @param string &$content =null content to create some replacements only if they are used
 	 * @return array|boolean
 	 */
-	protected function get_replacements($id,&$content=null)
+	protected function get_replacements($id, &$content = null)
 	{
 		$prefix = '';
 		// List events ?
 		if(is_array($id) && !$id['id'] && !$id[0]['id'])
 		{
 			$events = $this->bo->search($this->query + $id + array(
-				'offset' => 0,
-				'order' => 'cal_start',
-				'cfs' => strpos($content, '#') !== false ? array_keys(Api\Storage\Customfields::get('calendar')) : null
-			));
-			if(strpos($content,'$$calendar/') !== false || strpos($content, '$$table/day') !== false)
+											'offset' => 0,
+											'order'  => 'cal_start',
+											'cfs'    => strpos($content, '#') !== false ? array_keys(Api\Storage\Customfields::get('calendar')) : null
+										)
+			);
+			if(strpos($content, '$$calendar/') !== false || strpos($content, '$$table/day') !== false)
 			{
-				array_unshift($events,false); unset($events[0]);	// renumber the array to start with key 1, instead of 0
+				array_unshift($events, false);
+				unset($events[0]);    // renumber the array to start with key 1, instead of 0
 				$prefix = 'calendar/%d';
 			}
 		}
-		elseif (is_array($id) && $id[0]['id'])
+		elseif(is_array($id) && $id[0]['id'])
 		{
 			// Passed an array of events, to be handled like a date range
 			$events = $id;
@@ -162,18 +166,18 @@ class calendar_merge extends Api\Storage\Merge
 			$events = array($id);
 		}
 		// as this function allows to pass query- parameters, we need to check the result of the query against export_limit restrictions
-		if (Api\Storage\Merge::hasExportLimit($this->export_limit) && !Api\Storage\Merge::is_export_limit_excepted() && count($events) > (int)$this->export_limit)
+		if(Api\Storage\Merge::hasExportLimit($this->export_limit) && !Api\Storage\Merge::is_export_limit_excepted() && count($events) > (int)$this->export_limit)
 		{
-			$err = lang('No rights to export more than %1 entries!',(int)$this->export_limit);
+			$err = lang('No rights to export more than %1 entries!', (int)$this->export_limit);
 			throw new Api\Exception\WrongUserinput($err);
 		}
 		$replacements = array();
 		$n = 0;
 		foreach($events as $event)
 		{
-			$event_id = $event['id'] . ($event['recur_date'] ? ':'.$event['recur_date'] : '');
+			$event_id = $event['id'] . ($event['recur_date'] ? ':' . $event['recur_date'] : '');
 			if($this->ids && !in_array($event_id, $this->ids)) continue;
-			$values = $this->calendar_replacements($event,sprintf($prefix,++$n), $content);
+			$values = $this->calendar_replacements($event, sprintf($prefix, ++$n), $content);
 			if(is_array($id) && $id['start'])
 			{
 				foreach(self::$range_tags as $key => $format)
@@ -192,21 +196,24 @@ class calendar_merge extends Api\Storage\Merge
 	 * Return replacements for the calendar
 	 *
 	 * @param int|array $id event-id or array with id/recur_date, or array with event info
-	 * @param boolean $last_event_too=false also include information about the last event
+	 * @param boolean $last_event_too =false also include information about the last event
 	 * @return array
 	 */
-	public function calendar_replacements($id,$prefix = '', &$content = '')
+	public function calendar_replacements($id, $prefix = '', &$content = '')
 	{
 		$replacements = array();
-		if(!is_array($id) || !$id['start']) {
+		if(!is_array($id) || !$id['start'])
+		{
 			if(is_string($id) && strpos($id, ':'))
 			{
 				$_id = $id;
 				$id = array();
-				list($id['id'], $id['recur_date']) = explode(':',$_id);
+				list($id['id'], $id['recur_date']) = explode(':', $_id);
 			}
 			$event = $this->bo->read(is_array($id) ? $id['id'] : $id, is_array($id) ? $id['recur_date'] : null);
-		} else {
+		}
+		else
+		{
 			$event = $id;
 		}
 
@@ -219,17 +226,17 @@ class calendar_merge extends Api\Storage\Merge
 		$array = $record->get_record_array();
 		foreach($array as $key => $value)
 		{
-			$replacements['$$'.($prefix?$prefix.'/':'').$key.'$$'] = $value;
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . $key . '$$'] = $value;
 		}
 
-		$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_id'. '$$'] = $event['id'];
+		$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_id' . '$$'] = $event['id'];
 		foreach($this->bo->event2array($event) as $name => $data)
 		{
 			if (substr($name,-4) == 'date') $name = substr($name,0,-4);
-			$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_'.$name . '$$'] = is_array($data['data']) ? implode(', ',$data['data']) : $data['data'];
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_' . $name . '$$'] = is_array($data['data']) ? implode(', ', $data['data']) : $data['data'];
 		}
 		// Add seperate lists of participants by type
-		if(strpos($content, 'calendar_participants/')!== false)
+		if(strpos($content, 'calendar_participants/') !== false)
 		{
 			$types = array();
 			foreach($this->bo->resources as $resource)
@@ -247,7 +254,7 @@ class calendar_merge extends Api\Storage\Merge
 			}
 			foreach($types as $t_id => $type)
 			{
-				$replacements['$$'.($prefix ? $prefix . '/' : '') . "calendar_participants/{$t_id}$$"] = implode(', ',$type);
+				$replacements['$$' . ($prefix ? $prefix . '/' : '') . "calendar_participants/{$t_id}$$"] = implode(', ', $type);
 			}
 		}
 		// Participant email list (not declined)
@@ -256,32 +263,35 @@ class calendar_merge extends Api\Storage\Merge
 		// Add participant summary
 		$this->participant_summary($replacements, $record, $prefix, $content);
 
-		if(!$replacements['$$'.($prefix ? $prefix . '/' : '') . 'calendar_recur_type$$'])
+		if(!$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_recur_type$$'])
 		{
 			// Need to set it to '' if not set or previous record may be used
-			$replacements['$$'.($prefix ? $prefix . '/' : '') . 'calendar_recur_type$$'] = '';
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_recur_type$$'] = '';
 		}
-		foreach(array('start','end') as $what)
+		foreach(array('start', 'end') as $what)
 		{
 			foreach(array(
-				'date' => $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'],
-				'day'  => 'l',
-				'time' => (date('Ymd',$event['start']) != date('Ymd',$event['end']) ? $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'].' ' : '') . ($GLOBALS['egw_info']['user']['preferences']['common']['timeformat'] == 12 ? 'h:i a' : 'H:i'),
-			) as $name => $format)
+						'date' => $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'],
+						'day'  => 'l',
+						'time' => (date('Ymd', $event['start']) != date('Ymd', $event['end']) ? $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'] . ' ' : '') . ($GLOBALS['egw_info']['user']['preferences']['common']['timeformat'] == 12 ? 'h:i a' : 'H:i'),
+					) as $name => $format)
 			{
-				$value = Api\DateTime::to($event[$what],$format);
-				if ($format == 'l') $value = lang($value);
-				$replacements['$$' .($prefix ? $prefix.'/':'').'calendar_'.$what.$name.'$$'] = $value;
+				$value = Api\DateTime::to($event[$what], $format);
+				if($format == 'l')
+				{
+					$value = lang($value);
+				}
+				$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_' . $what . $name . '$$'] = $value;
 			}
 		}
-		$duration = ($event['end'] - $event['start'])/60;
-		$replacements['$$'.($prefix?$prefix.'/':'').'calendar_duration$$'] = floor($duration/60).lang('h').($duration%60 ? $duration%60 : '');
+		$duration = ($event['end'] - $event['start']) / 60;
+		$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_duration$$'] = floor($duration / 60) . lang('h') . ($duration % 60 ? $duration % 60 : '');
 
 		// Add in contact stuff for owner
-		if (strpos($content,'$$calendar_owner/') !== null && ($user = $GLOBALS['egw']->accounts->id2name($event['owner'],'person_id')))
+		if(strpos($content, '$$calendar_owner/') !== null && ($user = $GLOBALS['egw']->accounts->id2name($event['owner'], 'person_id')))
 		{
-			$replacements += $this->contact_replacements($user,($prefix ? $prefix.'/':'').'calendar_owner');
-			$replacements['$$'.($prefix?$prefix.'/':'').'calendar_owner/primary_group$$'] = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw']->accounts->id2name($event['owner'],'account_primary_group'));
+			$replacements += $this->contact_replacements($user, ($prefix ? $prefix . '/' : '') . 'calendar_owner');
+			$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'calendar_owner/primary_group$$'] = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw']->accounts->id2name($event['owner'], 'account_primary_group'));
 		}
 
 		if($content && strpos($content, '$$#') !== FALSE)
@@ -307,7 +317,7 @@ class calendar_merge extends Api\Storage\Merge
 	public function participant_emails(&$replacements, &$record, $prefix, &$content)
 	{
 		// Early exit if the placeholder is not used
-		if(strpos($content, '$$'.($prefix?$prefix.'/':'').'participant_emails$$') === FALSE)
+		if(strpos($content, '$$' . ($prefix ? $prefix . '/' : '') . 'participant_emails$$') === FALSE)
 		{
 			return false;
 		}
@@ -320,7 +330,7 @@ class calendar_merge extends Api\Storage\Merge
 		foreach($event['participants'] as $uid => $status)
 		{
 			// Skip rejected
-			if (in_array(substr($status, 0, 1), array('R')))
+			if(in_array(substr($status, 0, 1), array('R')))
 			{
 				continue;
 			}
@@ -331,7 +341,7 @@ class calendar_merge extends Api\Storage\Merge
 				$emails[] = $info['email'];
 			}
 		}
-		$replacements['$$'.($prefix?$prefix.'/':'').'participant_emails$$'] = implode(', ', $emails);
+		$replacements['$$' . ($prefix ? $prefix . '/' : '') . 'participant_emails$$'] = implode(', ', $emails);
 	}
 
 	/**
@@ -348,12 +358,12 @@ class calendar_merge extends Api\Storage\Merge
 	public function participant_summary(&$replacements, &$record, $prefix, &$content)
 	{
 		// Early exit if the placeholder is not used
-		if(strpos($content, '$$'.($prefix?$prefix.'/':'').'participant_summary$$') === FALSE)
+		if(strpos($content, '$$' . ($prefix ? $prefix . '/' : '') . 'participant_summary$$') === FALSE)
 		{
 			return false;
 		}
 
-		$placeholder = '$$'.($prefix?$prefix.'/':'').'participant_summary$$';
+		$placeholder = '$$' . ($prefix ? $prefix . '/' : '') . 'participant_summary$$';
 
 		// No summary for 1 participant
 		if(count($record->participants) < 2)
@@ -362,13 +372,14 @@ class calendar_merge extends Api\Storage\Merge
 		}
 
 		$participant_status = array('A' => 0, 'R' => 0, 'T' => 0, 'U' => 0, 'D' => 0);
-		$status_label = array('A' => 'accepted', 'R' => 'rejected', 'T' => 'tentative', 'U' => 'unknown', 'D' => 'delegated');
-		$participant_summary = count($record->participants) . ' ' . lang('Participants').': ';
+		$status_label = array('A' => 'accepted', 'R' => 'rejected', 'T' => 'tentative', 'U' => 'unknown',
+							  'D' => 'delegated');
+		$participant_summary = count($record->participants) . ' ' . lang('Participants') . ': ';
 		$status_totals = [];
 
 		foreach($record->participants as $uid => $status)
 		{
-			$participant_status[substr($status,0,1)]++;
+			$participant_status[substr($status, 0, 1)]++;
 		}
 		foreach($participant_status as $status => $count)
 		{
@@ -377,40 +388,54 @@ class calendar_merge extends Api\Storage\Merge
 				$status_totals[] = $count . ' ' . lang($status_label[$status]);
 			}
 		}
-		$summary = $participant_summary . join(', ',$status_totals);
+		$summary = $participant_summary . join(', ', $status_totals);
 		$replacements[$placeholder] = $summary;
 	}
 
 	/**
-	* Table plugin for event
-	* Lists events for a certain day of the week.  Only works for one week at a time, so for multiple weeks,
-	* use multiple date ranges.
-	*
-	* Use:
-	* $$table/Monday$$ $$starttime$$ $$title$$ $$endtable$$
-	* The day of the week may be language specific (date('l')).
-	*
-	* @param string $plugin (Monday-Sunday)
-	* @param int/array date or date range
-	* @param int $n Row number
-	* @param string $repeat Text being repeated for each entry
-	* @return array
-	*/
-	public function day_plugin($plugin,$date,$n,$repeat)
+	 * Table plugin for event
+	 * Lists events for a certain day of the week.  Only works for one week at a time, so for multiple weeks,
+	 * use multiple date ranges.
+	 *
+	 * Use:
+	 * $$table/Monday$$ $$starttime$$ $$title$$ $$endtable$$
+	 * The day of the week may be language specific (date('l')).
+	 *
+	 * @param string $plugin (Monday-Sunday)
+	 * @param int/array date or date range
+	 * @param int $n Row number
+	 * @param string $repeat Text being repeated for each entry
+	 * @return array
+	 */
+	public function day_plugin($plugin, $date, $n, $repeat)
 	{
 		static $days = null;
-		if(is_array($date) && !$date['start']) {
+		if(is_array($date) && !$date['start'])
+		{
 			// List of IDs
-			if($date[0]['start']) {
+			if($date[0]['start'])
+			{
 				$id = array('start' => PHP_INT_MAX, 'end' => 0);
-				foreach($date as $event) {
-					if($event['start'] && $event['start'] < $id['start']) $id['start'] = $event['start'];
-					if($event['end'] && $event['end'] > $id['end']) $id['end'] = $event['end'];
+				foreach($date as $event)
+				{
+					if($event['start'] && $event['start'] < $id['start'])
+					{
+						$id['start'] = $event['start'];
+					}
+					if($event['end'] && $event['end'] > $id['end'])
+					{
+						$id['end'] = $event['end'];
+					}
 				}
 				$date = $id;
-			} else {
+			}
+			else
+			{
 				$event = $this->bo->read(is_array($date) ? $date['id'] : $date, is_array($date) ? $date['recur_date'] : null);
-				if(date('l',$event['start']) != $plugin) return array();
+				if(date('l', $event['start']) != $plugin)
+				{
+					return array();
+				}
 				$date = $event['start'];
 			}
 		}
@@ -419,14 +444,15 @@ class calendar_merge extends Api\Storage\Merge
 		if($days[date('Ymd',$_date)][$plugin]) return $days[date('Ymd',$_date)][$plugin][$n];
 
 		$events = $this->bo->search($this->query + array(
-			'start' => $date['end'] ? $date['start'] : mktime(0,0,0,date('m',$_date),date('d',$_date),date('Y',$_date)),
-			'end' => $date['end'] ? $date['end'] : mktime(23,59,59,date('m',$_date),date('d',$_date),date('Y',$_date)),
-			'offset' => 0,
-			'num_rows' => 20,
-			'order' => 'cal_start',
-			'daywise' => true,
-			'cfs' => array(),	// read all custom-fields
-		));
+										'start'    => $date['end'] ? $date['start'] : mktime(0, 0, 0, date('m', $_date), date('d', $_date), date('Y', $_date)),
+										'end'      => $date['end'] ? $date['end'] : mktime(23, 59, 59, date('m', $_date), date('d', $_date), date('Y', $_date)),
+										'offset'   => 0,
+										'num_rows' => 20,
+										'order'    => 'cal_start',
+										'daywise'  => true,
+										'cfs'      => array(),    // read all custom-fields
+									)
+		);
 
 		if (true) $days = array();
 		$replacements = array();
@@ -435,71 +461,82 @@ class calendar_merge extends Api\Storage\Merge
 		{
 			foreach($list as $event)
 			{
-				$event_id = $event['id'] . ($event['recur_date'] ? ':'.$event['recur_date'] : '');
-				if($this->ids && !in_array($event_id, $this->ids)) continue;
+				$event_id = $event['id'] . ($event['recur_date'] ? ':' . $event['recur_date'] : '');
+				if($this->ids && !in_array($event_id, $this->ids))
+				{
+					continue;
+				}
 				$start = Api\DateTime::to($event['start'], 'array');
 				$end = Api\DateTime::to($event['end'], 'array');
 				$replacements = $this->calendar_replacements($event);
-				if($start['year'] == $end['year'] && $start['month'] == $end['month'] && $start['day'] == $end['day']) {
-					$dow = date('l',$event['start']);
-				} else {
+				if($start['year'] == $end['year'] && $start['month'] == $end['month'] && $start['day'] == $end['day'])
+				{
+					$dow = date('l', $event['start']);
+				}
+				else
+				{
 					$dow = date('l', strtotime($day));
 					// Fancy date+time formatting for multi-day events
-					$replacements['$$calendar_starttime$$'] = date($time_format, $day == date('Ymd', $event['start']) ? $event['start'] : mktime(0,0,0,0,0,1));
-					$replacements['$$calendar_endtime$$'] = date($time_format, $day == date('Ymd', $event['end']) ? $event['end'] : mktime(23,59,59,0,0,0));
+					$replacements['$$calendar_starttime$$'] = date($time_format, $day == date('Ymd', $event['start']) ? $event['start'] : mktime(0, 0, 0, 0, 0, 1));
+					$replacements['$$calendar_endtime$$'] = date($time_format, $day == date('Ymd', $event['end']) ? $event['end'] : mktime(23, 59, 59, 0, 0, 0));
 				}
 
-				$days[date('Ymd',$_date)][$dow][] = $replacements;
+				$days[date('Ymd', $_date)][$dow][] = $replacements;
 			}
-			if(strpos($repeat, 'day/date') !== false || strpos($repeat, 'day/name') !== false) {
+			if(strpos($repeat, 'day/date') !== false || strpos($repeat, 'day/name') !== false)
+			{
 				$date_marker = array(
 					'$$day/date$$' => date($GLOBALS['egw_info']['user']['preferences']['common']['dateformat'], strtotime($day)),
 					'$$day/name$$' => lang(date('l', strtotime($day)))
 				);
-				if(!is_array($days[date('Ymd',$_date)][date('l',strtotime($day))])) {
+				if(!is_array($days[date('Ymd', $_date)][date('l', strtotime($day))]))
+				{
 					$blank = $this->calendar_replacements(array());
 					foreach($blank as &$value)
 					{
 						$value = '';
 					}
-					$days[date('Ymd',$_date)][date('l',strtotime($day))][] = $blank;
+					$days[date('Ymd', $_date)][date('l', strtotime($day))][] = $blank;
 				}
-				$days[date('Ymd',$_date)][date('l',strtotime($day))][0] += $date_marker;
+				$days[date('Ymd', $_date)][date('l', strtotime($day))][0] += $date_marker;
 			}
 			// Add in birthdays
 			if(strpos($repeat, 'day/birthdays') !== false)
 			{
-				$days[date('Ymd', $_date)][date('l',strtotime($day))][0]['$$day/birthdays$$'] = $this->get_birthdays($day);
+				$days[date('Ymd', $_date)][date('l', strtotime($day))][0]['$$day/birthdays$$'] = $this->get_birthdays($day);
 			}
 		}
-		return $days[date('Ymd',$_date)][$plugin][0];
+		return $days[date('Ymd', $_date)][$plugin][0];
 	}
 
 	/**
-	* Table plugin for a certain date
-	*
-	* Can be either a particular date (2011-02-15) or a day of the month (15)
-	*
-	* @param string $plugin
-	* @param int $id ID for this record
-	* @param int $n Repeated row number
-	* @param string $repeat Text being repeated for each entry
-	* @return array
-	*/
-	public function day($plugin,$id,$n,$repeat)
+	 * Table plugin for a certain date
+	 *
+	 * Can be either a particular date (2011-02-15) or a day of the month (15)
+	 *
+	 * @param string $plugin
+	 * @param int $id ID for this record
+	 * @param int $n Repeated row number
+	 * @param string $repeat Text being repeated for each entry
+	 * @return array
+	 */
+	public function day($plugin, $id, $n, $repeat)
 	{
 		static $days = null;
 
 		// Figure out which day
-		list($type, $which) = explode('_',$plugin);
+		list($type, $which) = explode('_', $plugin);
 		if($type == 'day' && $which)
 		{
 			$arr = $this->bo->date2array($id['start']);
 			$arr['day'] = $which;
 			$date = $this->bo->date2ts($arr);
-			if(is_array($id) && $id['start'] && ($date < $id['start'] || $date > $id['end'])) return array();
+			if(is_array($id) && $id['start'] && ($date < $id['start'] || $date > $id['end']))
+			{
+				return array();
+			}
 		}
-		elseif ($plugin == 'selected')
+		elseif($plugin == 'selected')
 		{
 			$date = $id['start'];
 		}
@@ -507,26 +544,34 @@ class calendar_merge extends Api\Storage\Merge
 		{
 			$date = strtotime($plugin);
 		}
-		if($type == 'day' && is_array($id) && !$id['start']) {
+		if($type == 'day' && is_array($id) && !$id['start'])
+		{
 			$event = $this->bo->read(is_array($id) ? $id['id'] : $id, is_array($id) ? $id['recur_date'] : null);
-			if($which && date('d',$event['start']) != $which) return array();
-			if(date('Ymd',$date) != date('Ymd', $event['start'])) return array();
+			if($which && date('d', $event['start']) != $which)
+			{
+				return array();
+			}
+			if(date('Ymd', $date) != date('Ymd', $event['start']))
+			{
+				return array();
+			}
 			return $n == 0 ? $this->calendar_replacements($event) : array();
 		}
 
 		// Use start for cache, in case of multiple months
 		$_date = $id['start'] ? $id['start'] : $date;
-		if($days[date('Ymd',$_date)][$plugin]) return $days[date('Ymd',$_date)][$plugin][$n];
+		if($days[date('Ymd', $_date)][$plugin]) return $days[date('Ymd', $_date)][$plugin][$n];
 
 		$events = $this->bo->search($this->query + array(
-			'start' => $date,
-			'end' => mktime(23,59,59,date('m',$date),date('d',$date),date('Y',$date)),
-			'offset' => 0,
-			'num_rows' => 20,
-			'order' => 'cal_start',
-			'daywise' => true,
-			'cfs' => array(),	// read all custom-fields
-		));
+										'start'    => $date,
+										'end'      => mktime(23, 59, 59, date('m', $date), date('d', $date), date('Y', $date)),
+										'offset'   => 0,
+										'num_rows' => 20,
+										'order'    => 'cal_start',
+										'daywise'  => true,
+										'cfs'      => array(),    // read all custom-fields
+									)
+		);
 
 		$replacements = array();
 		if (true) $days = array();
@@ -535,126 +580,150 @@ class calendar_merge extends Api\Storage\Merge
 		{
 			foreach($list as $event)
 			{
-				$event_id = $event['id'] . ($event['recur_date'] ? ':'.$event['recur_date'] : '');
-				if($this->ids && !in_array($event_id, $this->ids)) continue;
+				$event_id = $event['id'] . ($event['recur_date'] ? ':' . $event['recur_date'] : '');
+				if($this->ids && !in_array($event_id, $this->ids))
+				{
+					continue;
+				}
 				$start = Api\DateTime::to($event['start'], 'array');
 				$end = Api\DateTime::to($event['end'], 'array');
 				$replacements = $this->calendar_replacements($event);
-				if($start['year'] == $end['year'] && $start['month'] == $end['month'] && $start['day'] == $end['day']) {
+				if($start['year'] == $end['year'] && $start['month'] == $end['month'] && $start['day'] == $end['day'])
+				{
 					//$dow = date('l',$event['start']);
-				} else {
-					// Fancy date+time formatting for multi-day events
-					$replacements['$$calendar_starttime$$'] = date($time_format, $day == date('Ymd', $event['start']) ? $event['start'] : mktime(0,0,0,0,0,1));
-					$replacements['$$calendar_endtime$$'] = date($time_format, $day == date('Ymd', $event['end']) ? $event['end'] : mktime(23,59,59,0,0,0));
 				}
-				$days[date('Ymd',$_date)][$plugin][] = $replacements;
+				else
+				{
+					// Fancy date+time formatting for multi-day events
+					$replacements['$$calendar_starttime$$'] = date($time_format, $day == date('Ymd', $event['start']) ? $event['start'] : mktime(0, 0, 0, 0, 0, 1));
+					$replacements['$$calendar_endtime$$'] = date($time_format, $day == date('Ymd', $event['end']) ? $event['end'] : mktime(23, 59, 59, 0, 0, 0));
+				}
+				$days[date('Ymd', $_date)][$plugin][] = $replacements;
 			}
-			if(strpos($repeat, 'day/date') !== false || strpos($repeat, 'day/name') !== false) {
+			if(strpos($repeat, 'day/date') !== false || strpos($repeat, 'day/name') !== false)
+			{
 				$date_marker = array(
 					'$$day/date$$' => date($GLOBALS['egw_info']['user']['preferences']['common']['dateformat'], strtotime($day)),
 					'$$day/name$$' => lang(date('l', strtotime($day)))
 				);
-				if(!is_array($days[date('Ymd',$_date)][$plugin])) {
+				if(!is_array($days[date('Ymd', $_date)][$plugin]))
+				{
 					$blank = $this->calendar_replacements(array());
 					foreach($blank as &$value)
 					{
 						$value = '';
 					}
-					$days[date('Ymd',$_date)][$plugin][] = $blank;
+					$days[date('Ymd', $_date)][$plugin][] = $blank;
 				}
-				$days[date('Ymd',$_date)][$plugin][0] += $date_marker;
+				$days[date('Ymd', $_date)][$plugin][0] += $date_marker;
 			}
 			// Add in birthdays
 			if(strpos($repeat, 'day/birthdays') !== false)
 			{
-				$days[date('Ymd', $_date)][date('l',strtotime($day))][0]['$$day/birthdays$$'] = $this->get_birthdays($day);
+				$days[date('Ymd', $_date)][date('l', strtotime($day))][0]['$$day/birthdays$$'] = $this->get_birthdays($day);
 			}
 		}
-		return $days[date('Ymd',$_date)][$plugin][0];
+		return $days[date('Ymd', $_date)][$plugin][0];
 	}
 
 	/**
-	* Table plugin for participants
-	*
-	* Copied from eventmgr resources
-	*
-	* @param string $plugin
-	* @param int $id
-	* @param int $n
-	* @return array
-	*/
-	public function participant($plugin,$id,$n)
+	 * Table plugin for participants
+	 *
+	 * Copied from eventmgr resources
+	 *
+	 * @param string $plugin
+	 * @param int $id
+	 * @param int $n
+	 * @return array
+	 */
+	public function participant($plugin, $id, $n)
 	{
-		unset($plugin);	// not used, but required by function signature
+		unset($plugin);    // not used, but required by function signature
 
-		if(!is_array($id) || !$id['start']) {
+		if(!is_array($id) || !$id['start'])
+		{
 			$event = $this->bo->read(is_array($id) ? $id['id'] : $id, is_array($id) ? $id['recur_date'] : null);
-		} else {
+		}
+		else
+		{
 			$event = $id;
 		}
 
-		if(!is_array($event['participants']) || $n >= count($event['participants'])) return array();
+		if(!is_array($event['participants']) || $n >= count($event['participants']))
+		{
+			return array();
+		}
 
 		$participant = null;
 		$status = null;
 		$i = -1;
-		foreach($event['participants'] as $participant => $status) {
-			if(++$i == $n) break;
+		foreach($event['participants'] as $participant => $status)
+		{
+			if(++$i == $n)
+			{
+				break;
+			}
 		}
 
-		if(!$participant) return array();
+		if(!$participant)
+		{
+			return array();
+		}
 
 		// Add some common information
 		$quantity = $role = null;
-		calendar_so::split_status($status,$quantity,$role);
-		if ($role != 'REQ-PARTICIPANT')
+		calendar_so::split_status($status, $quantity, $role);
+		if($role != 'REQ-PARTICIPANT')
 		{
-			if (isset($this->bo->roles[$role]))
+			if(isset($this->bo->roles[$role]))
 			{
 				$role = lang($this->bo->roles[$role]);
 			}
 			// allow to use cats as roles (beside regular iCal ones)
-			elseif (substr($role,0,6) == 'X-CAT-' && ($cat_id = (int)substr($role,6)) > 0)
+			elseif(substr($role, 0, 6) == 'X-CAT-' && ($cat_id = (int)substr($role, 6)) > 0)
 			{
 				$role = $GLOBALS['egw']->categories->id2name($cat_id);
 			}
 			else
 			{
-				$role = lang(str_replace('X-','',$role));
+				$role = lang(str_replace('X-', '', $role));
 			}
 		}
 		$info = array(
-			'name'		=> $this->bo->participant_name($participant),
-			'status'	=> lang($this->bo->verbose_status[$status]),
-			'quantity'	=> $quantity,
-			'role'		=> $role
+			'name'     => $this->bo->participant_name($participant),
+			'status'   => lang($this->bo->verbose_status[$status]),
+			'quantity' => $quantity,
+			'role'     => $role
 		);
 
-		switch ($participant[0])
+		switch($participant[0])
 		{
 			case 'c':
-				$replacements = $this->contact_replacements(substr($participant,1),'');
+				$replacements = $this->contact_replacements(substr($participant, 1), '');
 				break;
 			case 'r':
-				if (is_null(self::$resources)) self::$resources = new resources_bo();
-				if (($resource = self::$resources->read(substr($participant,1))))
+				if(is_null(self::$resources))
+				{
+					self::$resources = new resources_bo();
+				}
+				if(($resource = self::$resources->read(substr($participant, 1))))
 				{
 					foreach($resource as $name => $value)
 					{
-					    $replacements['$$'.$name.'$$'] = $value;
+						$replacements['$$' . $name . '$$'] = $value;
 					}
 				}
 				break;
 			default:
-				if (is_numeric($participant) && ($contact = $GLOBALS['egw']->accounts->id2name($participant,'person_id')))
+				if(is_numeric($participant) && ($contact = $GLOBALS['egw']->accounts->id2name($participant, 'person_id')))
 				{
-					$replacements = $this->contact_replacements($contact,'');
+					$replacements = $this->contact_replacements($contact, '');
 				}
 				break;
 		}
 		foreach($info as $name => $value)
 		{
-			$replacements['$$'.$name.'$$'] = $value;
+			$replacements['$$' . $name . '$$'] = $value;
 		}
 		return $replacements;
 	}
@@ -666,7 +735,7 @@ class calendar_merge extends Api\Storage\Merge
 	protected function get_birthdays($day)
 	{
 		$contacts = new Api\Contacts();
-		$birthdays = Array();
+		$birthdays = array();
 		foreach($contacts->get_addressbooks() as $owner => $name)
 		{
 			$birthdays += $contacts->read_birthdays($owner, substr($day, 0, 4));
@@ -682,30 +751,30 @@ class calendar_merge extends Api\Storage\Merge
 	 * first ID
 	 *
 	 * @param Array[]|String[] $ids List of IDs, which can be a list of individual
-	 *	event IDs, entire events, a date range (start & end) or a list of date ranges.
+	 *    event IDs, entire events, a date range (start & end) or a list of date ranges.
 	 * @param String $content Template content, used to determine what style of
-	 *	ID is needed.
+	 *    ID is needed.
 	 */
-	protected function validate_ids(Array $ids, $content)
+	protected function validate_ids(array $ids, $content)
 	{
 		$validated_ids = array();
 		if((strpos($content, '$$range') !== false || strpos($content, '{{range') !== false) && is_array($ids))
 		{
 			// Merging into a template that uses range - need ranges, got events
-			if (is_array($ids) && ($ids[0]['id'] || is_string($ids[0])))
+			if(is_array($ids) && (is_array($ids[0]) && $ids[0]['id'] || is_string($ids[0])))
 			{
 				// Passed an array of events, to be handled like a date range
 				$events = $ids;
 				$validated_ids = (array)$this->events_to_range($ids);
 			}
-			else if (is_array($ids) && $ids[0]['start'])
+			else if(is_array($ids) && $ids[0]['start'])
 			{
 				// Got a list of ranges
 				$validated_ids = $ids;
 			}
 		}
 		// Handle merging a range of events into a document with pagerepeat instead of range
-		else if ((strpos($content, '$$pagerepeat') !== false || strpos($content, '{{pagerepeat') !== false)
+		else if((strpos($content, '$$pagerepeat') !== false || strpos($content, '{{pagerepeat') !== false)
 			&& ((strpos($content, '$$range') === false && strpos($content, '{{range') === false)))
 		{
 			if(is_array($ids) && $ids[0] && !$ids[0]['id'])
@@ -714,12 +783,14 @@ class calendar_merge extends Api\Storage\Merge
 				{
 					// Passed a range, needs to be expanded into list of events
 					$events = $this->bo->search($this->query + $range + array(
-						'offset' => 0,
-						'enum_recuring' => true,
-						'order' => 'cal_start',
-						'cfs' => strpos($content, '#') !== false ? array_keys(Api\Storage\Customfields::get('calendar')) : null
-					));
-					foreach($events as $event) {
+													'offset'        => 0,
+													'enum_recuring' => true,
+													'order'         => 'cal_start',
+													'cfs'           => strpos($content, '#') !== false ? array_keys(Api\Storage\Customfields::get('calendar')) : null
+												)
+					);
+					foreach($events as $event)
+					{
 						$validated_ids[] = $event;
 					}
 				}
@@ -749,15 +820,22 @@ class calendar_merge extends Api\Storage\Merge
 	{
 		$limits = array('start' => PHP_INT_MAX, 'end' => 0);
 		$this->ids = array();
-		foreach($ids as $event) {
+		foreach($ids as $event)
+		{
 			$event = $this->normalize_event_id($event);
 
-			if($event['start'] && Api\DateTime::to($event['start'],'ts') < $limits['start']) $limits['start'] = Api\DateTime::to($event['start'],'ts');
-			if($event['end'] && Api\DateTime::to($event['end'],'ts') > $limits['end']) $limits['end'] = Api\DateTime::to($event['end'],'ts');
+			if($event['start'] && Api\DateTime::to($event['start'], 'ts') < $limits['start'])
+			{
+				$limits['start'] = Api\DateTime::to($event['start'], 'ts');
+			}
+			if($event['end'] && Api\DateTime::to($event['end'], 'ts') > $limits['end'])
+			{
+				$limits['end'] = Api\DateTime::to($event['end'], 'ts');
+			}
 			// Keep ids for future use
 			if($event['id'])
 			{
-				$this->ids[] = $event['id'] . ($event['recur_date'] ? ':'.$event['recur_date'] : '');
+				$this->ids[] = $event['id'] . ($event['recur_date'] ? ':' . $event['recur_date'] : '');
 			}
 		}
 		// Check a start was found
@@ -776,7 +854,7 @@ class calendar_merge extends Api\Storage\Merge
 		$limits['end'] = new Api\DateTime($limits['end']);
 
 		// Align with user's week
-		$limits['start']->setTime(0,0);
+		$limits['start']->setTime(0, 0);
 		$limits['start']->setWeekstart();
 
 		// Ranges should be at most a week, since that's what our templates expect
@@ -788,9 +866,10 @@ class calendar_merge extends Api\Storage\Merge
 			$rrule->next_no_exception();
 			$validated_ids[] = array(
 				'start' => Api\DateTime::to($current, 'ts'),
-				'end' =>  Api\DateTime::to($rrule->current(), 'ts') - 1
+				'end'   => Api\DateTime::to($rrule->current(), 'ts') - 1
 			);
-		} while ($rrule->valid());
+		}
+		while($rrule->valid());
 
 		return $validated_ids;
 	}
@@ -805,19 +884,22 @@ class calendar_merge extends Api\Storage\Merge
 	 * @param String|Array $id Some record identifier, in either string or array form
 	 *
 	 * @param Array If an id for a single event is passed in, an array with id & recur_date,
-	 *	otherwise a range with start & end.
+	 *    otherwise a range with start & end.
 	 */
 	protected function normalize_event_id($id)
 	{
-		if(is_string($id) || is_array($id) && $id['id'] && !$id['start']) {
+		if(is_string($id) || is_array($id) && $id['id'] && !$id['start'])
+		{
 			if(strpos($id, ':'))
 			{
 				$_id = $id;
 				$id = array();
-				list($id['id'], $id['recur_date']) = explode(':',$_id);
+				list($id['id'], $id['recur_date']) = explode(':', $_id);
 			}
 			$event = $this->bo->read(is_array($id) ? $id['id'] : $id, is_array($id) ? $id['recur_date'] : null);
-		} else {
+		}
+		else
+		{
 			$event = $id;
 		}
 
@@ -831,116 +913,226 @@ class calendar_merge extends Api\Storage\Merge
 	public function show_replacements()
 	{
 		Api\Translation::add_app('calendar');
-		$GLOBALS['egw_info']['flags']['app_header'] = lang('calendar').' - '.lang('Replacements for inserting events into documents');
+		$GLOBALS['egw_info']['flags']['app_header'] = lang('calendar') . ' - ' . lang('Replacements for inserting events into documents');
 		$GLOBALS['egw_info']['flags']['nonavbar'] = true;
 		echo $GLOBALS['egw']->framework->header();
 
 		echo "<table width='90%' align='center'>\n";
-		echo '<tr><td colspan="4"><h3>'.lang('Calendar fields:')."</h3></td></tr>";
+		echo '<tr><td colspan="4"><h3>' . lang('Calendar fields:') . "</h3></td></tr>";
 
 		$n = 0;
 		foreach(array(
-			'calendar_id' => lang('Calendar ID'),
-			'calendar_title' => lang('Title'),
-			'calendar_description' => lang('Description'),
-			'calendar_participants' => lang('Participants'),
-			'calendar_location' => lang('Location'),
-			'calendar_start'    => lang('Start').': '.lang('Date').'+'.lang('Time'),
-			'calendar_startday' => lang('Start').': '.lang('Weekday'),
-			'calendar_startdate'=> lang('Start').': '.lang('Date'),
-			'calendar_starttime'=> lang('Start').': '.lang('Time'),
-			'calendar_end'      => lang('End').': '.lang('Date').'+'.lang('Time'),
-			'calendar_endday'   => lang('End').': '.lang('Weekday'),
-			'calendar_enddate'  => lang('End').': '.lang('Date'),
-			'calendar_endtime'  => lang('End').': '.lang('Time'),
-			'calendar_duration' => lang('Duration'),
-			'calendar_category' => lang('Category'),
-			'calendar_priority' => lang('Priority'),
-			'calendar_updated'  => lang('Updated'),
-			'calendar_recur_type' => lang('Repetition'),
-			'calendar_access'   => lang('Access').': '.lang('public').', '.lang('private'),
-			'calendar_owner'    => lang('Owner'),
-		) as $name => $label)
+					'calendar_id'           => lang('Calendar ID'),
+					'calendar_title'        => lang('Title'),
+					'calendar_description'  => lang('Description'),
+					'calendar_participants' => lang('Participants'),
+					'calendar_location'     => lang('Location'),
+					'calendar_start'        => lang('Start') . ': ' . lang('Date') . '+' . lang('Time'),
+					'calendar_startday'     => lang('Start') . ': ' . lang('Weekday'),
+					'calendar_startdate'    => lang('Start') . ': ' . lang('Date'),
+					'calendar_starttime'    => lang('Start') . ': ' . lang('Time'),
+					'calendar_end'          => lang('End') . ': ' . lang('Date') . '+' . lang('Time'),
+					'calendar_endday'       => lang('End') . ': ' . lang('Weekday'),
+					'calendar_enddate'      => lang('End') . ': ' . lang('Date'),
+					'calendar_endtime'      => lang('End') . ': ' . lang('Time'),
+					'calendar_duration'     => lang('Duration'),
+					'calendar_category'     => lang('Category'),
+					'calendar_priority'     => lang('Priority'),
+					'calendar_updated'      => lang('Updated'),
+					'calendar_recur_type'   => lang('Repetition'),
+					'calendar_access'       => lang('Access') . ': ' . lang('public') . ', ' . lang('private'),
+					'calendar_owner'        => lang('Owner'),
+				) as $name => $label)
 		{
-			if (in_array($name,array('start','end')) && $n&1)		// main values, which should be in the first column
+			if(in_array($name, array('start',
+									 'end')) && $n & 1)        // main values, which should be in the first column
 			{
 				echo "</tr>\n";
 				$n++;
 			}
-			if (!($n&1)) echo '<tr>';
-			echo '<td>{{'.$name.'}}</td><td>'.$label.'</td>';
-			if ($n&1) echo "</tr>\n";
+			if(!($n & 1))
+			{
+				echo '<tr>';
+			}
+			echo '<td>{{' . $name . '}}</td><td>' . $label . '</td>';
+			if($n & 1)
+			{
+				echo "</tr>\n";
+			}
 			$n++;
 		}
 
-		echo '<tr><td colspan="4"><h3>'.lang('Range fields').":</h3></td></tr>";
-		echo '<tr><td colspan="4">'.lang('If you select a range (month, week, etc) instead of a list of entries, these extra fields are available').'</td></tr>';
+		echo '<tr><td colspan="4"><h3>' . lang('Range fields') . ":</h3></td></tr>";
+		echo '<tr><td colspan="4">' . lang('If you select a range (month, week, etc) instead of a list of entries, these extra fields are available') . '</td></tr>';
 		foreach(array_keys(self::$range_tags) as $name)
 		{
-			echo '<tr><td>{{range/'.$name.'}}</td><td>'.lang($name)."</td></tr>\n";
+			echo '<tr><td>{{range/' . $name . '}}</td><td>' . lang($name) . "</td></tr>\n";
 		}
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
+		echo '<tr><td colspan="4"><h3>' . lang('Custom fields') . ":</h3></td></tr>";
 		$custom = Api\Storage\Customfields::get('calendar');
 		foreach($custom as $name => $field)
 		{
-			echo '<tr><td>{{#'.$name.'}}</td><td colspan="3">'.$field['label']."</td></tr>\n";
+			echo '<tr><td>{{#' . $name . '}}</td><td colspan="3">' . $field['label'] . "</td></tr>\n";
 		}
 
 
-		echo '<tr><td colspan="4"><h3>'.lang('Participants').":</h3></td></tr>";
-		echo '<tr><td>{{participant_emails}}</td><td colspan="3">'.lang('A list of email addresses of all participants who have not declined')."</td></tr>\n";
-		echo '<tr><td>{{participant_summary}}</td><td colspan="3">'.lang('Summary of participant status: 3 Participants: 1 Accepted, 2 Unknown')."</td></tr>\n";
-		echo '<tr><td colspan="4">'.lang('Participant names by type').'</td></tr>';
-		echo '<tr><td>{{calendar_participants/account}}</td><td colspan="3">'.lang('Accounts')."</td></tr>\n";
-		echo '<tr><td>{{calendar_participants/group}}</td><td colspan="3">'.lang('Groups')."</td></tr>\n";
+		echo '<tr><td colspan="4"><h3>' . lang('Participants') . ":</h3></td></tr>";
+		echo '<tr><td>{{participant_emails}}</td><td colspan="3">' . lang('A list of email addresses of all participants who have not declined') . "</td></tr>\n";
+		echo '<tr><td>{{participant_summary}}</td><td colspan="3">' . lang('Summary of participant status: 3 Participants: 1 Accepted, 2 Unknown') . "</td></tr>\n";
+		echo '<tr><td colspan="4">' . lang('Participant names by type') . '</td></tr>';
+		echo '<tr><td>{{calendar_participants/account}}</td><td colspan="3">' . lang('Accounts') . "</td></tr>\n";
+		echo '<tr><td>{{calendar_participants/group}}</td><td colspan="3">' . lang('Groups') . "</td></tr>\n";
 		foreach($this->bo->resources as $resource)
 		{
 			if($resource['type'])
 			{
-				echo '<tr><td>{{calendar_participants/'.$resource['app'].'}}</td><td colspan="3">'.lang($resource['app'])."</td></tr>\n";
+				echo '<tr><td>{{calendar_participants/' . $resource['app'] . '}}</td><td colspan="3">' . lang($resource['app']) . "</td></tr>\n";
 			}
 		}
 
-		echo '<tr><td colspan="4"><h3>'.lang('Participant table').":</h3></td></tr>";
+		echo '<tr><td colspan="4"><h3>' . lang('Participant table') . ":</h3></td></tr>";
 		echo '<tr><td colspan="4">{{table/participant}} ... </td></tr>';
-		echo '<tr><td>{{name}}</td><td>'.lang('name').'</td></tr>';
-		echo '<tr><td>{{role}}</td><td>'.lang('role').'</td></tr>';
-		echo '<tr><td>{{quantity}}</td><td>'.lang('quantity').'</td></tr>';
-		echo '<tr><td>{{status}}</td><td>'.lang('status').'</td></tr>';
+		echo '<tr><td>{{name}}</td><td>' . lang('name') . '</td></tr>';
+		echo '<tr><td>{{role}}</td><td>' . lang('role') . '</td></tr>';
+		echo '<tr><td>{{quantity}}</td><td>' . lang('quantity') . '</td></tr>';
+		echo '<tr><td>{{status}}</td><td>' . lang('status') . '</td></tr>';
 		echo '<tr><td colspan="4">{{endtable}}</td></tr>';
 
 		echo '<tr style="vertical-align:top"><td colspan="2"><table >';
-		echo '<tr><td><h3>'.lang('Day of week tables').":</h3></td></tr>";
+		echo '<tr><td><h3>' . lang('Day of week tables') . ":</h3></td></tr>";
 		$days = array();
 		for($i = 0; $i < 7; $i++)
 		{
-			$days[date('N',strtotime("+$i days"))] = date('l',strtotime("+$i days"));
+			$days[date('N', strtotime("+$i days"))] = date('l', strtotime("+$i days"));
 		}
 		ksort($days);
 		foreach($days as $day)
 		{
-			echo '<tr><td>{{table/'.$day. '}} ... {{endtable}}</td></tr>';
+			echo '<tr><td>{{table/' . $day . '}} ... {{endtable}}</td></tr>';
 		}
 		echo '</table></td><td colspan="2"><table >';
-		echo '<tr><td><h3>'.lang('Daily tables').":</h3></td></tr>";
-		foreach(self::$relative as $value) {
-			echo '<tr><td>{{table/'.$value. '}} ... {{endtable}}</td></tr>';
+		echo '<tr><td><h3>' . lang('Daily tables') . ":</h3></td></tr>";
+		foreach(self::$relative as $value)
+		{
+			echo '<tr><td>{{table/' . $value . '}} ... {{endtable}}</td></tr>';
 		}
 		echo '<tr><td>{{table/day_n}} ... {{endtable}}</td><td>1 <= n <= 31</td></tr>';
 		echo '</table></td></tr>';
-		echo '<tr><td colspan="2">'.lang('Available for the first entry inside each day of week or daily table inside the selected range:').'</td></tr>';
-		echo '<tr><td>{{day/date}}</td><td colspan="3">'.lang('Date for the day of the week').'</td></tr>';
-		echo '<tr><td>{{day/name}}</td><td colspan="3">'.lang('Name of the day of the week (ex: Monday)').'</td></tr>';
-		echo '<tr><td>{{day/birthdays}}</td><td colspan="3">'.lang('Birthdays').'</td></tr>';
+		echo '<tr><td colspan="2">' . lang('Available for the first entry inside each day of week or daily table inside the selected range:') . '</td></tr>';
+		echo '<tr><td>{{day/date}}</td><td colspan="3">' . lang('Date for the day of the week') . '</td></tr>';
+		echo '<tr><td>{{day/name}}</td><td colspan="3">' . lang('Name of the day of the week (ex: Monday)') . '</td></tr>';
+		echo '<tr><td>{{day/birthdays}}</td><td colspan="3">' . lang('Birthdays') . '</td></tr>';
 
-		echo '<tr><td colspan="4"><h3>'.lang('General fields:')."</h3></td></tr>";
+		echo '<tr><td colspan="4"><h3>' . lang('General fields:') . "</h3></td></tr>";
 		foreach($this->get_common_replacements() as $name => $label)
 		{
-			echo '<tr><td>{{'.$name.'}}</td><td colspan="3">'.$label."</td></tr>\n";
+			echo '<tr><td>{{' . $name . '}}</td><td colspan="3">' . $label . "</td></tr>\n";
 		}
 
 		echo "</table>\n";
 
 		echo $GLOBALS['egw']->framework->footer();
 	}
+
+	/**
+	 * Get a list of placeholders provided.
+	 *
+	 * Placeholders are grouped logically.  Group key should have a user-friendly translation.
+	 */
+	public function get_placeholder_list($prefix = '')
+	{
+		$placeholders = array(
+				'event'        => [],
+				'range'        => [],
+				'participant'  => [],
+				'customfields' => []
+			) + parent::get_placeholder_list($prefix);
+		unset($placeholders['placeholders']);
+
+		$fields = array(
+			'calendar_id'          => lang('Calendar ID'),
+			'calendar_title'       => lang('Title'),
+			'calendar_description' => lang('Description'),
+			'calendar_location'    => lang('Location'),
+			'calendar_start'       => lang('Start') . ': ' . lang('Date') . '+' . lang('Time'),
+			'calendar_startday'    => lang('Start') . ': ' . lang('Weekday'),
+			'calendar_startdate'   => lang('Start') . ': ' . lang('Date'),
+			'calendar_starttime'   => lang('Start') . ': ' . lang('Time'),
+			'calendar_end'         => lang('End') . ': ' . lang('Date') . '+' . lang('Time'),
+			'calendar_endday'      => lang('End') . ': ' . lang('Weekday'),
+			'calendar_enddate'     => lang('End') . ': ' . lang('Date'),
+			'calendar_endtime'     => lang('End') . ': ' . lang('Time'),
+			'calendar_duration'    => lang('Duration'),
+			'calendar_category'    => lang('Category'),
+			'calendar_priority'    => lang('Priority'),
+			'calendar_updated'     => lang('Updated'),
+			'calendar_recur_type'  => lang('Repetition'),
+			'calendar_access'      => lang('Access') . ': ' . lang('public') . ', ' . lang('private'),
+			'calendar_owner'       => lang('Owner'),
+		);
+		$group = 'event';
+		foreach($fields as $name => $label)
+		{
+			$marker = $this->prefix($prefix, $name, '{');
+			if(!array_filter($placeholders, function ($a) use ($marker)
+			{
+				return array_key_exists($marker, $a);
+			}))
+			{
+				$placeholders[$group][] = [
+					'value' => $marker,
+					'label' => $label
+				];
+			}
+		}
+
+		/**
+		 * These ones only work if you select a range, not events
+		 * $group = 'range';
+		 * foreach(array_keys(self::$range_tags) as $name)
+		 * {
+		 * $marker = $this->prefix($prefix, "range/$name", '{');
+		 * $placeholders[$group][] = [
+		 * 'value' => $marker,
+		 * 'label' => lang($name)
+		 * ];
+		 * }
+		 */
+
+		$group = 'participant';
+		$placeholders[$group][] = array(
+			'value' => '{{calendar_participants}}',
+			'label' => lang('Participants')
+		);
+		$placeholders[$group][] = array(
+			'value' => '{{participant_emails}}',
+			'label' => 'Emails',
+			'title' => lang('A list of email addresses of all participants who have not declined')
+		);
+		$placeholders[$group][] = array(
+			'value' => '{{participant_summary}}',
+			'label' => 'participant summary',
+			'title' => lang('Summary of participant status: 3 Participants: 1 Accepted, 2 Unknown')
+		);
+		$placeholders[$group][] = array(
+			'value' => '{{calendar_participants/account}}',
+			'label' => lang('Accounts')
+		);
+		$placeholders[$group][] = array(
+			'value' => '{{calendar_participants/group}}',
+			'label' => lang('Groups')
+		);
+		foreach($this->bo->resources as $resource)
+		{
+			if($resource['type'])
+			{
+				$marker = $this->prefix($prefix, 'calendar_participants/' . $resource['app'], '{');
+				$placeholders[$group][] = array(
+					'value' => $marker,
+					'label' => lang($resource['app'])
+				);
+			}
+		}
+		return $placeholders;
+	}
 }
diff --git a/calendar/inc/class.calendar_so.inc.php b/calendar/inc/class.calendar_so.inc.php
index 6595a608d6..386c64cd21 100644
--- a/calendar/inc/class.calendar_so.inc.php
+++ b/calendar/inc/class.calendar_so.inc.php
@@ -878,7 +878,14 @@ class calendar_so
 			$where[] = "$this->user_table.cal_recur_date=0";
 			$cols = str_replace(array('cal_start','cal_end'),array('range_start AS cal_start','(SELECT MIN(cal_end) FROM egw_cal_dates WHERE egw_cal.cal_id=egw_cal_dates.cal_id) AS cal_end'),$cols);
 			// in case cal_start is used in a query, eg. calendar_ical::find_event
-			$where = str_replace(array('cal_start','cal_end'), array('range_start','(SELECT MIN(cal_end) FROM egw_cal_dates WHERE egw_cal.cal_id=egw_cal_dates.cal_id)'), $where);
+			// in contrary to the docu on php.net, 3rd parameter can not be an array: https://3v4l.org/budKH
+			foreach($where as &$val)
+			{
+				if (!is_array($val))
+				{
+					$val = str_replace(array('cal_start','cal_end'), array('range_start','(SELECT MIN(cal_end) FROM egw_cal_dates WHERE egw_cal.cal_id=egw_cal_dates.cal_id)'), $val);
+				}
+			}
 			$params['order'] = str_replace('cal_start', 'range_start', $params['order']);
 			if ($end) $where[] = (int)$end.' > range_start';
   		}
diff --git a/calendar/inc/class.calendar_ui.inc.php b/calendar/inc/class.calendar_ui.inc.php
index 5446573d60..d3b03e67b1 100644
--- a/calendar/inc/class.calendar_ui.inc.php
+++ b/calendar/inc/class.calendar_ui.inc.php
@@ -642,16 +642,20 @@ class calendar_ui
 	 *
 	 * @param int $event_id
 	 * @param Api\DateTime $recurrence_date
+	 * @param array|bool|int|null $old_event
 	 *
 	 * @return boolean True if the event was updated, false if it could not be
-	 *	updated or was removed.
+	 *    updated or was removed.
 	 */
-	public function update_client($event_id, Api\DateTime $recurrence_date = null)
+	public function update_client($event_id, Api\DateTime $recurrence_date = null, $old_event = array())
 	{
-		if(!$event_id) return false;
-		if(is_string($event_id) && strpos($event_id,':') !== FALSE)
+		if(!$event_id)
 		{
-			list($event_id, $date) = explode(':',$event_id);
+			return false;
+		}
+		if(is_string($event_id) && strpos($event_id, ':') !== FALSE)
+		{
+			list($event_id, $date) = explode(':', $event_id);
 			$recurrence_date = new Api\DateTime($date);
 		}
 
@@ -698,6 +702,21 @@ class calendar_ui
 		else if($event['recur_type'] )
 		{
 			$this_month = new Api\DateTime('next month');
+			$data = [];
+			if($old_event && ($old_event['start'] != $event['start'] || $old_event['recur_enddate'] != $event['recur_enddate']))
+			{
+				// Set up to clear old events in case recurrence start/end date changed
+				$old_rrule = calendar_rrule::event2rrule($old_event, true);
+
+				$old_rrule->rewind();
+				do
+				{
+					$occurrence = $old_rrule->current();
+					$data['calendar::' . $old_event['id'] . ':' . $occurrence->format('ts')] = null;
+					$old_rrule->next();
+				}
+				while($old_rrule->valid() && $occurrence <= $this_month);
+			}
 			$rrule = calendar_rrule::event2rrule($event, true);
 			$rrule->rewind();
 			do
@@ -705,10 +724,17 @@ class calendar_ui
 				$occurrence = $rrule->current();
 				$converted = $this->bo->read($event['id'], $occurrence);
 				$this->to_client($converted);
-				$response->generic('data', array('uid' => 'calendar::'.$converted['row_id'], 'data' => $converted));
+				$data['calendar::' . $converted['row_id']] = $converted;
 				$rrule->next();
 			}
-			while ($rrule->valid() && $occurrence <= $this_month );
+			while($rrule->valid() && $occurrence <= $this_month);
+
+			// Now we have to go through and send each one individually, since client side data can't handle more than one
+			foreach($data as $uid => $cal_data)
+			{
+				$response->apply('egw.dataStoreUID', [$uid, $cal_data]);
+			}
+			$response->apply('app.calendar.update_events', [array_keys($data)]);
 		}
 		return true;
 	}
diff --git a/calendar/inc/class.calendar_uiforms.inc.php b/calendar/inc/class.calendar_uiforms.inc.php
index ac65f48aa0..bedef318c8 100644
--- a/calendar/inc/class.calendar_uiforms.inc.php
+++ b/calendar/inc/class.calendar_uiforms.inc.php
@@ -1007,7 +1007,7 @@ class calendar_uiforms extends calendar_ui
 				$response = Api\Json\Response::get();
 				if($response && $update_type != 'delete' && !$client_updated)
 				{
-					$client_updated = $this->update_client($event['id']);
+					$client_updated = $this->update_client($event['id'], null, is_array($old_event) ? $old_event : []);
 				}
 
 				$msg = $message . ($msg ? ', ' . $msg : '');
@@ -1267,8 +1267,6 @@ class calendar_uiforms extends calendar_ui
 			Api\DateTime::to($as_of_date,'ts') < time()
 		)
 		{
-
-			unset($orig_event);
 			// copy event by unsetting the id(s)
 			unset($event['id']);
 			unset($event['uid']);
@@ -1325,7 +1323,8 @@ class calendar_uiforms extends calendar_ui
 			}
 			$last->setTime(0, 0, 0);
 			$old_event['recur_enddate'] = Api\DateTime::to($last, 'ts');
-			if (!$this->bo->update($old_event,true,true,false,true,$dummy=null,$no_notifications))
+			$dummy = null;
+			if (!$this->bo->update($old_event,true,true,false,true,$dummy, $no_notifications))
 			{
 				$msg .= ($msg ? ', ' : '') .lang('Error: the entry has been updated since you opened it for editing!').'<br />'.
 					lang('Copy your changes to the clipboard, %1reload the entry%2 and merge them.','<a href="'.
@@ -1925,7 +1924,7 @@ class calendar_uiforms extends calendar_ui
 				$sel_options['owner'][$uid] = $this->bo->participant_name($uid);
 			}
 		}
-		$content['no_add_alarm'] = !count($sel_options['owner']);	// no rights to set any alarm
+		$content['no_add_alarm'] = empty($sel_options['owner']) || !count((array)$sel_options['owner']);	// no rights to set any alarm
 		if (!$event['id'])
 		{
 			$etpl->set_cell_attribute('button[new_alarm]','type','checkbox');
@@ -2182,7 +2181,7 @@ class calendar_uiforms extends calendar_ui
 						}
 						$user_and_memberships = $GLOBALS['egw']->accounts->memberships($user, true);
 						$user_and_memberships[] = $user;
-						if (!array_intersect(array_keys($event['participants']), $user_and_memberships))
+						if (!array_intersect(array_keys($event['participants'] ?? []), $user_and_memberships))
 						{
 							$event['error'] .= ($event['error'] ? "\n" : '').lang('You are not invited to that event!');
 							if ($event['id'])
diff --git a/calendar/js/app.ts b/calendar/js/app.ts
index 1892521640..38181192d5 100644
--- a/calendar/js/app.ts
+++ b/calendar/js/app.ts
@@ -651,22 +651,34 @@ export class CalendarApp extends EgwApp
 		}
 
 		// Do we already have "fresh" data?  Most user actions give fresh data in response
-		let existing = egw.dataGetUIDdata('calendar::'+pushData.id);
+		let existing = egw.dataGetUIDdata('calendar::' + pushData.id);
 		if(existing && Math.abs(existing.timestamp - new Date().valueOf()) < 1000)
 		{
 			// Update directly
-			this._update_events(this.state, ['calendar::'+pushData.id]);
+			this._update_events(this.state, ['calendar::' + pushData.id]);
 			return;
-		};
+		}
+		;
 
 		// Ask for the real data, we don't have it
-		egw.request("calendar.calendar_ui.ajax_get", [[pushData.id]]).then((data) =>
+		let process_data = (data) =>
 		{
 			// Store it, which will call all registered listeners
 			egw.dataStoreUID(data.uid, data.data);
 
 			// Any existing events were updated.  Run this to catch new events or events moved into view
 			this._update_events(this.state, [data.uid]);
+		}
+		egw.request("calendar.calendar_ui.ajax_get", [[pushData.id]]).then((data) =>
+		{
+			if(typeof data.uid !== "undefined")
+			{
+				return process_data(data)
+			}
+			for(let e of data)
+			{
+				process_data(e);
+			}
 		});
 	}
 
@@ -3736,13 +3748,22 @@ export class CalendarApp extends EgwApp
 				else if(typeof framework !== 'undefined')
 				{
 					framework.applications.calendar.sidemenuEntry.hideAjaxLoader();
-					egw.loading_prompt('calendar',false)
+					egw.loading_prompt('calendar', false)
 
 				}
-			}, this,null
+			}, this, null
 		);
 	}
 
+	/**
+	 * We have a list of calendar UIDs of events that need updating.
+	 * Public wrapper for _update_events so we can call it from server
+	 */
+	update_events(uids : string[])
+	{
+		return this._update_events(this.state, uids);
+	}
+
 	/**
 	 * We have a list of calendar UIDs of events that need updating.
 	 *
diff --git a/composer.json b/composer.json
index 0d58292407..12a95595e8 100644
--- a/composer.json
+++ b/composer.json
@@ -46,6 +46,14 @@
 			"type": "pear",
 			"url": "https://pear.horde.org"
 		},
+		{
+			"type": "vcs",
+			"url": "https://github.com/egroupware/Crypt"
+		},
+		{
+			"type": "vcs",
+			"url": "https://github.com/egroupware/Compress"
+		},
 		{
 			"type": "vcs",
 			"url": "https://github.com/IMSGlobal/lti-1-3-php-library"
@@ -81,6 +89,8 @@
 		"egroupware/adodb-php": "self.version",
 		"egroupware/bookmarks": "self.version",
 		"egroupware/collabora": "self.version",
+		"egroupware/compress": "^2.2.3",
+		"egroupware/crypt": "^2.7.13",
 		"egroupware/guzzlestream": "dev-master",
 		"egroupware/icalendar": "^2.1.9",
 		"egroupware/magicsuggest": "^2.1",
@@ -99,8 +109,6 @@
 		"npm-asset/as-jqplot": "1.0.*",
 		"npm-asset/gridster": "0.5.*",
 		"oomphinc/composer-installers-extender": "^1.1",
-		"pear-pear.horde.org/horde_compress": "^2.0.8",
-		"pear-pear.horde.org/horde_crypt": "^2.7.9",
 		"pear-pear.horde.org/horde_imap_client": "^2.30.3",
 		"pear-pear.horde.org/horde_mail": "^2.1.2",
 		"pear-pear.horde.org/horde_managesieve": "^1.0.2",
diff --git a/composer.lock b/composer.lock
index d135317698..36fd4d41c3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "75e07178544cf7f50260755a37724799",
+    "content-hash": "c413807814d716c0662b69272da98ba2",
     "packages": [
         {
             "name": "adldap2/adldap2",
@@ -215,9 +215,48 @@
                 "url": "https://api.github.com/repos/fengyuanchen/cropper/zipball/30c58b29ee21010e17e58ebab165fbd84285c685",
                 "reference": "30c58b29ee21010e17e58ebab165fbd84285c685"
             },
-            "type": "bower-asset",
+            "type": "bower-asset-library",
+            "extra": {
+                "bower-asset-main": [
+                    "dist/cropper.js",
+                    "dist/cropper.css"
+                ],
+                "bower-asset-ignore": [
+                    "**/.*",
+                    "node_modules",
+                    "bower_components",
+                    "tests",
+                    "test",
+                    "examples",
+                    "assets",
+                    "demo",
+                    "docs",
+                    "gulpfile.js",
+                    "CONTRIBUTING.md",
+                    "ISSUE_TEMPLATE.md"
+                ]
+            },
             "license": [
                 "MIT"
+            ],
+            "description": "A simple jQuery image cropping plugin.",
+            "keywords": [
+                "crop",
+                "cropper",
+                "cropping",
+                "css",
+                "development",
+                "front-end",
+                "html",
+                "image",
+                "javascript",
+                "jquery",
+                "move",
+                "plugin",
+                "rotate",
+                "scale",
+                "web",
+                "zoom"
             ]
         },
         {
@@ -233,7 +272,7 @@
                 "url": "https://api.github.com/repos/rtfpessoa/diff2html/zipball/4c15a9ca93e50c78f8d02e37273076994888d499",
                 "reference": "4c15a9ca93e50c78f8d02e37273076994888d499"
             },
-            "type": "bower-asset"
+            "type": "bower-asset-library"
         },
         {
             "name": "bower-asset/fastclick",
@@ -248,7 +287,18 @@
                 "url": "https://api.github.com/repos/ftlabs/fastclick/zipball/9977a916739360ad4ffd7aa19b0016bf375e934b",
                 "reference": "9977a916739360ad4ffd7aa19b0016bf375e934b"
             },
-            "type": "bower-asset"
+            "type": "bower-asset-library",
+            "extra": {
+                "bower-asset-main": "lib/fastclick.js",
+                "bower-asset-ignore": [
+                    "**/.*",
+                    "component.json",
+                    "package.json",
+                    "Makefile",
+                    "tests",
+                    "examples"
+                ]
+            }
         },
         {
             "name": "bower-asset/jquery",
@@ -263,9 +313,21 @@
                 "url": "https://api.github.com/repos/jquery/jquery-dist/zipball/5e89585e0121e72ff47de177c5ef604f3089a53d",
                 "reference": "5e89585e0121e72ff47de177c5ef604f3089a53d"
             },
-            "type": "bower-asset",
+            "type": "bower-asset-library",
+            "extra": {
+                "bower-asset-main": "dist/jquery.js",
+                "bower-asset-ignore": [
+                    "package.json"
+                ]
+            },
             "license": [
                 "MIT"
+            ],
+            "keywords": [
+                "browser",
+                "javascript",
+                "jquery",
+                "library"
             ]
         },
         {
@@ -281,7 +343,10 @@
                 "url": "https://api.github.com/repos/mattbryson/TouchSwipe-Jquery-Plugin/zipball/f0d71bf645779358e1458e212102c5d59b7702ec",
                 "reference": "f0d71bf645779358e1458e212102c5d59b7702ec"
             },
-            "type": "bower-asset"
+            "type": "bower-asset-library",
+            "extra": {
+                "bower-asset-main": "./jquery.touchSwipe.js"
+            }
         },
         {
             "name": "celtic/lti",
@@ -894,6 +959,139 @@
             "homepage": "https://www.egroupware.org/",
             "time": "2021-09-28T20:48:25+00:00"
         },
+        {
+            "name": "egroupware/compress",
+            "version": "2.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/EGroupware/Compress.git",
+                "reference": "5c02e95fff1462ca13ecd3af04b30356ad1363b3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/EGroupware/Compress/zipball/5c02e95fff1462ca13ecd3af04b30356ad1363b3",
+                "reference": "5c02e95fff1462ca13ecd3af04b30356ad1363b3",
+                "shasum": ""
+            },
+            "require": {
+                "pear-pear.horde.org/horde_exception": "^2",
+                "pear-pear.horde.org/horde_mime": "^2.5",
+                "pear-pear.horde.org/horde_translation": "^2.2",
+                "pear-pear.horde.org/horde_util": "^2",
+                "php": "^5.3 || ^7"
+            },
+            "replace": {
+                "pear-horde/horde_compress": "2.*",
+                "pear-pear.horde.org/horde_compress": "2.*"
+            },
+            "suggest": {
+                "ext-zlib": "*",
+                "pear-pear.horde.org/Horde_Icalendar": "^2",
+                "pear-pear.horde.org/Horde_Mail": "^2.5",
+                "pear-pear.horde.org/Horde_Mapi": "^1",
+                "pear-pear.horde.org/Horde_Stream_Filter": "^2",
+                "pear-pear.horde.org/Horde_Test": "^2.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "Horde_Compress": "lib/"
+                }
+            },
+            "license": [
+                "LGPL-2.1"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Schneider",
+                    "email": "jan@horde.org",
+                    "role": "lead"
+                },
+                {
+                    "name": "Michael J Rubinsky",
+                    "email": "mrubinsk@horde.org",
+                    "role": "developer"
+                },
+                {
+                    "name": "Chuck Hagenbuch",
+                    "email": "chuck@horde.org",
+                    "role": "lead"
+                },
+                {
+                    "name": "Michael Slusarz",
+                    "email": "slusarz@horde.org",
+                    "role": "lead"
+                }
+            ],
+            "description": "Compression library",
+            "homepage": "https://www.horde.org/libraries/Horde_Compress",
+            "support": {
+                "source": "https://github.com/EGroupware/Compress/tree/2.2.3"
+            },
+            "time": "2020-03-11T00:00:00+00:00"
+        },
+        {
+            "name": "egroupware/crypt",
+            "version": "2.7.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/EGroupware/Crypt.git",
+                "reference": "d25af6514c134426647dae2c47e5bf07bb7c4d1e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/EGroupware/Crypt/zipball/d25af6514c134426647dae2c47e5bf07bb7c4d1e",
+                "reference": "d25af6514c134426647dae2c47e5bf07bb7c4d1e",
+                "shasum": ""
+            },
+            "require": {
+                "ext-hash": "*",
+                "ext-openssl": "*",
+                "pear-pear.horde.org/horde_exception": "^2",
+                "pear-pear.horde.org/horde_http": "^2",
+                "pear-pear.horde.org/horde_mime": "^2",
+                "pear-pear.horde.org/horde_stream": "^1.5",
+                "pear-pear.horde.org/horde_stream_filter": "^2",
+                "pear-pear.horde.org/horde_translation": "^2.2",
+                "pear-pear.horde.org/horde_url": "^2",
+                "pear-pear.horde.org/horde_util": "^2",
+                "php": "^5.3 || ^7 || ^8"
+            },
+            "replace": {
+                "pear-horde/horde_crypt": "2.*",
+                "pear-pear.horde.org/horde_crypt": "2.*"
+            },
+            "suggest": {
+                "pear-pear.horde.org/Horde_Test": "^2.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "Horde_Crypt": "lib/"
+                }
+            },
+            "license": [
+                "LGPL-2.1"
+            ],
+            "authors": [
+                {
+                    "name": "Michael Slusarz",
+                    "email": "slusarz@horde.org",
+                    "role": "lead"
+                },
+                {
+                    "name": "Jan Schneider",
+                    "email": "jan@horde.org",
+                    "role": "lead"
+                }
+            ],
+            "description": "Cryptography library",
+            "homepage": "https://www.horde.org/libraries/Horde_Crypt",
+            "support": {
+                "source": "https://github.com/EGroupware/Crypt/tree/v2.7.13"
+            },
+            "time": "2017-11-11T00:00:00+00:00"
+        },
         {
             "name": "egroupware/guzzlestream",
             "version": "dev-master",
@@ -994,12 +1192,12 @@
                 {
                     "name": "Chuck Hagenbuch",
                     "email": "chuck@horde.org",
-                    "role": "Lead"
+                    "role": "lead"
                 },
                 {
                     "name": "Jan Schneider",
                     "email": "jan@horde.org",
-                    "role": "Lead"
+                    "role": "lead"
                 },
                 {
                     "name": "Michael J Rubinsky",
@@ -1113,7 +1311,7 @@
             ],
             "description": "Compiled version of magicsuggest customized for EGroupware project.",
             "homepage": "https://github.com/EGroupware/magicsuggest",
-            "time": "2018-06-21T13:36:37+00:00"
+            "time": "2018-06-21T10:14:03+00:00"
         },
         {
             "name": "egroupware/news_admin",
@@ -2338,36 +2536,112 @@
             "version": "1.0.8",
             "dist": {
                 "type": "tar",
-                "url": "https://registry.npmjs.org/as-jqplot/-/as-jqplot-1.0.8.tgz"
+                "url": "https://registry.npmjs.org/as-jqplot/-/as-jqplot-1.0.8.tgz",
+                "shasum": "97061e0f32167597b87c98dfd42c93f10a2fb249"
+            },
+            "type": "npm-asset-library",
+            "extra": {
+                "npm-asset-main": "index.js",
+                "npm-asset-directories": [],
+                "npm-asset-scripts": {
+                    "test": "echo \"Error: no test specified\" && exit 1"
+                }
             },
-            "type": "npm-asset",
             "license": [
                 "ISC"
-            ]
+            ],
+            "authors": [
+                ""
+            ],
+            "description": "AS jqPlot Shim"
         },
         {
             "name": "npm-asset/dhtmlx-gantt",
             "version": "6.2.7",
             "dist": {
                 "type": "tar",
-                "url": "https://registry.npmjs.org/dhtmlx-gantt/-/dhtmlx-gantt-6.2.7.tgz"
+                "url": "https://registry.npmjs.org/dhtmlx-gantt/-/dhtmlx-gantt-6.2.7.tgz",
+                "shasum": "86689f36c1f10e2bfaa5126445b4c560d82b4587"
+            },
+            "type": "npm-asset-library",
+            "extra": {
+                "npm-asset-bugs": {
+                    "url": "https://github.com/DHTMLX/gantt/issues"
+                },
+                "npm-asset-main": "codebase/dhtmlxgantt.js",
+                "npm-asset-directories": [],
+                "npm-asset-repository": {
+                    "type": "git",
+                    "url": "git+https://github.com/DHTMLX/gantt.git"
+                }
             },
-            "type": "npm-asset",
             "license": [
                 "GPL-2.0"
-            ]
+            ],
+            "authors": [
+                {
+                    "name": "DHTMLX"
+                }
+            ],
+            "description": "An open source JavaScript Gantt chart that helps you illustrate a project schedule in a nice-looking chart.",
+            "homepage": "https://github.com/DHTMLX/gantt#readme",
+            "keywords": [
+                "browser",
+                "calendar",
+                "chart",
+                "dhtmlx",
+                "dhtmlxgantt",
+                "gantt",
+                "gantt chart",
+                "scheduler",
+                "timeline"
+            ],
+            "time": "2019-10-11T10:48:39+00:00"
         },
         {
             "name": "npm-asset/gridster",
             "version": "0.5.6",
             "dist": {
                 "type": "tar",
-                "url": "https://registry.npmjs.org/gridster/-/gridster-0.5.6.tgz"
+                "url": "https://registry.npmjs.org/gridster/-/gridster-0.5.6.tgz",
+                "shasum": "4024713aabd559093a72e9b713f1e41f9bded76f"
             },
             "require": {
                 "npm-asset/jquery": "2.0.3"
             },
-            "type": "npm-asset"
+            "require-dev": {
+                "npm-asset/bower": "~0.9.2",
+                "npm-asset/grunt": "~0.4.1",
+                "npm-asset/grunt-bump": "0.0.11",
+                "npm-asset/grunt-contrib-concat": "~0.1.3",
+                "npm-asset/grunt-contrib-cssmin": "~0.5.0",
+                "npm-asset/grunt-contrib-jshint": "~0.3.0",
+                "npm-asset/grunt-contrib-uglify": "~0.2.0",
+                "npm-asset/grunt-contrib-watch": "~0.3.1",
+                "npm-asset/grunt-contrib-yuidoc": "~0.4.0",
+                "npm-asset/grunt-conventional-changelog": "~1.0.0",
+                "npm-asset/qunitjs": "~1.11.0"
+            },
+            "type": "npm-asset-library",
+            "extra": {
+                "npm-asset-bugs": {
+                    "url": "https://github.com/ducksboard/gridster.js/issues"
+                },
+                "npm-asset-directories": [],
+                "npm-asset-repository": {
+                    "type": "git",
+                    "url": "git://github.com/ducksboard/gridster.js.git"
+                },
+                "npm-asset-scripts": []
+            },
+            "authors": [
+                {
+                    "name": "ducksboard",
+                    "email": "hackers@ducksboard.com"
+                }
+            ],
+            "description": "a drag-and-drop multi-column jQuery grid plugin",
+            "homepage": "http://gridster.net/"
         },
         {
             "name": "npm-asset/jquery",
@@ -2382,7 +2656,36 @@
                 "url": "https://api.github.com/repos/jquery/jquery/zipball/f852e631ba85af7da4ad7594785e122504e7b233",
                 "reference": "f852e631ba85af7da4ad7594785e122504e7b233"
             },
-            "type": "npm-asset"
+            "require-dev": {
+                "npm-asset/archiver": "~0.4.2",
+                "npm-asset/grunt": "0.4.1",
+                "npm-asset/grunt-compare-size": "~0.4.0",
+                "npm-asset/grunt-contrib-jshint": "0.3.0",
+                "npm-asset/grunt-contrib-uglify": "0.2.0",
+                "npm-asset/grunt-contrib-watch": "0.3.1",
+                "npm-asset/grunt-git-authors": "1.2.0",
+                "npm-asset/grunt-update-submodules": "0.2.0",
+                "npm-asset/gzip-js": "0.3.1",
+                "npm-asset/testswarm": "~1.1.0"
+            },
+            "type": "npm-asset-library",
+            "extra": {
+                "npm-asset-bugs": {
+                    "url": "http://bugs.jquery.com"
+                },
+                "npm-asset-repository": {
+                    "type": "git",
+                    "url": "https://github.com/jquery/jquery.git"
+                }
+            },
+            "authors": [
+                {
+                    "name": "jQuery Foundation and other contributors",
+                    "url": "https://github.com/jquery/jquery/blob/master/AUTHORS.txt"
+                }
+            ],
+            "description": "JavaScript library for DOM operations",
+            "homepage": "http://jquery.com"
         },
         {
             "name": "oomphinc/composer-installers-extender",
@@ -2533,74 +2836,6 @@
             ],
             "time": "2018-07-02T15:55:56+00:00"
         },
-        {
-            "name": "pear-pear.horde.org/Horde_Compress",
-            "version": "2.2.3",
-            "dist": {
-                "type": "file",
-                "url": "https://pear.horde.org/get/Horde_Compress-2.2.3.tgz"
-            },
-            "require": {
-                "pear-pear.horde.org/horde_exception": "<3.0.0.0",
-                "pear-pear.horde.org/horde_mime": "<3.0.0.0",
-                "pear-pear.horde.org/horde_translation": "<3.0.0.0",
-                "pear-pear.horde.org/horde_util": "<3.0.0.0",
-                "php": "<8.0.0.0"
-            },
-            "replace": {
-                "pear-horde/horde_compress": "== 2.2.3.0"
-            },
-            "type": "pear-library",
-            "autoload": {
-                "classmap": [
-                    ""
-                ]
-            },
-            "include-path": [
-                "/"
-            ],
-            "license": [
-                "LGPL-2.1"
-            ],
-            "description": "A library to wrap various compression techniques."
-        },
-        {
-            "name": "pear-pear.horde.org/Horde_Crypt",
-            "version": "2.7.12",
-            "dist": {
-                "type": "file",
-                "url": "https://pear.horde.org/get/Horde_Crypt-2.7.12.tgz"
-            },
-            "require": {
-                "ext-hash": "*",
-                "ext-openssl": "*",
-                "pear-pear.horde.org/horde_exception": "<3.0.0.0",
-                "pear-pear.horde.org/horde_http": "<3.0.0.0",
-                "pear-pear.horde.org/horde_mime": "<3.0.0.0",
-                "pear-pear.horde.org/horde_stream": "<2.0.0.0",
-                "pear-pear.horde.org/horde_stream_filter": "<3.0.0.0",
-                "pear-pear.horde.org/horde_translation": "<3.0.0.0",
-                "pear-pear.horde.org/horde_url": "<3.0.0.0",
-                "pear-pear.horde.org/horde_util": "<3.0.0.0",
-                "php": "<8.0.0.0"
-            },
-            "replace": {
-                "pear-horde/horde_crypt": "== 2.7.12.0"
-            },
-            "type": "pear-library",
-            "autoload": {
-                "classmap": [
-                    ""
-                ]
-            },
-            "include-path": [
-                "/"
-            ],
-            "license": [
-                "LGPL-2.1"
-            ],
-            "description": "The Horde_Crypt package class provides an API for various cryptographic systems."
-        },
         {
             "name": "pear-pear.horde.org/Horde_Crypt_Blowfish",
             "version": "1.1.3",
@@ -3977,12 +4212,6 @@
                 }
             ],
             "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
-            "funding": [
-                {
-                    "url": "https://github.com/Synchro",
-                    "type": "github"
-                }
-            ],
             "time": "2021-06-16T14:33:43+00:00"
         },
         {
@@ -4074,20 +4303,6 @@
                 "x.509",
                 "x509"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/terrafrost",
-                    "type": "github"
-                },
-                {
-                    "url": "https://www.patreon.com/phpseclib",
-                    "type": "patreon"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-04-06T13:56:45+00:00"
         },
         {
@@ -4942,6 +5157,7 @@
                 "facebook",
                 "simplesamlphp"
             ],
+            "abandoned": true,
             "time": "2020-03-13T11:29:21+00:00"
         },
         {
@@ -5088,6 +5304,7 @@
                 "windows",
                 "windowslive"
             ],
+            "abandoned": true,
             "time": "2019-12-03T09:01:13+00:00"
         },
         {
@@ -5805,6 +6022,7 @@
                 "oauth1",
                 "simplesamlphp"
             ],
+            "abandoned": true,
             "time": "2019-12-03T09:22:08+00:00"
         },
         {
@@ -6368,20 +6586,6 @@
                 "caching",
                 "psr6"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-03-16T09:10:13+00:00"
         },
         {
@@ -6444,20 +6648,6 @@
                 "interoperability",
                 "standards"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-03-23T23:28:01+00:00"
         },
         {
@@ -6522,20 +6712,6 @@
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-27T16:54:36+00:00"
         },
         {
@@ -6608,20 +6784,6 @@
             ],
             "description": "Eases the creation of beautiful and testable command line interfaces",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-03-26T09:23:24+00:00"
         },
         {
@@ -6679,20 +6841,6 @@
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-10T07:47:39+00:00"
         },
         {
@@ -6766,20 +6914,6 @@
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-30T10:09:30+00:00"
         },
         {
@@ -6837,20 +6971,6 @@
             ],
             "description": "Symfony ErrorHandler Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-17T09:56:45+00:00"
         },
         {
@@ -6994,20 +7114,6 @@
                 "interoperability",
                 "standards"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-06T13:19:58+00:00"
         },
         {
@@ -7058,20 +7164,6 @@
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-27T16:54:36+00:00"
         },
         {
@@ -7116,20 +7208,6 @@
             ],
             "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-02-15T18:55:04+00:00"
         },
         {
@@ -7256,20 +7334,6 @@
             ],
             "description": "Symfony FrameworkBundle",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-12-11T16:32:02+00:00"
         },
         {
@@ -7325,20 +7389,6 @@
             ],
             "description": "Symfony HttpFoundation Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-17T07:39:58+00:00"
         },
         {
@@ -7430,20 +7480,6 @@
             ],
             "description": "Symfony HttpKernel Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-09-02T08:09:29+00:00"
         },
         {
@@ -7506,20 +7542,6 @@
                 "mime",
                 "mime-type"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-17T09:56:45+00:00"
         },
         {
@@ -7582,20 +7604,6 @@
                 "polyfill",
                 "portable"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-01-07T16:49:33+00:00"
         },
         {
@@ -7667,20 +7675,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-04T06:02:08+00:00"
         },
         {
@@ -7748,20 +7742,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -7825,20 +7805,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -7958,20 +7924,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -8031,20 +7983,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -8107,20 +8045,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -8187,20 +8111,6 @@
                 "portable",
                 "shim"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-07-14T12:35:20+00:00"
         },
         {
@@ -8329,20 +8239,6 @@
                 "uri",
                 "url"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-30T11:41:10+00:00"
         },
         {
@@ -8478,20 +8374,6 @@
                 "debug",
                 "dump"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-08-17T07:31:35+00:00"
         },
         {
@@ -8548,20 +8430,6 @@
                 "instantiate",
                 "serialize"
             ],
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2021-01-27T10:01:46+00:00"
         },
         {
@@ -8621,20 +8489,6 @@
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-30T11:41:10+00:00"
         },
         {
@@ -8972,20 +8826,6 @@
                 "constructor",
                 "instantiate"
             ],
-            "funding": [
-                {
-                    "url": "https://www.doctrine-project.org/sponsorship.html",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://www.patreon.com/phpdoctrine",
-                    "type": "patreon"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-11-10T18:47:58+00:00"
         },
         {
@@ -9223,12 +9063,6 @@
                 "object",
                 "object graph"
             ],
-            "funding": [
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-11-13T09:40:50+00:00"
         },
         {
@@ -9661,12 +9495,6 @@
                 "testing",
                 "xunit"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2021-03-28T07:26:59+00:00"
         },
         {
@@ -9717,12 +9545,6 @@
                 "filesystem",
                 "iterator"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T05:57:25+00:00"
         },
         {
@@ -9776,12 +9598,6 @@
             "keywords": [
                 "process"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T05:58:55+00:00"
         },
         {
@@ -9831,12 +9647,6 @@
             "keywords": [
                 "template"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T05:33:50+00:00"
         },
         {
@@ -9886,12 +9696,6 @@
             "keywords": [
                 "timer"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:16:10+00:00"
         },
         {
@@ -9981,16 +9785,6 @@
                 "testing",
                 "xunit"
             ],
-            "funding": [
-                {
-                    "url": "https://phpunit.de/donate.html",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-02T03:54:37+00:00"
         },
         {
@@ -10077,12 +9871,6 @@
             ],
             "description": "Library for parsing CLI options",
             "homepage": "https://github.com/sebastianbergmann/cli-parser",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T06:08:49+00:00"
         },
         {
@@ -10129,12 +9917,6 @@
             ],
             "description": "Collection of value objects that represent the PHP code units",
             "homepage": "https://github.com/sebastianbergmann/code-unit",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:08:54+00:00"
         },
         {
@@ -10180,12 +9962,6 @@
             ],
             "description": "Looks up which function or method a line of code belongs to",
             "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T05:30:19+00:00"
         },
         {
@@ -10250,12 +10026,6 @@
                 "compare",
                 "equality"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T15:49:45+00:00"
         },
         {
@@ -10303,12 +10073,6 @@
             ],
             "description": "Library for calculating the complexity of PHP code units",
             "homepage": "https://github.com/sebastianbergmann/complexity",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T15:52:27+00:00"
         },
         {
@@ -10365,12 +10129,6 @@
                 "unidiff",
                 "unified diff"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:10:38+00:00"
         },
         {
@@ -10424,12 +10182,6 @@
                 "environment",
                 "hhvm"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T05:52:38+00:00"
         },
         {
@@ -10497,12 +10249,6 @@
                 "export",
                 "exporter"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T05:24:23+00:00"
         },
         {
@@ -10557,12 +10303,6 @@
             "keywords": [
                 "global state"
             ],
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T15:55:19+00:00"
         },
         {
@@ -10610,12 +10350,6 @@
             ],
             "description": "Library for counting the lines of code in PHP source code",
             "homepage": "https://github.com/sebastianbergmann/lines-of-code",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-11-28T06:42:11+00:00"
         },
         {
@@ -10663,12 +10397,6 @@
             ],
             "description": "Traverses array structures and object graphs to enumerate all referenced objects",
             "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:12:34+00:00"
         },
         {
@@ -10714,12 +10442,6 @@
             ],
             "description": "Allows reflection of object attributes, including inherited and non-public ones",
             "homepage": "https://github.com/sebastianbergmann/object-reflector/",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:14:26+00:00"
         },
         {
@@ -10773,12 +10495,6 @@
             ],
             "description": "Provides functionality to recursively process PHP variables",
             "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:17:30+00:00"
         },
         {
@@ -10824,12 +10540,6 @@
             ],
             "description": "Provides a list of PHP built-in functions that operate on resources",
             "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T06:45:17+00:00"
         },
         {
@@ -10876,12 +10586,6 @@
             ],
             "description": "Collection of value objects that represent the types of the PHP type system",
             "homepage": "https://github.com/sebastianbergmann/type",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-10-26T13:18:59+00:00"
         },
         {
@@ -10925,12 +10629,6 @@
             ],
             "description": "Library that helps with managing the version number of Git-hosted PHP projects",
             "homepage": "https://github.com/sebastianbergmann/version",
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                }
-            ],
             "time": "2020-09-28T06:39:44+00:00"
         },
         {
@@ -10971,12 +10669,6 @@
                 }
             ],
             "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
-            "funding": [
-                {
-                    "url": "https://github.com/theseer",
-                    "type": "github"
-                }
-            ],
             "time": "2020-07-12T23:59:07+00:00"
         },
         {
diff --git a/filemanager/inc/class.filemanager_hooks.inc.php b/filemanager/inc/class.filemanager_hooks.inc.php
index f13a16696b..2ea5bd1b03 100644
--- a/filemanager/inc/class.filemanager_hooks.inc.php
+++ b/filemanager/inc/class.filemanager_hooks.inc.php
@@ -171,49 +171,19 @@ class filemanager_hooks
 			),
 		);
 
-		$settings[Api\Storage\Merge::PREF_STORE_LOCATION] = array(
-			'type'  => 'vfs_dir',
-			'size'  => 60,
-			'label' => 'Directory for storing merged documents',
-			'name'  => Api\Storage\Merge::PREF_STORE_LOCATION,
-			'help'  => lang('When you merge entries into documents, they will be stored here.  If no directory is provided, they will be stored in %1', Vfs::get_home_dir())
-		);
-		$settings['default_document'] = array(
-			'type'     => 'vfs_file',
-			'size'     => 60,
-			'label'    => 'Default document to insert entries',
-			'name'     => 'default_document',
-			'help'     => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.', lang('filemanager')) . ' ' .
-				lang('The document can contain placeholder like {{%1}}, to be replaced with the data.', 'name') . ' ' .
-				lang('The following document-types are supported:') . implode(',', Api\Storage\Merge::get_file_extensions()),
-			'run_lang' => false,
-			'xmlrpc'   => True,
-			'admin'    => False,
-		);
-		$settings['document_dir'] = array(
-			'type'   => 'vfs_dirs',
-			'size'   => 60,
-			'label'  => 'Directory with documents to insert entries',
-			'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 %1 data inserted.', lang('filemanager')).' '.
-				lang('The document can contain placeholder like {{%1}}, to be replaced with the data.','name').' '.
-				lang('The following document-types are supported:'). implode(',',Api\Storage\Merge::get_file_extensions()),
-			'run_lang' => false,
-			'xmlrpc' => True,
-			'admin'  => False,
-			'default' => '/templates/filemanager',
-		);
+		$merge = new filemanager_merge();
+		$settings += $merge->merge_preferences();
 
 		$editorLink = self::getEditorLink();
 		$mimes = array('0' => lang('None'));
 
-		foreach ((array)$editorLink['mime'] as $mime => $value)
+		foreach((array)$editorLink['mime'] as $mime => $value)
 		{
-			$mimes[$mime] = lang('%1 file', strtoupper($value['ext'])).' ('.$mime.')';
+			$mimes[$mime] = lang('%1 file', strtoupper($value['ext'])) . ' (' . $mime . ')';
 
-			if (!empty($value['extra_extensions']))
+			if(!empty($value['extra_extensions']))
 			{
-				$mimes[$mime] .= ', '.strtoupper(implode(', ', $value['extra_extensions']));
+				$mimes[$mime] .= ', ' . strtoupper(implode(', ', $value['extra_extensions']));
 			}
 		}
 
diff --git a/filemanager/inc/class.filemanager_merge.inc.php b/filemanager/inc/class.filemanager_merge.inc.php
index f19e73a2a6..af43538563 100644
--- a/filemanager/inc/class.filemanager_merge.inc.php
+++ b/filemanager/inc/class.filemanager_merge.inc.php
@@ -26,15 +26,14 @@ class filemanager_merge extends Api\Storage\Merge
 	 * @var array
 	 */
 	var $public_functions = array(
-		'show_replacements'		=> true,
-		'merge_entries'		=> true
+		'show_replacements' => true,
+		'merge_entries'     => true
 	);
 
 	/**
 	 * Fields that are numeric, for special numeric handling
 	 */
-	protected $numeric_fields = array(
-	);
+	protected $numeric_fields = array();
 
 	/**
 	 * Fields that are dates or timestamps
@@ -74,12 +73,12 @@ class filemanager_merge extends Api\Storage\Merge
 	 * Get replacements
 	 *
 	 * @param int $id id of entry
-	 * @param string &$content=null content to create some replacements only if they are use
+	 * @param string &$content =null content to create some replacements only if they are use
 	 * @return array|boolean
 	 */
-	protected function get_replacements($id,&$content=null)
+	protected function get_replacements($id, &$content = null)
 	{
-		if (!($replacements = $this->filemanager_replacements($id, '', $content)))
+		if(!($replacements = $this->filemanager_replacements($id, '', $content)))
 		{
 			return false;
 		}
@@ -90,58 +89,58 @@ class filemanager_merge extends Api\Storage\Merge
 	 * Get filemanager replacements
 	 *
 	 * @param int $id id (vfs path) of entry
-	 * @param string $prefix='' prefix like eg. 'erole'
+	 * @param string $prefix ='' prefix like eg. 'erole'
 	 * @return array|boolean
 	 */
-	public function filemanager_replacements($id,$prefix='', &$content = null)
+	public function filemanager_replacements($id, $prefix = '', &$content = null)
 	{
 		$info = array();
-		$file = Vfs::lstat($id,true);
+		$file = Vfs::lstat($id, true);
 
 		$file['mtime'] = Api\DateTime::to($file['mtime']);
 		$file['ctime'] = Api\DateTime::to($file['ctime']);
 
 		$file['name'] = Vfs::basename($id);
 		$file['dir'] = ($dir = Vfs::dirname($id)) ? Vfs::decodePath($dir) : '';
-		$dirlist = explode('/',$file['dir']);
+		$dirlist = explode('/', $file['dir']);
 		$file['folder'] = array_pop($dirlist);
-		$file['folder_file'] = $file['folder'] . '/'.$file['name'];
+		$file['folder_file'] = $file['folder'] . '/' . $file['name'];
 		$file['path'] = $id;
-		$file['rel_path'] = str_replace($this->dir.'/', '', $id);
+		$file['rel_path'] = str_replace($this->dir . '/', '', $id);
 		$file['hsize'] = Vfs::hsize($file['size']);
 		$file['mime'] = Vfs::mime_content_type($id);
 		$file['gid'] *= -1;  // our widgets use negative gid's
-		if (($props = Vfs::propfind($id)))
+		if(($props = Vfs::propfind($id)))
 		{
 			foreach($props as $prop)
 			{
 				$file[$prop['name']] = $prop['val'];
 			}
 		}
-		if (($file['is_link'] = Vfs::is_link($id)))
+		if(($file['is_link'] = Vfs::is_link($id)))
 		{
 			$file['symlink'] = Vfs::readlink($id);
 		}
 		// Custom fields
 		if($content && strpos($content, '#') !== 0)
-                {
+		{
 			// Expand link-to custom fields
-			 $this->cf_link_to_expand($file, $content, $info);
+			$this->cf_link_to_expand($file, $content, $info);
 
 			foreach(Api\Storage\Customfields::get('filemanager') as $name => $field)
 			{
 				// Set any missing custom fields, or the marker will stay
-				if(!$file['#'.$name])
+				if(!$file['#' . $name])
 				{
-					$file['#'.$name] = '';
+					$file['#' . $name] = '';
 					continue;
 				}
 
 				// Format date cfs per user Api\Preferences
 				if($field['type'] == 'date' || $field['type'] == 'date-time')
 				{
-					$this->date_fields[] = '#'.$name;
-					$file['#'.$name] = Api\DateTime::to($file['#'.$name], $field['type'] == 'date' ? true : '');
+					$this->date_fields[] = '#' . $name;
+					$file['#' . $name] = Api\DateTime::to($file['#' . $name], $field['type'] == 'date' ? true : '');
 				}
 			}
 		}
@@ -150,17 +149,19 @@ class filemanager_merge extends Api\Storage\Merge
 		if($dirlist[1] == 'apps' && count($dirlist) > 1)
 		{
 			// Try this first - a normal path /apps/appname/id/file
-			list($app, $app_id) = explode('/', substr($file['path'], strpos($file['path'], 'apps/')+5));
+			list($app, $app_id) = explode('/', substr($file['path'], strpos($file['path'], 'apps/') + 5));
 			// Symlink?
-			if(!$app || !(int)$app_id || !array_key_exists($app, $GLOBALS['egw_info']['user']['apps'])) {
+			if(!$app || !(int)$app_id || !array_key_exists($app, $GLOBALS['egw_info']['user']['apps']))
+			{
 				// Try resolving just app + ID - /apps/App Name/Record Title/file
-				$resolved = Vfs::resolve_url_symlinks(implode('/',array_slice(explode('/',$file['dir']),0,4)));
-				list($app, $app_id) = explode('/', substr($resolved, strpos($resolved, 'apps/')+5));
+				$resolved = Vfs::resolve_url_symlinks(implode('/', array_slice(explode('/', $file['dir']), 0, 4)));
+				list($app, $app_id) = explode('/', substr($resolved, strpos($resolved, 'apps/') + 5));
 
-				if(!$app || !(int)$app_id || !array_key_exists($app, $GLOBALS['egw_info']['user']['apps'])) {
+				if(!$app || !(int)$app_id || !array_key_exists($app, $GLOBALS['egw_info']['user']['apps']))
+				{
 					// Get rid of any virtual folders (eg: All$) and symlinks
 					$resolved = Vfs::resolve_url_symlinks($file['path']);
-					list($app, $app_id) = explode('/', substr($resolved, strpos($resolved, 'apps/')+5));
+					list($app, $app_id) = explode('/', substr($resolved, strpos($resolved, 'apps/') + 5));
 				}
 			}
 			if($app && $app_id)
@@ -170,7 +171,7 @@ class filemanager_merge extends Api\Storage\Merge
 					$app_merge = null;
 					try
 					{
-						$classname = $app .'_merge';
+						$classname = $app . '_merge';
 						if(class_exists($classname))
 						{
 							$app_merge = new $classname();
@@ -180,9 +181,10 @@ class filemanager_merge extends Api\Storage\Merge
 							}
 						}
 					}
-					// Silently discard & continue
-					catch(Exception $e) {
-						unset($e);	// not used
+						// Silently discard & continue
+					catch (Exception $e)
+					{
+						unset($e);    // not used
 					}
 				}
 			}
@@ -211,7 +213,7 @@ class filemanager_merge extends Api\Storage\Merge
 		foreach($file as $key => &$value)
 		{
 			if(!$value) $value = '';
-			$info['$$'.($prefix ? $prefix.'/':'').$key.'$$'] = $value;
+			$info['$$' . ($prefix ? $prefix . '/' : '') . $key . '$$'] = $value;
 		}
 		if($app_placeholders)
 		{
@@ -239,14 +241,18 @@ class filemanager_merge extends Api\Storage\Merge
 		{
 			return $session;
 		}
-		else if (($session = \EGroupware\Api\Cache::getSession(Api\Sharing::class, "$app::$id")) &&
-				substr($session['share_path'], -strlen($path)) === $path)
+		else
 		{
-			return $session;
+			if(($session = \EGroupware\Api\Cache::getSession(Api\Sharing::class, "$app::$id")) &&
+				substr($session['share_path'], -strlen($path)) === $path)
+			{
+				return $session;
+			}
 		}
 		// Need to create the share here.
 		// No way to know here if it should be writable, or who it's going to
-		$mode = /* ?  ? Sharing::WRITABLE :*/ Api\Sharing::READONLY;
+		$mode = /* ?  ? Sharing::WRITABLE :*/
+			Api\Sharing::READONLY;
 		$recipients = array();
 		$extra = array();
 
@@ -254,72 +260,59 @@ class filemanager_merge extends Api\Storage\Merge
 	}
 
 	/**
-	 * Generate table with replacements for the Api\Preferences
+	 * Hook for extending apps to customise the replacements UI without having to override the whole method
 	 *
+	 * @param string $template_name
+	 * @param $content
+	 * @param $sel_options
+	 * @param $readonlys
 	 */
-	public function show_replacements()
+	protected function show_replacements_hook(&$template_name, &$content, &$sel_options, &$readonlys)
 	{
-		$GLOBALS['egw_info']['flags']['app_header'] = lang('filemanager').' - '.lang('Replacements for inserting entries into documents');
-		$GLOBALS['egw_info']['flags']['nonavbar'] = false;
-		echo $GLOBALS['egw']->framework->header();
+		$content['extra_template'] = 'filemanager.replacements';
+	}
 
-		echo "<table width='90%' align='center'>\n";
-		echo '<tr><td colspan="4"><h3>'.lang('Filemanager fields:')."</h3></td></tr>";
+	/**
+	 * Get a list of placeholders provided.
+	 *
+	 * Placeholders are grouped logically.  Group key should have a user-friendly translation.
+	 */
+	public function get_placeholder_list($prefix = '')
+	{
+		$placeholders = parent::get_placeholder_list($prefix);
 
-		$n = 0;
 		$fields = array(
-			'name' => 'name',
-			'path' => 'Absolute path',
-			'rel_path' => 'Path relative to current directory',
-			'folder' => 'Containing folder',
+			'name'        => 'name',
+			'path'        => 'Absolute path',
+			'rel_path'    => 'Path relative to current directory',
+			'folder'      => 'Containing folder',
 			'folder_file' => 'Containing folder and file name',
-			'url' => 'url',
-			'webdav_url' => 'External path using webdav',
-			'link' => 'Clickable link to file',
-			'comment' => 'comment',
-			'mtime' => 'modified',
-			'ctime' => 'created',
-			'mime'	=> 'Type',
-			'hsize' => 'Size',
-			'size' => 'Size (in bytes)',
+			'url'         => 'url',
+			'webdav_url'  => 'External path using webdav',
+			'link'        => 'Clickable link to file',
+			'comment'     => 'comment',
+			'mtime'       => 'modified',
+			'ctime'       => 'created',
+			'mime'        => 'Type',
+			'hsize'       => 'Size',
+			'size'        => 'Size (in bytes)',
 		);
+		$group = 'placeholders';
 		foreach($fields as $name => $label)
 		{
-			if (!($n&1)) echo '<tr>';
-			echo '<td>{{'.$name.'}}</td><td>'.lang($label).'</td>';
-			if ($n&1) echo "</tr>\n";
-			$n++;
+			$marker = $this->prefix($prefix, $name, '{');
+			if(!array_filter($placeholders, function ($a) use ($marker)
+			{
+				return array_key_exists($marker, $a);
+			}))
+			{
+				$placeholders[$group][] = [
+					'value' => $marker,
+					'label' => $label
+				];
+			}
 		}
 
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
-		foreach(Api\Storage\Customfields::get('filemanager') as $name => $field)
-		{
-			echo '<tr><td>{{#'.$name.'}}</td><td colspan="3">'.$field['label']."</td></tr>\n";
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Application fields').":</h3></td></tr>";
-		echo '<tr><td colspan="4">'.lang('For files linked to an application entry (inside /apps/appname/id/) the placeholders for that application are also available.  See the specific application for a list of available placeholders.').'</td></tr>';
-
-		echo '<tr><td colspan="4"><h3>'.lang('General fields:')."</h3></td></tr>";
-		foreach(array(
-			'date' => lang('Date'),
-			'user/n_fn' => lang('Name of current user, all other contact fields are valid too'),
-			'user/account_lid' => lang('Username'),
-			'pagerepeat' => lang('For serial letter use this tag. Put the content, you want to repeat between two Tags.'),
-			'label' => lang('Use this tag for addresslabels. Put the content, you want to repeat, between two tags.'),
-			'labelplacement' => lang('Tag to mark positions for address labels'),
-			'IF fieldname' => lang('Example {{IF n_prefix~Mr~Hello Mr.~Hello Ms.}} - search the field "n_prefix", for "Mr", if found, write Hello Mr., else write Hello Ms.'),
-			'NELF' => lang('Example {{NELF role}} - if field role is not empty, you will get a new line with the value of field role'),
-			'NENVLF' => lang('Example {{NENVLF role}} - if field role is not empty, set a LF without any value of the field'),
-			'LETTERPREFIX' => lang('Example {{LETTERPREFIX}} - Gives a letter prefix without double spaces, if the title is empty for example'),
-			'LETTERPREFIXCUSTOM' => lang('Example {{LETTERPREFIXCUSTOM n_prefix title n_family}} - Example: Mr Dr. James Miller'),
-			) as $name => $label)
-		{
-			echo '<tr><td>{{'.$name.'}}</td><td colspan="3">'.$label."</td></tr>\n";
-		}
-
-		echo "</table>\n";
-
-		echo $GLOBALS['egw']->framework->footer();
+		return $placeholders;
 	}
 }
diff --git a/filemanager/inc/class.filemanager_ui.inc.php b/filemanager/inc/class.filemanager_ui.inc.php
index df5ca6eca1..c8eb9bda2d 100644
--- a/filemanager/inc/class.filemanager_ui.inc.php
+++ b/filemanager/inc/class.filemanager_ui.inc.php
@@ -684,7 +684,7 @@ class filemanager_ui
 	 */
 	static public function action($action,$selected,$dir=null,&$errs=null,&$files=null,&$dirs=null)
 	{
-		if (!count($selected))
+		if (!count((array)$selected))
 		{
 			return lang('You need to select some files first!');
 		}
diff --git a/filemanager/templates/default/replacements.xet b/filemanager/templates/default/replacements.xet
new file mode 100644
index 0000000000..3727c045e9
--- /dev/null
+++ b/filemanager/templates/default/replacements.xet
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2//EN" "http://www.egroupware.org/etemplate2.dtd">
+<!-- This template adds the extra bits to the replacements list UI -->
+<overlay>
+    <template id="filemanager.replacements">
+        <vbox>
+            <description class="title" value="Application fields"/>
+            <description
+                    value="For files linked to an application entry (inside /apps/appname/id/) the placeholders for that application are also available.  See the specific application for a list of available placeholders."/>
+        </vbox>
+    </template>
+</overlay>
\ No newline at end of file
diff --git a/importexport/inc/class.importexport_definitions_bo.inc.php b/importexport/inc/class.importexport_definitions_bo.inc.php
index 2707070e95..62bf782460 100644
--- a/importexport/inc/class.importexport_definitions_bo.inc.php
+++ b/importexport/inc/class.importexport_definitions_bo.inc.php
@@ -126,7 +126,10 @@ class importexport_definitions_bo {
 			$definition = $this->read($key);
 			if($definition['owner'] && $definition['owner'] == $GLOBALS['egw_info']['user']['account_id'] || $GLOBALS['egw_info']['user']['apps']['admin']) {
 				// clear private cache
-				unset($this->definitions[array_search($key,$this->definitions)]);
+				if(is_array($this->definitions))
+				{
+					unset($this->definitions[array_search($key, $this->definitions)]);
+				}
 			} else {
 				unset($keys[$index]);
 			}
diff --git a/importexport/inc/class.importexport_export_csv.inc.php b/importexport/inc/class.importexport_export_csv.inc.php
index 5135916fbd..aa1cdaa7e1 100644
--- a/importexport/inc/class.importexport_export_csv.inc.php
+++ b/importexport/inc/class.importexport_export_csv.inc.php
@@ -279,7 +279,7 @@ class importexport_export_csv implements importexport_iface_export_record
 					}
 					// Fall through for other settings
 				case 'select':
-					if (count($c_field['values']) == 1 && isset($c_field['values']['@']))
+					if (!empty($c_field['values']) && count($c_field['values']) == 1 && isset($c_field['values']['@']))
 					{
 						$c_field['values'] = Api\Storage\Customfields::get_options_from_file($c_field['values']['@']);
 					}
diff --git a/importexport/inc/class.importexport_wizard_basic_export_csv.inc.php b/importexport/inc/class.importexport_wizard_basic_export_csv.inc.php
index 68b6e5c8b8..39442db9b7 100644
--- a/importexport/inc/class.importexport_wizard_basic_export_csv.inc.php
+++ b/importexport/inc/class.importexport_wizard_basic_export_csv.inc.php
@@ -183,28 +183,37 @@ class importexport_wizard_basic_export_csv
 			$content['step'] = 'wizard_step40';
 
 			// If editing an existing definition, these will be in plugin_options
-			if(!$content['delimiter'] && $content['plugin_options']['delimiter']) {
+			if(!$content['delimiter'] && $content['plugin_options']['delimiter'])
+			{
 				$content['delimiter'] = $content['plugin_options']['delimiter'];
-			} elseif (!$content['delimiter']) {
+			}
+			elseif(!$content['delimiter'])
+			{
 				$content['delimiter'] = ';';
 			}
-			if(!$content['charset'] && $content['plugin_options']['charset']) {
+			if(!$content['charset'] && $content['plugin_options']['charset'])
+			{
 				$content['charset'] = $content['plugin_options']['charset'] ? $content['plugin_options']['charset'] : 'user';
 			}
-			if(!array_key_exists('begin_with_fieldnames', $content) && array_key_exists('begin_with_fieldnames', $content['plugin_options'])) {
+			if(!array_key_exists('begin_with_fieldnames', $content) &&
+				is_array($content['plugin_options']) &&
+				array_key_exists('begin_with_fieldnames', $content['plugin_options']))
+			{
 				$content['begin_with_fieldnames'] = $content['plugin_options']['begin_with_fieldnames'];
 			}
-			if(!array_key_exists('convert', $content) && array_key_exists('convert', $content['plugin_options'])) {
+			if(!array_key_exists('convert', $content) &&
+				is_array($content['plugin_options']) && array_key_exists('convert', $content['plugin_options']))
+			{
 				$content['convert'] = $content['plugin_options']['convert'];
 			}
 
 
 			$sel_options['begin_with_fieldnames'] = array(
-				0	=> lang('No'),
-				1	=> lang('Field names'),
-				'label'	=> lang('Field labels')
+				0       => lang('No'),
+				1       => lang('Field names'),
+				'label' => lang('Field labels')
 			);
-			$sel_options['charset'] = Api\Translation::get_installed_charsets()+
+			$sel_options['charset'] = Api\Translation::get_installed_charsets() +
 			array(
                                 'user'  => lang('User preference'),
                         );
@@ -273,12 +282,19 @@ class importexport_wizard_basic_export_csv
 			unset ($preserv['button']);
 
 			$content['set_filter']['fields'] = importexport_helper_functions::get_filter_fields(
-				$content['application'],$content['plugin'],$this
+				$content['application'], $content['plugin'], $this
 			);
 			// Load existing filter from either content or definition
+			if(!array_key_exists('filter', $content) || !is_array($content['filter']))
+			{
+				$content['filter'] = [];
+			}
 			foreach($content['set_filter']['fields'] as $field => $settings)
 			{
-				$content['set_filter'][$field] = $content['filter'][$field];
+				if(array_key_exists($field, $content['filter']))
+				{
+					$content['set_filter'][$field] = $content['filter'][$field];
+				}
 			}
 
 			if(!$content['set_filter']['fields'])
diff --git a/infolog/inc/class.infolog_bo.inc.php b/infolog/inc/class.infolog_bo.inc.php
index 9bcfd37f3c..8c11fa8ce5 100644
--- a/infolog/inc/class.infolog_bo.inc.php
+++ b/infolog/inc/class.infolog_bo.inc.php
@@ -228,7 +228,7 @@ class infolog_bo
 			{
 				foreach(array_keys($config_data['status']) as $key)
 				{
-					if (!is_array($this->status[$key]))
+					if (!isset($this->status[$key]) || !is_array($this->status[$key]))
 					{
 						$this->status[$key] = array();
 					}
@@ -262,17 +262,17 @@ class infolog_bo
 						$save_config = true;
 					}
 				}
-				if ($save_config) Api\Config::save_value('customfields',$this->customfields,'infolog');
+				if (!empty($save_config)) Api\Config::save_value('customfields',$this->customfields,'infolog');
 			}
-			if (is_array($config_data['responsible_edit']))
+			if (isset($config_data['responsible_edit']) && is_array($config_data['responsible_edit']))
 			{
 				$this->responsible_edit = array_merge($this->responsible_edit,$config_data['responsible_edit']);
 			}
-			if (is_array($config_data['copy_excludefields']))
+			if (isset($config_data['copy_excludefields']) && is_array($config_data['copy_excludefields']))
 			{
 				$this->copy_excludefields = array_merge($this->copy_excludefields,$config_data['copy_excludefields']);
 			}
-			if (is_array($config_data['sub_excludefields']) && $config_data['sub_excludefields'])
+			if (!empty($config_data['sub_excludefields']) && is_array($config_data['sub_excludefields']))
 			{
 				$this->sub_excludefields = array_merge($this->sub_excludefields,$config_data['sub_excludefields']);
 			}
@@ -286,7 +286,7 @@ class infolog_bo
 			}
 			$this->history = $config_data['history'];
 
-			$this->limit_modified_n_month = $config_data['limit_modified_n_month'];
+			$this->limit_modified_n_month = $config_data['limit_modified_n_month'] ?? null;
 		}
 		// sort types by there translation
 		foreach($this->enums['type'] as $key => $val)
@@ -629,12 +629,14 @@ class infolog_bo
 
 		if (!$info_id || ($data = $this->so->read($info_id)) === False)
 		{
-			return null;
+			$null = null;
+			return $null;
 		}
 
 		if (!$ignore_acl && !$this->check_access($data,Acl::READ))	// check behind read, to prevent a double read
 		{
-			return False;
+			$false = False;
+			return $false;
 		}
 
 		if ($data['info_subject'] == $this->subject_from_des($data['info_des']))
@@ -1092,10 +1094,14 @@ class infolog_bo
 	 * Checks for info_contact properly linked, project properly linked and
 	 * adds or removes to correct.
 	 *
-	 * @param Array $values
+	 * @param array $values
 	 */
-	protected function write_check_links(&$values)
+	protected function write_check_links(array &$values)
 	{
+		if(!$this->check_access($values, Acl::EDIT))
+		{
+			return;
+		}
 		$old_link_id = (int)$values['info_link_id'];
 		$from = $values['info_from'];
 
@@ -1104,7 +1110,7 @@ class infolog_bo
 			) || (
 				is_array($values['info_contact']) && $values['info_contact']['id'] == 'none' &&
 				array_key_exists('search', $values['info_contact'])
-		))
+			))
 		{
 			if(is_array($values['info_contact']))
 			{
@@ -1113,7 +1119,7 @@ class infolog_bo
 				$id = (int)$values['info_contact']['id'];
 				$from = $values['info_contact']['search'];
 			}
-			else if ($values['info_contact'])
+			else if($values['info_contact'])
 			{
 				list($app, $id) = explode(':', $values['info_contact'], 2);
 			}
diff --git a/infolog/inc/class.infolog_egw_record.inc.php b/infolog/inc/class.infolog_egw_record.inc.php
index b389e3c626..7deb2b15f7 100644
--- a/infolog/inc/class.infolog_egw_record.inc.php
+++ b/infolog/inc/class.infolog_egw_record.inc.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * eGroupWare - Infolog - importexport
+ * EGroupware - InfoLog - importexport
  *
  * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
  * @package infolog
@@ -8,13 +8,12 @@
  * @link http://www.egroupware.org
  * @author Nathan Gray
  * @copyright Nathan Gray
- * @version $Id$
  */
 
 /**
  * class infolog_egw_record
  *
- * compability layer for iface_egw_record needet for importexport
+ * compatibility layer for iface_egw_record needed for importexport
  */
 class infolog_egw_record implements importexport_iface_egw_record
 {
@@ -53,7 +52,7 @@ class infolog_egw_record implements importexport_iface_egw_record
 	 * @param string $_attribute_name
 	 */
 	public function __get($_attribute_name) {
-		return $this->record[$_attribute_name];
+		return $this->record[$_attribute_name] ?? null;
 	}
 
 	/**
diff --git a/infolog/inc/class.infolog_hooks.inc.php b/infolog/inc/class.infolog_hooks.inc.php
index e09c7d23d1..dc81b81267 100644
--- a/infolog/inc/class.infolog_hooks.inc.php
+++ b/infolog/inc/class.infolog_hooks.inc.php
@@ -458,41 +458,8 @@ class infolog_hooks
 		// Merge print
 		if ($GLOBALS['egw_info']['user']['apps']['filemanager'])
 		{
-			$settings['default_document'] = array(
-				'type'   => 'vfs_file',
-				'size'   => 60,
-				'label'  => 'Default document to insert entries',
-				'name'   => 'default_document',
-				'help'   => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.',lang('infolog')).' '.
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.','info_subject').' '.
-					lang('The following document-types are supported:').'*.rtf, *.txt',
-				'run_lang' => false,
-				'xmlrpc' => True,
-				'admin'  => False,
-			);
-			$settings['document_dir'] = array(
-				'type'     => 'vfs_dirs',
-				'size'     => 60,
-				'label'    => 'Directory with documents to insert entries',
-				'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('infolog')) . ' ' .
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.', 'info_subject') . ' ' .
-					lang('The following document-types are supported:') . '*.rtf, *.txt',
-				'run_lang' => false,
-				'xmlrpc'   => True,
-				'admin'    => False,
-				'default'  => '/templates/infolog',
-			);
-			$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',
-			);
+			$merge = new infolog_merge();
+			$settings += $merge->merge_preferences();
 		}
 
 		if ($GLOBALS['egw_info']['user']['apps']['calendar'])
diff --git a/infolog/inc/class.infolog_merge.inc.php b/infolog/inc/class.infolog_merge.inc.php
index c53c3370bc..c36a43d16f 100644
--- a/infolog/inc/class.infolog_merge.inc.php
+++ b/infolog/inc/class.infolog_merge.inc.php
@@ -138,7 +138,7 @@ class infolog_merge extends Api\Storage\Merge
 		// Set any missing custom fields, or the marker will stay
 		foreach($this->bo->customfields as $name => $field)
 		{
-			if(!$array['#'.$name])
+			if (empty($array['#'.$name]))
 			{
 				$array['#'.$name] = '';
 			}
@@ -183,9 +183,9 @@ class infolog_merge extends Api\Storage\Merge
 		$info += $this->get_all_links('infolog', $id, $prefix, $content);
 
 		// Add contact fields
-		if($array['info_link'] && $array['info_link']['app'] && $array['info_link']['id'])
+		if($array['info_link'] && is_array($array['info_link']) && $array['info_link']['app'] && $array['info_link']['id'])
 		{
-			$info+=$this->get_app_replacements($array['info_link']['app'], $array['info_link']['id'], $content, 'info_contact');
+			$info += $this->get_app_replacements($array['info_link']['app'], $array['info_link']['id'], $content, 'info_contact');
 		}
 		// Add owner fields
 		$info += $this->contact_replacements(Api\Accounts::id2name($info_owner,'person_id'),'info_owner');
@@ -198,98 +198,20 @@ class infolog_merge extends Api\Storage\Merge
 		return $info;
 	}
 
-	/**
-	 * Generate table with replacements for the Api\Preferences
-	 *
-	 */
-	public function show_replacements()
-	{
-		$GLOBALS['egw_info']['flags']['app_header'] = lang('infolog').' - '.lang('Replacements for inserting entries into documents');
-		$GLOBALS['egw_info']['flags']['nonavbar'] = false;
-		echo $GLOBALS['egw']->framework->header();
-
-		echo "<table width='90%' align='center'>\n";
-		echo '<tr><td colspan="4"><h3>'.lang('Infolog fields:')."</h3></td></tr>";
-
-		$n = 0;
-		$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');
-		foreach($fields as $name => $label)
-		{
-			if (in_array($name,array('custom'))) continue;	// dont show them
-
-			if (in_array($name,array('info_subject', 'info_des')) && $n&1)		// main values, which should be in the first column
-			{
-				echo "</tr>\n";
-				$n++;
-			}
-			if (!($n&1)) echo '<tr>';
-			echo '<td>{{'.$name.'}}</td><td>'.lang($label).'</td>';
-			if ($n&1) echo "</tr>\n";
-			$n++;
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
-		$contact_custom = false;
-		foreach($this->bo->customfields as $name => $field)
-		{
-			echo '<tr><td>{{#'.$name.'}}</td><td colspan="3">'.$field['label'].($field['type'] == 'select-account' ? '*':'')."</td></tr>\n";
-			if($field['type'] == 'select-account') $contact_custom = true;
-		}
-		if($contact_custom)
-		{
-			echo '<tr><td /><td colspan="3">* '.lang('Addressbook placeholders available'). '</td></tr>';
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Parent').":</h3></td></tr>";
-		echo '<tr><td>{{info_id_parent/info_subject}}</td><td colspan="3">'.lang('All other %1 fields are valid',lang('infolog'))."</td></tr>\n";
-
-		echo '<tr><td colspan="4"><h3>'.lang('Contact fields').':</h3></td></tr>';
-		$i = 0;
-		foreach($this->contacts->contact_fields as $name => $label)
-		{
-			if (in_array($name,array('tid','label','geo'))) continue;       // dont show them, as they are not used in the UI atm.
-
-			if (in_array($name,array('email','org_name','tel_work','url')) && $n&1)         // main values, which should be in the first column
-			{
-					echo "</tr>\n";
-					$i++;
-			}
-			if (!($i&1)) echo '<tr>';
-			echo '<td>{{info_contact/'.$name.'}}</td><td>'.$label.'</td>';
-			if ($i&1) echo "</tr>\n";
-			$i++;
-		}
-		echo '<tr><td colspan="4">'.lang('Owner contact fields also available under info_owner/...').'</td></tr>';
-
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
-		foreach($this->contacts->customfields as $name => $field)
-		{
-			echo '<tr><td>{{info_contact/#'.$name.'}}</td><td colspan="3">'.$field['label']."</td></tr>\n";
-		}
-
-		echo '<tr><td colspan="4"><h3>' . lang('General fields:') . "</h3></td></tr>";
-		foreach($this->get_common_replacements() as $name => $label)
-		{
-			echo '<tr><td>{{' . $name . '}}</td><td colspan="3">' . $label . "</td></tr>\n";
-		}
-
-		echo "</table>\n";
-
-		echo $GLOBALS['egw']->framework->footer();
-	}
-
 	public function get_placeholder_list($prefix = '')
 	{
-		$placeholders = parent::get_placeholder_list($prefix);
-
 		$tracking = new infolog_tracking($this->bo);
+		$placeholders = array(
+				'infolog'                                 => [],
+				lang('parent')                            => [],
+				lang($tracking->field2label['info_from']) => []
+			) + parent::get_placeholder_list($prefix);
+
 		$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';
+		$group = 'infolog';
 		foreach($fields as $name => $label)
 		{
 			if(in_array($name, array('custom')))
@@ -310,15 +232,27 @@ class infolog_merge extends Api\Storage\Merge
 			}
 		}
 
-		// Add contact placeholders
-		$insert_index = 1;
-		$placeholders = array_slice($placeholders, 0, $insert_index, true) +
-			[lang($tracking->field2label['info_from']) => []] +
-			array_slice($placeholders, $insert_index, count($placeholders) - $insert_index, true);
-		$contact_merge = new Api\Contacts\Merge();
-		$contact = $contact_merge->get_placeholder_list('info_contact');
-		$this->add_linked_placeholders($placeholders, lang($tracking->field2label['info_from']), $contact);
+		// Don't add any linked placeholders if we're not at the top level
+		// This avoids potential recursion
+		if(!$prefix)
+		{
+			// Add contact placeholders
+			$contact_merge = new Api\Contacts\Merge();
+			$contact = $contact_merge->get_placeholder_list($this->prefix($prefix, 'info_contact'));
+			$this->add_linked_placeholders($placeholders, lang($tracking->field2label['info_from']), $contact);
 
+			// Add parent placeholders
+			$this->add_linked_placeholders(
+				$placeholders,
+				lang('parent'),
+				$this->get_placeholder_list(($prefix ? $prefix . '/' : '') . 'info_id_parent')
+			);
+		}
+		else
+		{
+			unset($placeholders[lang('parent')]);
+			unset($placeholders[lang($tracking->field2label['info_from'])]);
+		}
 		return $placeholders;
 	}
 }
diff --git a/infolog/inc/class.infolog_tracking.inc.php b/infolog/inc/class.infolog_tracking.inc.php
index 69c3a810c0..23f3cfb302 100644
--- a/infolog/inc/class.infolog_tracking.inc.php
+++ b/infolog/inc/class.infolog_tracking.inc.php
@@ -144,7 +144,7 @@ class infolog_tracking extends Api\Storage\Tracking
 	 */
 	function get_subject($data, $old, $deleted = null, $receiver = null)
 	{
-		if ($data['prefix'])
+		if (!empty($data['prefix']))
 		{
 			$prefix = $data['prefix'];	// async notification
 		}
@@ -172,7 +172,7 @@ class infolog_tracking extends Api\Storage\Tracking
 	 */
 	function get_message($data, $old, $receiver = null)
 	{
-		if ($data['message']) return $data['message'];	// async notification
+		if (!empty($data['message'])) return $data['message'];	// async notification
 
 		if (!$old || $old['info_status'] == 'deleted')
 		{
@@ -345,16 +345,16 @@ class infolog_tracking extends Api\Storage\Tracking
 					return '';
 				}
 				// Per-type notification
-				$type_config = $info_config[self::CUSTOM_NOTIFICATION][$data['info_type']];
+				$type_config = $info_config[self::CUSTOM_NOTIFICATION][$data['info_type']] ?? null;
 				$global = $info_config[self::CUSTOM_NOTIFICATION]['~global~'];
 
 				// Disabled
-				if(!$type_config['use_custom'] && !$global['use_custom']) return '';
+				if(empty($type_config['use_custom']) && empty($global['use_custom'])) return '';
 
-				// Type or globabl
+				// Type or global
 				$config = trim(strip_tags($type_config['message'])) != '' && $type_config['use_custom'] ? $type_config['message'] : $global['message'];
 				break;
 		}
-		return $config;
+		return $config ?? null;
 	}
 }
diff --git a/infolog/inc/class.infolog_ui.inc.php b/infolog/inc/class.infolog_ui.inc.php
index 5e8673f56b..830748f0fc 100644
--- a/infolog/inc/class.infolog_ui.inc.php
+++ b/infolog/inc/class.infolog_ui.inc.php
@@ -1874,7 +1874,7 @@ class infolog_ui
 
 					$content['link_to']['to_app'] = 'infolog';
 					$content['link_to']['to_id'] = $info_id;
-
+					/* $info_link_id is never defined
 					if ($info_link_id && strpos($info_link_id,':') !== false)	// updating info_link_id if necessary
 					{
 						list($app,$id) = explode(':',$info_link_id);
@@ -1903,7 +1903,7 @@ class infolog_ui
 							// we need eg. the new modification date, for further updates
 							$content = array_merge($content,$to_write);
 						}
-					}
+					}*/
 
 					// Need to purge description history after encryption?
 					if($content['clean_history'])
@@ -2115,7 +2115,7 @@ class infolog_ui
 			// remove types owned by groups the user has no edit grant (current type is made readonly)
 			foreach($this->bo->group_owners as $type => $group)
 			{
-				if (!($this->bo->grants[$group] & Acl::EDIT))
+				if (!(($this->bo->grants[$group]??0) & Acl::EDIT))
 				{
 					if ($type == $content['info_type'])
 					{
@@ -2172,7 +2172,7 @@ class infolog_ui
 			$readonlys['action'] = true;
 		}
 		// ToDo: use the old status before the delete
-		if ($info_id && $undelete)
+		if ($info_id && !empty($undelete))
 		{
 			$content['info_status'] = $this->bo->status['defaults'][$content['info_type']];
 			$this->tmpl->setElementAttribute('button[save]', 'label', 'Un-Delete');
@@ -2187,7 +2187,7 @@ class infolog_ui
 		// use a typ-specific template (infolog.edit.xyz), if one exists, otherwise fall back to the generic one
 		if (!$this->tmpl->read('infolog.edit.'.$content['info_type']))
 		{
-			$this->tmpl->read($print ? 'infolog.edit.print':'infolog.edit');
+			$this->tmpl->read(!empty($print) ? 'infolog.edit.print' : 'infolog.edit');
 		}
 		if ($this->bo->has_customfields($content['info_type']))
 		{
@@ -2252,7 +2252,7 @@ class infolog_ui
 			$tracking = new infolog_tracking($this);
 			foreach($tracking->field2history as $field => $history)
 			{
-				$history_stati[$history] = $tracking->field2label[$field];
+				$history_stati[$history] = $tracking->field2label[$field] ?? null;
 			}
 			// Modified date removed from field2history, we don't need that in the history
 			$history_stati['Mo'] = $tracking->field2label['info_datemodified'];
@@ -2276,20 +2276,20 @@ class infolog_ui
 				'to_tracker' => array('label' => 'Tracker', 'title' => 'Convert to a ticket'),
 			),
 		);
-		if ($GLOBALS['egw_info']['user']['apps']['calendar'])
+		if (!empty($GLOBALS['egw_info']['user']['apps']['calendar']))
 		{
 			$sel_options['action']['schedule'] = array('label' => 'Schedule', 'title' => 'Schedule appointment');
 		}
-		if ($GLOBALS['egw_info']['user']['apps']['stylite'] && !$GLOBALS['egw_info']['server']['disable_pgp_encryption'])
+		if (!empty($GLOBALS['egw_info']['user']['apps']['stylite']) && empty($GLOBALS['egw_info']['server']['disable_pgp_encryption']))
 		{
 			$content['encryption_ts'] = filemtime(EGW_SERVER_ROOT.'/stylite/js/app.js');
 		}
-		elseif ($GLOBALS['egw_info']['server']['disable_pgp_encryption'])
+		elseif (!empty($GLOBALS['egw_info']['server']['disable_pgp_encryption']))
 		{
 			$readonlys['encrypt'] = true;
 		}
 		$GLOBALS['egw_info']['flags']['app_header'] = lang('InfoLog').' - '.
-			($content['status_only'] ? lang('Edit Status') : lang('Edit'));
+			(!empty($content['status_only']) ? lang('Edit Status') : lang('Edit'));
 		$GLOBALS['egw_info']['flags']['params']['manual'] = array('page' => ($info_id ? 'ManualInfologEdit' : 'ManualInfologAdd'));
 		//error_log(substr($content['info_des'],1793,10));
 		//$content['info_des'] = substr($content['info_des'],0,1793);
diff --git a/json.php b/json.php
index 74025d96ec..2229aa645c 100644
--- a/json.php
+++ b/json.php
@@ -60,6 +60,7 @@ function ajax_exception_handler($e)
 	$response = Json\Response::get();
 	$message .= ($message ? "\n\n" : '').$e->getMessage();
 
+	$message .= "\n\n".$e->getFile().' ('.$e->getLine().')';
 	// only show trace (incl. function arguments) if explicitly enabled, eg. on a development system
 	if ($GLOBALS['egw_info']['server']['exception_show_trace'])
 	{
diff --git a/mail/inc/class.mail_compose.inc.php b/mail/inc/class.mail_compose.inc.php
index 0a6078d3d4..af7d42e33d 100644
--- a/mail/inc/class.mail_compose.inc.php
+++ b/mail/inc/class.mail_compose.inc.php
@@ -216,7 +216,7 @@ class mail_compose
 
 		);
 		$acc_smime = Mail\Smime::get_acc_smime($content['mailaccount']);
-		if ($acc_smime['acc_smime_password'])
+		if ($acc_smime && !empty($acc_smime['acc_smime_password']))
 		{
 			$actions = array_merge($actions, array(
 				'smime_sign' => array (
@@ -271,9 +271,9 @@ class mail_compose
 			}
 			unset($actions['pgp']);
 		}
-		if ($GLOBALS['egw_info']['server']['disable_pgp_encryption']) unset($actions['pgp']);
+		if (!empty($GLOBALS['egw_info']['server']['disable_pgp_encryption'])) unset($actions['pgp']);
 		// remove vfs actions if the user has no run access to filemanager
-		if (!$GLOBALS['egw_info']['user']['apps']['filemanager'])
+		if (empty($GLOBALS['egw_info']['user']['apps']['filemanager']))
 		{
 			unset($actions['save2vfs']);
 			unset($actions['selectFromVFSForCompose']);
@@ -1242,16 +1242,16 @@ class mail_compose
 		// address stuff like from, to, cc, replyto
 		$destinationRows = 0;
 		foreach(self::$destinations as $destination) {
-			if (!is_array($content[$destination]))
+			if (!empty($content[$destination]) && !is_array($content[$destination]))
 			{
-				if (!empty($content[$destination])) $content[$destination] = (array)$content[$destination];
+				$content[$destination] = (array)$content[$destination];
 			}
-			$addr_content = $content[strtolower($destination)];
+			$addr_content = $content[strtolower($destination)] ?? [];
 			// we clear the given address array and rebuild it
 			unset($content[strtolower($destination)]);
-			foreach((array)$addr_content as $key => $value) {
-				if ($value=="NIL@NIL") continue;
-				if ($destination=='replyto' && str_replace('"','',$value) ==
+			foreach($addr_content as $value) {
+				if ($value === "NIL@NIL") continue;
+				if ($destination === 'replyto' && str_replace('"','',$value) ===
 					str_replace('"','',$identities[$this->mail_bo->getDefaultIdentity()]))
 				{
 					// preserve/restore the value to content.
@@ -1261,7 +1261,7 @@ class mail_compose
 				//error_log(__METHOD__.__LINE__.array2string(array('key'=>$key,'value'=>$value)));
 				$value = str_replace("\"\"",'"', htmlspecialchars_decode($value, ENT_COMPAT));
 				foreach(Mail::parseAddressList($value) as $addressObject) {
-					if ($addressObject->host == '.SYNTAX-ERROR.') continue;
+					if ($addressObject->host === '.SYNTAX-ERROR.') continue;
 					$address = imap_rfc822_write_address($addressObject->mailbox,$addressObject->host,$addressObject->personal);
 					//$address = Mail::htmlentities($address, $this->displayCharset);
 					$content[strtolower($destination)][]=$address;
@@ -1289,7 +1289,7 @@ class mail_compose
 		$content['mail_'.($content['mimeType'] == 'html'?'html':'plain').'text'] =$content['body'];
 		$content['showtempname']=0;
 		//if (is_array($content['attachments']))error_log(__METHOD__.__LINE__.'before merging content with uploadforCompose:'.array2string($content['attachments']));
-		$content['attachments']=(is_array($content['attachments'])&&is_array($content['uploadForCompose'])?array_merge($content['attachments'],(!empty($content['uploadForCompose'])?$content['uploadForCompose']:array())):(is_array($content['uploadForCompose'])?$content['uploadForCompose']:(is_array($content['attachments'])?$content['attachments']:null)));
+		$content['attachments'] = array_merge($content['attachments'] ?? [], $content['uploadForCompose'] ?? []);
 		//if (is_array($content['attachments'])) foreach($content['attachments'] as $k => &$file) $file['delete['.$file['tmp_name'].']']=0;
 		$content['no_griddata'] = empty($content['attachments']);
 		$preserv['attachments'] = $content['attachments'];
@@ -1297,21 +1297,21 @@ class mail_compose
 
 		//if (is_array($content['attachments']))error_log(__METHOD__.__LINE__.' Attachments:'.array2string($content['attachments']));
 		// if no filemanager -> no vfsFileSelector
-		if (!$GLOBALS['egw_info']['user']['apps']['filemanager'])
+		if (empty($GLOBALS['egw_info']['user']['apps']['filemanager']))
 		{
 			$content['vfsNotAvailable'] = "mail_DisplayNone";
 		}
 		// if no infolog -> no save as infolog
-		if (!$GLOBALS['egw_info']['user']['apps']['infolog'])
+		if (empty($GLOBALS['egw_info']['user']['apps']['infolog']))
 		{
 			$content['noInfologAvailable'] = "mail_DisplayNone";
 		}
 		// if no tracker -> no save as tracker
-		if (!$GLOBALS['egw_info']['user']['apps']['tracker'])
+		if (empty($GLOBALS['egw_info']['user']['apps']['tracker']))
 		{
 			$content['noTrackerAvailable'] = "mail_DisplayNone";
 		}
-		if (!$GLOBALS['egw_info']['user']['apps']['infolog'] && !$GLOBALS['egw_info']['user']['apps']['tracker'])
+		if (empty($GLOBALS['egw_info']['user']['apps']['infolog']) && empty($GLOBALS['egw_info']['user']['apps']['tracker']))
 		{
 			$content['noSaveAsAvailable'] = "mail_DisplayNone";
 		}
@@ -1324,12 +1324,12 @@ class mail_compose
 		$sel_options['mimeType'] = self::$mimeTypes;
 		$sel_options['priority'] = self::$priorities;
 		$sel_options['filemode'] = Vfs\Sharing::$modes;
-		if (!isset($content['priority']) || empty($content['priority'])) $content['priority']=3;
+		if (empty($content['priority'])) $content['priority']=3;
 		//$GLOBALS['egw_info']['flags']['currentapp'] = 'mail';//should not be needed
 		$etpl = new Etemplate('mail.compose');
 
 		$etpl->setElementAttribute('composeToolbar', 'actions', self::getToolbarActions($content));
-		if ($content['mimeType']=='html')
+		if ($content['mimeType'] == 'html')
 		{
 			//mode="$cont[rtfEditorFeatures]" validation_rules="$cont[validation_rules]" base_href="$cont[upload_dir]"
 			$_htmlConfig = Mail::$htmLawed_config;
@@ -1340,7 +1340,7 @@ class mail_compose
 			Mail::$htmLawed_config = $_htmlConfig;
 		}
 
-		if (isset($content['composeID'])&&!empty($content['composeID']))
+		if (!empty($content['composeID']))
 		{
 			$composeCache = $content;
 			unset($composeCache['body']);
@@ -1348,21 +1348,21 @@ class mail_compose
 			unset($composeCache['mail_plaintext']);
 			Api\Cache::setCache(Api\Cache::SESSION,'mail','composeCache'.trim($GLOBALS['egw_info']['user']['account_id']).'_'.$this->composeID,$composeCache,$expiration=60*60*2);
 		}
-		if (!isset($_content['serverID'])||empty($_content['serverID']))
+		if (empty($_content['serverID']))
 		{
 			$content['serverID'] = $this->mail_bo->profileID;
 		}
 		$preserv['serverID'] = $content['serverID'];
-		$preserv['lastDrafted'] = $content['lastDrafted'];
-		$preserv['processedmail_id'] = $content['processedmail_id'];
-		$preserv['references'] = $content['references'];
-		$preserv['in-reply-to'] = $content['in-reply-to'];
+		$preserv['lastDrafted'] = $content['lastDrafted'] ?? null;
+		$preserv['processedmail_id'] = $content['processedmail_id'] ?? null;
+		$preserv['references'] = $content['references'] ?? null;
+		$preserv['in-reply-to'] = $content['in-reply-to'] ?? null;
 		// thread-topic is a proprietary microsoft header and deprecated with the current version
 		// horde does not support the encoding of thread-topic, and probably will not no so in the future
 		//$preserv['thread-topic'] = $content['thread-topic'];
-		$preserv['thread-index'] = $content['thread-index'];
-		$preserv['list-id'] = $content['list-id'];
-		$preserv['mode'] = $content['mode'];
+		$preserv['thread-index'] = $content['thread-index'] ?? null;
+		$preserv['list-id'] = $content['list-id'] ?? null;
+		$preserv['mode'] = $content['mode'] ?? null;
 		// convert it back to checkbox expectations
 		if($content['mimeType'] == 'html') {
 			$content['mimeType']=1;
@@ -1391,11 +1391,11 @@ class mail_compose
 		// Resolve distribution list before send content to client
 		foreach(array('to', 'cc', 'bcc', 'replyto')  as $f)
 		{
-			if (is_array($content[$f])) $content[$f]= self::resolveEmailAddressList ($content[$f]);
+			if (isset($content[$f]) && is_array($content[$f])) $content[$f]= self::resolveEmailAddressList ($content[$f]);
 		}
 
 		// set filemode icons for all attachments
-		if($content['attachments'] && is_array($content['attachments']))
+		if(!empty($content['attachments']))
 		{
 			foreach($content['attachments'] as &$attach)
 			{
@@ -1407,9 +1407,9 @@ class mail_compose
 			}
 		}
 
-		$content['to'] = self::resolveEmailAddressList($content['to']);
+		if (isset($content['to'])) $content['to'] = self::resolveEmailAddressList($content['to']);
 		$content['html_toolbar'] = empty(Mail::$mailConfig['html_toolbar']) ?
-			join(',', Etemplate\Widget\HtmlArea::$toolbar_default_list) : join(',', Mail::$mailConfig['html_toolbar']);
+			implode(',', Etemplate\Widget\HtmlArea::$toolbar_default_list) : implode(',', Mail::$mailConfig['html_toolbar']);
 		//error_log(__METHOD__.__LINE__.array2string($content));
 		$etpl->exec('mail.mail_compose.compose',$content,$sel_options,array(),$preserv,2);
 	}
@@ -2022,7 +2022,7 @@ class mail_compose
 			'size'		=> $_size,
 			'folder'	=> $_folder,
 			'winmailFlag' => $_is_winmail,
-			'tmp_name'	=> mail_ui::generateRowID($this->mail_bo->profileID, $_folder, $_uid).'_'.(!empty($_partID)?$_partID:count($this->sessionData['attachments'])+1),
+			'tmp_name'	=> mail_ui::generateRowID($this->mail_bo->profileID, $_folder, $_uid).'_'.(!empty($_partID)?$_partID:count($this->sessionData['attachments'] ?? [])+1),
 		);
 	}
 
@@ -2485,7 +2485,7 @@ class mail_compose
 		if(!empty($_formData['list-id'])) {
 			$_mailObject->addHeader('List-Id', $_formData['list-id']);
 		}
-		if($_formData['disposition']=='on') {
+		if(isset($_formData['disposition']) && $_formData['disposition'] === 'on') {
 			$_mailObject->addHeader('Disposition-Notification-To', $_identity['ident_email']);
 		}
 
@@ -2522,6 +2522,8 @@ class mail_compose
 		if ($_formData['attachments'] && $_formData['filemode'] != Vfs\Sharing::ATTACH && !$_autosaving)
 		{
 			$attachment_links = $this->_getAttachmentLinks($_formData['attachments'], $_formData['filemode'],
+				// @TODO: $content['mimeType'] could type string/boolean. At the moment we can't strictly check them :(.
+				// @TODO: This needs to be fixed in compose function to get the right type from the content.
 				$_formData['mimeType'] == 'html',
 				array_unique(array_merge((array)$_formData['to'], (array)$_formData['cc'], (array)$_formData['bcc'])),
 				$_formData['expiration'], $_formData['password']);
@@ -2530,7 +2532,7 @@ class mail_compose
 		{
 			case 'html':
 				$body = $_formData['body'];
-				if ($attachment_links)
+				if (!empty($attachment_links))
 				{
 					if (strpos($body, '<!-- HTMLSIGBEGIN -->') !== false)
 					{
@@ -2567,7 +2569,7 @@ class mail_compose
 			default:
 				$body = $this->convertHTMLToText($_formData['body'],false, false, true, true);
 
-				if ($attachment_links) $body .= $attachment_links;
+				if (!empty($attachment_links)) $body .= $attachment_links;
 
 				#$_mailObject->Body = $_formData['body'];
 				if(!empty($signature)) {
@@ -2653,7 +2655,7 @@ class mail_compose
 			}
 			if ($connection_opened) $mail_bo->closeConnection();
 		}
-		return is_array($inline_images)?$inline_images:array();
+		return $inline_images ?? [];
 	}
 
 	/**
@@ -2761,7 +2763,7 @@ class mail_compose
 						$dmailbox = $dhA['folder'];
 						// beware: do not delete the original mail as found in processedmail_id
 						$pMuid='';
-						if ($content['processedmail_id'])
+						if (!empty($content['processedmail_id']))
 						{
 							$pMhA = mail_ui::splitRowID($content['processedmail_id']);
 							$pMuid = $pMhA['msgUID'];
@@ -3021,7 +3023,7 @@ class mail_compose
 		// create the messages and store inline images
 		$inline_images = $this->createMessage($mail, $_formData, $identity);
 		// remember the identity
-		if ($_formData['to_infolog'] == 'on' || $_formData['to_tracker'] == 'on') $fromAddress = $mail->From;//$mail->FromName.($mail->FromName?' <':'').$mail->From.($mail->FromName?'>':'');
+		if (!empty($mail->From) && ($_formData['to_infolog'] == 'on' || $_formData['to_tracker'] == 'on')) $fromAddress = $mail->From;//$mail->FromName.($mail->FromName?' <':'').$mail->From.($mail->FromName?'>':'');
 		#print "<pre>". $mail->getMessageHeader() ."</pre><hr><br>";
 		#print "<pre>". $mail->getMessageBody() ."</pre><hr><br>";
 		#exit;
@@ -3317,14 +3319,14 @@ class mail_compose
 			if (isset($lastDrafted['uid']) && !empty($lastDrafted['uid'])) $lastDrafted['uid']=trim($lastDrafted['uid']);
 			// manually drafted, do not delete
 			// will be handled later on IF mode was $_formData['mode']=='composefromdraft'
-			if (isset($lastDrafted['uid']) && (empty($lastDrafted['uid']) || $lastDrafted['uid'] == $this->sessionData['uid'])) $lastDrafted=false;
+			if (isset($lastDrafted['uid']) && (empty($lastDrafted['uid']) || $lastDrafted['uid'] == ($this->sessionData['uid']??null))) $lastDrafted=false;
 			//error_log(__METHOD__.__LINE__.array2string($lastDrafted));
 		}
 		if ($lastDrafted && is_array($lastDrafted) && $mail_bo->isDraftFolder($lastDrafted['folder']))
 		{
 			try
 			{
-				if ($this->sessionData['lastDrafted'] != $this->sessionData['uid'] || !($_formData['mode']=='composefromdraft' &&
+				if ($this->sessionData['lastDrafted'] != ($this->sessionData['uid']??null) || !($_formData['mode']=='composefromdraft' &&
 					($_formData['to_infolog'] == 'on' || $_formData['to_tracker'] == 'on' || $_formData['to_calendar'] == 'on' )&&$this->sessionData['attachments']))
 				{
 					//error_log(__METHOD__.__LINE__."#".$lastDrafted['uid'].'#'.$lastDrafted['folder'].array2string($_formData));
@@ -3399,7 +3401,7 @@ class mail_compose
 		}
 		if (is_array($this->sessionData['cc'])) $mailaddresses['cc'] = $this->sessionData['cc'];
 		if (is_array($this->sessionData['bcc'])) $mailaddresses['bcc'] = $this->sessionData['bcc'];
-		if (!empty($mailaddresses)) $mailaddresses['from'] = Mail\Html::decodeMailHeader($fromAddress);
+		if (!empty($mailaddresses) && !empty($fromAddress)) $mailaddresses['from'] = Mail\Html::decodeMailHeader($fromAddress);
 
 		if ($_formData['to_infolog'] == 'on' || $_formData['to_tracker'] == 'on' || $_formData['to_calendar'] == 'on' )
 		{
@@ -3407,7 +3409,7 @@ class mail_compose
 
 			foreach(array('to_infolog','to_tracker','to_calendar') as $app_key)
 			{
-				$entryid = $_formData['to_integrate_ids'][0][$app_key];
+				$entryid = $_formData['to_integrate_ids'][0][$app_key] ?? null;
 				if ($_formData[$app_key] == 'on')
 				{
 					$app_name = substr($app_key,3);
diff --git a/mail/inc/class.mail_sieve.inc.php b/mail/inc/class.mail_sieve.inc.php
index 1b08caaea4..ecd8d37f8f 100644
--- a/mail/inc/class.mail_sieve.inc.php
+++ b/mail/inc/class.mail_sieve.inc.php
@@ -252,6 +252,10 @@ class mail_sieve
 						break;
 					case 'reject':
 						$content['action_reject_text'] = $rules['action_arg'];
+						break;
+					case 'flags':
+						$content['action_flags_list'] = explode(' ', $rules['action_arg']);
+						break;
 				}
 				$content['anyof'] = $rules['anyof'] != 0?1:0;
 			}
@@ -302,10 +306,15 @@ class mail_sieve
 								break;
 							case 'reject':
 								$newRule['action_arg'] = $content['action_reject_text'];
+								break;
+							case 'flags':
+								$newRule['action_arg'] = implode(' ', $content['action_flags_list']);
+								break;
 						}
 						unset($newRule['action_folder_text']);
 						unset($newRule['action_address_text']);
 						unset($newRule['action_reject_text']);
+						unset($newRule['action_flags_list']);
 
 						$newRule['flg'] = 0 ;
 						if( $newRule['continue'] ) { $newRule['flg'] += 1; }
@@ -550,7 +559,7 @@ class mail_sieve
 			}
 			else
 			{
-				if ($icServer->acc_imap_administration)
+				if ($icServer->acc_imap_administration || (!empty($icServer->getExtensions()) && in_array('DATE', $icServer->getExtensions())))
 				{
 					$ByDate = array('by_date' => lang('By date'));
 				}
@@ -949,11 +958,11 @@ class mail_sieve
 				break;
 			case 'enable':
 				$msg = lang('rule with priority ') . $checked . lang(' enabled!');
-				$this->rules[$checked][status] = 'ENABLED';
+				$this->rules[$checked]['status'] = 'ENABLED';
 				break;
 			case 'disable':
 				$msg = lang('rule with priority ') . $checked . lang(' disabled!');
-				$this->rules[$checked][status] = 'DISABLED';
+				$this->rules[$checked]['status'] = 'DISABLED';
 				break;
 			case 'move':
 				break;
diff --git a/mail/inc/class.mail_ui.inc.php b/mail/inc/class.mail_ui.inc.php
index 90b9528ff7..1e55829c96 100644
--- a/mail/inc/class.mail_ui.inc.php
+++ b/mail/inc/class.mail_ui.inc.php
@@ -425,7 +425,7 @@ class mail_ui
 	protected static function image_proxy()
 	{
 		$configs = Api\Config::read('mail');
-		$image_proxy = $configs[self::IMAGE_PROXY_CONFIG] ?: self::DEFAULT_IMAGE_PROXY;
+		$image_proxy = $configs[self::IMAGE_PROXY_CONFIG] ?? self::DEFAULT_IMAGE_PROXY;
 		if (strpos(self::EGROUPWARE_IMAGE_PROXY, parse_url($image_proxy, PHP_URL_HOST)))
 		{
 			$image_proxy = self::EGROUPWARE_IMAGE_PROXY;
@@ -565,7 +565,7 @@ class mail_ui
 				$etpl->setElementAttribute(self::$nm_index.'[foldertree]','actions', $this->get_tree_actions());
 
 				// sending preview toolbar actions
-				if ($content['mailSplitter']) $etpl->setElementAttribute('mailPreview[toolbar]', 'actions', $this->get_toolbar_actions());
+				if (!empty($content['mailSplitter'])) $etpl->setElementAttribute('mailPreview[toolbar]', 'actions', $this->get_toolbar_actions());
 
 				// We need to send toolbar actions to client-side because view template needs them
 				if (Api\Header\UserAgent::mobile()) $sel_options['toolbar'] = $this->get_toolbar_actions();
@@ -1827,7 +1827,7 @@ $filter['before']= date("d-M-Y", $cutoffdate2);
 			// we have an own created rowID; prepend app=mail
 			array_unshift($res,'mail');
 		}
-		return array('app'=>$res[0], 'accountID'=>$res[1], 'profileID'=>$res[2], 'folder'=>base64_decode($res[3]), 'msgUID'=>$res[4]);
+		return array('app'=>$res[0], 'accountID'=>$res[1]??null, 'profileID'=>$res[2]??null, 'folder'=>base64_decode($res[3]??null), 'msgUID'=>$res[4]??null);
 	}
 
 	/**
diff --git a/mail/templates/default/sieve.edit.xet b/mail/templates/default/sieve.edit.xet
index 44a9d2b194..0a58df34c2 100644
--- a/mail/templates/default/sieve.edit.xet
+++ b/mail/templates/default/sieve.edit.xet
@@ -98,6 +98,16 @@
 					<radio label="Discard message" id="action" options="discard"/>
 					<description/>
 				</row>
+				<row>
+					<radio label="set flags" id="action" options="flags"/>
+					<taglist id="action_flags_list">
+						<option value="\\Flagged">Flagged</option>
+						<option value="\\Deleted">Deleted</option>
+						<option value="\\Seen">Read</option>
+						<option value="\\Answered">Answered</option>
+						<option value="\\Draft">Draft</option>
+					</taglist>
+				</row>
 				<row>
 					<description value="(*) Please consider, forward to multiple addresses will not work if number of addresses exceeds the Limit. For most mail Servers the limit is 4 by default, please contact your mail server administrator for further info."/>
 				</row>
diff --git a/notifications/inc/class.notifications_popup.inc.php b/notifications/inc/class.notifications_popup.inc.php
index 3452e5ec6a..2c0f780416 100644
--- a/notifications/inc/class.notifications_popup.inc.php
+++ b/notifications/inc/class.notifications_popup.inc.php
@@ -160,7 +160,7 @@ class notifications_popup implements notifications_iface {
 			foreach ($rs as $notification) {
 				$actions = null;
 				$data = json_decode($notification['notify_data'], true);
-				if ($data['appname'] && $data['data'])
+				if (!empty($data['appname']) && !empty($data['data']))
 				{
 					$_actions = Api\Hooks::process (array(
 						'location' => 'notifications_actions',
@@ -175,7 +175,7 @@ class notifications_popup implements notifications_iface {
 					'created' => Api\DateTime::server2user($notification['notify_created']),
 					'current' => new Api\DateTime('now'),
 					'actions' => is_array($actions)?$actions:NULL,
-					'extra_data' => ($data['data'] ? $data['data'] : array())
+					'extra_data' => $data['data'] ?? [],
 				);
 
 			}
diff --git a/pixelegg/inc/class.pixelegg_framework.inc.php b/pixelegg/inc/class.pixelegg_framework.inc.php
index 073f8f8ee3..7dba756528 100755
--- a/pixelegg/inc/class.pixelegg_framework.inc.php
+++ b/pixelegg/inc/class.pixelegg_framework.inc.php
@@ -85,13 +85,13 @@ class pixelegg_framework extends Api\Framework\Ajax
 	{
 		$ret = parent::_get_css();
 		// color to use
-		$color = str_replace('custom',$GLOBALS['egw_info']['user']['preferences']['common']['template_custom_color'],
-			$GLOBALS['egw_info']['user']['preferences']['common']['template_color']);
+		$color = str_replace('custom', $GLOBALS['egw_info']['user']['preferences']['common']['template_custom_color'] ?? null,
+			$GLOBALS['egw_info']['user']['preferences']['common']['template_color'] ?? null);
 
 		// Create a dark variant of the color
 		$color_darker = empty($color) ? '' :$this->_color_shader($color, -30);
 
-		if (preg_match('/^(#[0-9A-F]+|[A-Z]+)$/i', $GLOBALS['egw_info']['user']['preferences']['common']['sidebox_custom_color']))
+		if (!empty($GLOBALS['egw_info']['user']['preferences']['common']['sidebox_custom_color']) && preg_match('/^(#[0-9A-F]+|[A-Z]+)$/i', $GLOBALS['egw_info']['user']['preferences']['common']['sidebox_custom_color']))
 		{
 			$sidebox_color_hover = $GLOBALS['egw_info']['user']['preferences']['common']['sidebox_custom_color'];
 			$sidebox_color = $this->_color_shader($sidebox_color_hover, -30);
@@ -101,7 +101,7 @@ class pixelegg_framework extends Api\Framework\Ajax
 			$sidebox_color_hover = $color;
 			$sidebox_color = $color_darker;
 		}
-		if (preg_match('/^(#[0-9A-F]+|[A-Z]+)$/i', $GLOBALS['egw_info']['user']['preferences']['common']['loginbox_custom_color']))
+		if (!empty($GLOBALS['egw_info']['user']['preferences']['common']['loginbox_custom_color']) && preg_match('/^(#[0-9A-F]+|[A-Z]+)$/i', $GLOBALS['egw_info']['user']['preferences']['common']['loginbox_custom_color']))
 		{
 			$loginbox_color = $GLOBALS['egw_info']['user']['preferences']['common']['loginbox_custom_color'];
 		}
@@ -109,8 +109,8 @@ class pixelegg_framework extends Api\Framework\Ajax
 		{
 			$loginbox_color = $color_darker;
 		}
-		//alway set header logo used  in sharing regardless of custom color being set
-		$header = $GLOBALS['egw_info']['server']['login_logo_header'] ? Api\Framework::get_login_logo_or_bg_url('login_logo_header', 'logo')
+		//always set header logo used  in sharing regardless of custom color being set
+		$header = !empty($GLOBALS['egw_info']['server']['login_logo_header']) ? Api\Framework::get_login_logo_or_bg_url('login_logo_header', 'logo')
 			: Api\Framework::get_login_logo_or_bg_url('login_logo_file', 'logo');
 		$ret['app_css'] .= "
 			/*
diff --git a/timesheet/inc/class.timesheet_bo.inc.php b/timesheet/inc/class.timesheet_bo.inc.php
index 08f5287f5a..f6897e8700 100644
--- a/timesheet/inc/class.timesheet_bo.inc.php
+++ b/timesheet/inc/class.timesheet_bo.inc.php
@@ -451,7 +451,7 @@ class timesheet_bo extends Api\Storage
 		{
 			$extra_cols[] = $total_sql.' AS ts_total';
 		}
-		if (!isset($filter['ts_owner']) || !count($filter['ts_owner']))
+		if (!isset($filter['ts_owner']) || !count((array)$filter['ts_owner']))
 		{
 			$filter['ts_owner'] = array_keys($this->grants);
 		}
diff --git a/timesheet/inc/class.timesheet_hooks.inc.php b/timesheet/inc/class.timesheet_hooks.inc.php
index 7b9f66a0f8..e254685313 100644
--- a/timesheet/inc/class.timesheet_hooks.inc.php
+++ b/timesheet/inc/class.timesheet_hooks.inc.php
@@ -178,41 +178,8 @@ class timesheet_hooks
 		// Merge print
 		if ($GLOBALS['egw_info']['user']['apps']['filemanager'])
 		{
-			$settings['default_document'] = array(
-				'type'   => 'vfs_file',
-				'size'   => 60,
-				'label'  => 'Default document to insert entries',
-				'name'   => 'default_document',
-				'help'   => lang('If you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. That icon allows to download the specified document with the data inserted.',lang('timesheet')).' '.
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.', 'ts_title').' '.
-					lang('The following document-types are supported:'). implode(',',Api\Storage\Merge::get_file_extensions()),
-				'run_lang' => false,
-				'xmlrpc' => True,
-				'admin'  => False,
-			);
-			$settings['document_dir'] = array(
-				'type'     => 'vfs_dirs',
-				'size'     => 60,
-				'label'    => 'Directory with documents to insert entries',
-				'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 %1 data inserted.', lang('timesheet')) . ' ' .
-					lang('The document can contain placeholder like {{%1}}, to be replaced with the data.', 'ts_title') . ' ' .
-					lang('The following document-types are supported:') . implode(',', Api\Storage\Merge::get_file_extensions()),
-				'run_lang' => false,
-				'xmlrpc'   => True,
-				'admin'    => False,
-				'default'  => '/templates/timesheet',
-			);
-			$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',
-			);
+			$merge = new timesheet_merge();
+			$settings += $merge->merge_preferences();
 		}
 
 		return $settings;
diff --git a/timesheet/inc/class.timesheet_merge.inc.php b/timesheet/inc/class.timesheet_merge.inc.php
index 89a11149bc..6f4c8c9f1d 100644
--- a/timesheet/inc/class.timesheet_merge.inc.php
+++ b/timesheet/inc/class.timesheet_merge.inc.php
@@ -156,68 +156,6 @@ class timesheet_merge extends Api\Storage\Merge
 		return $info;
 	}
 
-	/**
-	 * Generate table with replacements for the Api\Preferences
-	 *
-	 */
-	public function show_replacements()
-	{
-		$GLOBALS['egw_info']['flags']['app_header'] = lang('timesheet').' - '.lang('Replacements for inserting entries into documents');
-		$GLOBALS['egw_info']['flags']['nonavbar'] = false;
-		echo $GLOBALS['egw']->framework->header();
-
-		echo "<table width='90%' align='center'>\n";
-		echo '<tr><td colspan="4"><h3>'.lang('Timesheet fields:')."</h3></td></tr>";
-
-		$n = 0;
-		$fields = array('ts_id' => lang('Timesheet ID')) + $this->bo->field2label + array(
-			'ts_total' => lang('total'),
-			'ts_created' => lang('Created'),
-			'ts_modified' => lang('Modified'),
-		);
-		foreach($fields as $name => $label)
-		{
-			if (in_array($name,array('pl_id','customfields'))) continue;	// dont show them
-
-			if (in_array($name,array('ts_title', 'ts_description')) && $n&1)		// main values, which should be in the first column
-			{
-				echo "</tr>\n";
-				$n++;
-			}
-			if (!($n&1)) echo '<tr>';
-			echo '<td>{{'.$name.'}}</td><td>'.lang($label).'</td>';
-			if ($n&1) echo "</tr>\n";
-			$n++;
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Custom fields').":</h3></td></tr>";
-		foreach($this->bo->customfields as $name => $field)
-		{
-			echo '<tr><td>{{#'.$name.'}}</td><td colspan="3">'.$field['label']."</td></tr>\n";
-		}
-
-		echo '<tr><td colspan="4"><h3>'.lang('Project fields').':</h3></td></tr>';
-		$pm_merge = new projectmanager_merge();
-		$i = 0;
-		foreach($pm_merge->projectmanager_fields as $name => $label)
-		{
-			if (!($i&1)) echo '<tr>';
-			echo '<td>{{ts_project/'.$name.'}}</td><td>'.$label.'</td>';
-			if ($i&1) echo "</tr>\n";
-			$i++;
-		}
-
-		echo '<tr><td colspan="4"><h3>' . lang('General fields:') . "</h3></td></tr>";
-		foreach($this->get_common_replacements() as $name => $label)
-		{
-			echo '<tr><td>{{' . $name . '}}</td><td colspan="3">' . $label . "</td></tr>\n";
-		}
-
-		echo "</table>\n";
-
-		echo $GLOBALS['egw']->framework->footer();
-	}
-
 	/**
 	 * Get a list of placeholders provided.
 	 *
@@ -256,10 +194,14 @@ class timesheet_merge extends Api\Storage\Merge
 			}
 		}
 
-		// Add project placeholders
-		$pm_merge = new projectmanager_merge();
-		$this->add_linked_placeholders($placeholders, lang('Project'), $pm_merge->get_placeholder_list('ts_project'));
-
+		// Don't add any linked placeholders if we're not at the top level
+		// This avoids potential recursion
+		if(!$prefix)
+		{
+			// Add project placeholders
+			$pm_merge = new projectmanager_merge();
+			$this->add_linked_placeholders($placeholders, lang('Project'), $pm_merge->get_placeholder_list('ts_project'));
+		}
 		return $placeholders;
 	}
 }
diff --git a/timesheet/inc/class.timesheet_ui.inc.php b/timesheet/inc/class.timesheet_ui.inc.php
index e7dcd6e836..7ed7ca7c4b 100644
--- a/timesheet/inc/class.timesheet_ui.inc.php
+++ b/timesheet/inc/class.timesheet_ui.inc.php
@@ -660,12 +660,16 @@ class timesheet_ui extends timesheet_bo
 			unset($query['col_filter']['cat_id']);
 		}
 		$GLOBALS['egw_info']['flags']['app_header'] = lang('timesheet');
-		if ($query['col_filter']['ts_owner'])
+		if (!empty($query['col_filter']['ts_owner']))
 		{
-			$GLOBALS['egw_info']['flags']['app_header'] .= ': '.Api\Accounts::username($query['col_filter']['ts_owner']);
-			#if ($GLOBALS['egw']->accounts->get_type($query['col_filter']['ts_owner']) == 'g') $GLOBALS['egw_info']['flags']['app_header'] .= ' '. lang("and its members");
-			#_debug_array($GLOBALS['egw']->accounts->members($query['col_filter']['ts_owner'],true));
-			if ($query['col_filter']['ts_owner']<0) $query['col_filter']['ts_owner'] = array_merge(array($query['col_filter']['ts_owner']),$GLOBALS['egw']->accounts->members($query['col_filter']['ts_owner'],true));
+			$GLOBALS['egw_info']['flags']['app_header'] .= ': '.implode(', ',
+				array_map(Api\Accounts::class.'::username', (array)$query['col_filter']['ts_owner']));
+
+			if ($query['col_filter']['ts_owner'] < 0)
+			{
+				$query['col_filter']['ts_owner'] = array_merge(array($query['col_filter']['ts_owner']),
+					$GLOBALS['egw']->accounts->members($query['col_filter']['ts_owner'],true));
+			}
 		}
 		else
 		{