From 5f795a43790b1fde964dc673a1ca2ae6b434db90 Mon Sep 17 00:00:00 2001 From: ralf Date: Fri, 17 May 2024 15:59:49 +0200 Subject: [PATCH 01/51] WIP ViDoTeach REST API: fix not working unsetting in PATCH requests with null value --- api/src/CalDAV/JsBase.php | 5 +++++ api/src/MimeMagic.php | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/api/src/CalDAV/JsBase.php b/api/src/CalDAV/JsBase.php index 453f66e918..827a09c82a 100644 --- a/api/src/CalDAV/JsBase.php +++ b/api/src/CalDAV/JsBase.php @@ -370,6 +370,11 @@ class JsBase $target = $value; } } + // if we unset fields stored directly in the database, they will NOT be updated :( + elseif (!$n) + { + $target = null; + } else { unset($parent[$part]); diff --git a/api/src/MimeMagic.php b/api/src/MimeMagic.php index aaa93aafb2..9f8e0d6a56 100644 --- a/api/src/MimeMagic.php +++ b/api/src/MimeMagic.php @@ -115,6 +115,25 @@ class MimeMagic return $key; } + /** + * Convert a MIME type to all known file extensions + * + * If we cannot map the type to any file extension, we return an empty array. + * + * @param string $_type The MIME type to be mapped to a file extension. + * @return array with possible file extensions + */ + public static function mime2extensions($_type) + { + $type = strtolower($_type); + if (isset(self::$mime_alias_map[$type])) $type = self::$mime_alias_map[$type]; + + return array_keys(array_filter(self::$mime_extension_map, static function($mime) use ($type) + { + return $mime == $type; + })); + } + /** * Fix old / aliased mime-types by returning valid/default mime-type * From 839a0896bc205a16a0c63706f6458979d8b9e77d Mon Sep 17 00:00:00 2001 From: ralf Date: Fri, 17 May 2024 17:10:25 +0200 Subject: [PATCH 02/51] Simple PHP client for EGroupware REST API using PHP Curl extension --- doc/REST-CalDAV-CardDAV/api-client.php | 187 +++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100755 doc/REST-CalDAV-CardDAV/api-client.php diff --git a/doc/REST-CalDAV-CardDAV/api-client.php b/doc/REST-CalDAV-CardDAV/api-client.php new file mode 100755 index 0000000000..59ca9e5cbf --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/api-client.php @@ -0,0 +1,187 @@ + + * @copyright (c) 2024 by Ralf Becker + */ + +/* Example usage of this client: +require_once('/path/to/egroupware/doc/api-client.php'); + +if (PHP_SAPI !== 'cli') +{ + die('This script can only be run from the command line.'); +} +$base_url = 'https://egw.example.org/egroupware/groupdav.php'; +$authorization[parse_url($base_url, PHP_URL_HOST)] = 'Authorization: Basic '.base64_encode('sysop:secret'); + +$params = [ + 'filters[info_status]' => 'archive', +]; +$courses = []; +foreach(apiIterator('/infolog/', $params) as $infolog) +{ + echo json_encode($infolog, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n"; + foreach($infolog['participants'] as $account_id => $participant) + { + if ($participant['roles']['owner'] ?? false) + { + echo json_encode($contact=api('/addressbook-accounts/'.$account_id),JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n"; + break; + } + } + +} +*/ + +/** + * Iterate through API calls on collections + * + * This function only queries a limited number of entries (default 100) and uses sync-token to query more. + * + * @param string $url either path (starting with / and prepending global $base_url) or full URL + * @param array& $params can contain optional "sync-token" (default="") and "nresults" (default=100) and returns final "sync-token" + * @return Generator yields array with additional value for key "@self" containing the key of the responses-object yielded + * @throws JsonException|Exception see api + */ +function apiIterator(string $url, array &$params=[]) +{ + while(true) + { + if (!isset($params['nresults'])) + { + $params['nresults'] = 100; + } + if (!isset($params['sync-token'])) + { + $params['sync-token']=''; + } + $responses = api($url, 'GET', $params); + if (!isset($responses['responses'])) + { + throw new \Exception('Invalid respose: '.(is_scalar($responses) ? $responses : json_encode($responses))); + } + foreach($responses['responses'] as $self => $response) + { + $response['@self'] = $self; + + yield $response; + } + $params['sync-token'] = $responses['sync-token'] ?? ''; + if (empty($responses['more-results'])) + { + return; + } + } +} + +/** + * Make an API call to given URL + * + * Authorization is added from global $authorization array indexed by host-name of $url or $base_url + * + * @param string $url either path (starting with / and prepending global $base_url) or full URL + * @param string $method + * @param string|array|resource $body for GET&DELETE this is added as query and must not be a resource/file-handle + * @param array $header + * @param array|null $response_header associative array of response headers, key 0 has HTTP status + * @param int $follow how many redirects to follow, default 3, can be set to 0 to NOT follow + * @return array|string array of decoded JSON or string body + * @throws JsonException for invalid JSON + * @throws Exception with code=0: opening http connection, code=HTTP status, if status is NOT 2xx + */ +function api(string $url, string $method='GET', $body='', array $header=['Content-Type: application/json'], ?array &$response_header=null, int $follow=3) +{ + global $base_url, $authorization; + + if ($url[0] === '/') + { + $url = $base_url . $url; + } + if (in_array(strtoupper($method), ['GET', 'DELETE']) && $body && !is_resource($body)) + { + $url .= '?' . (is_array($body) ? http_build_query($body) : $body); + } + if (!($curl = curl_init($url))) + { + throw new Exception(curl_error($curl)); + } + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HEADER, true); + if ($follow > 0) + { + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, CURLOPT_MAXREDIRS, $follow); + } + + switch (strtoupper($method)) + { + case 'POST': + curl_setopt($curl, CURLOPT_POST, true); + break; + case 'PUT': + case 'DELETE': + case 'PATCH': + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + break; + case 'GET': + curl_setopt($curl, CURLOPT_HTTPGET, true); + break; + } + $header = array_merge($header, ['User-Agent: '.basename(__FILE__, '.php'), $authorization[parse_url($url, PHP_URL_HOST)]]); + if (in_array(strtoupper($method), ['POST', 'PUT', 'PATCH'])) + { + if (is_resource($body)) + { + fseek($body, 0, SEEK_END); + curl_setopt($curl, CURLOPT_INFILESIZE, ftell($body)); + fseek($body, 0); + } + curl_setopt($curl, is_resource($body) ? CURLOPT_INFILE : CURLOPT_POSTFIELDS, is_array($body) ? json_encode($body) : $body); + } + if (!array_filter($header, function($header) + { + return stripos($header, 'Accept:') === 0; + })) + { + $header[] = 'Accept: application/json'; + } + curl_setopt($curl, CURLOPT_HTTPHEADER, $header); + $response_header = []; + if (($response = curl_exec($curl)) === false) + { + throw new Exception(curl_error($curl), 0); + } + do { + [$rheader, $response] = explode("\r\n\r\n", $response, 2); + foreach (explode("\r\n", $rheader) as $line) + { + list($key, $value) = explode(':', $line, 2) + [null, null]; + if (!isset($value)) + { + $response_header[0] = $key; + } + else + { + $response_header[strtolower($key)] = trim($value); + } + } + [, $http_status] = explode(' ', $response_header[0], 2); + } + while ($http_status[0] === '3' && $follow && preg_match('#^HTTP/[\d.]+ \d+#', $response)); + + if ($http_status[0] !== '2') + { + throw new Exception("Unexpected HTTP status code $http_status: $response", (int)$http_status); + } + if ($response !== '' && preg_match('#^application/([^+; ]+\+)?json(;|$)#', $response_header['content-type'])) + { + return json_decode($response, true, 512, JSON_THROW_ON_ERROR); + } + return $response; +} \ No newline at end of file From 50dfa86150299c7e938af919f4950615d8f3a853 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 17 May 2024 13:35:02 -0600 Subject: [PATCH 03/51] Addressbook: Add a method to easily get email addresses for contacts on the client --- addressbook/js/app.ts | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/addressbook/js/app.ts b/addressbook/js/app.ts index 80e758971c..1c07c4eb9c 100644 --- a/addressbook/js/app.ts +++ b/addressbook/js/app.ts @@ -1110,6 +1110,62 @@ class AddressbookApp extends EgwApp return false; } + /** + * Get email addresses from selected contacts + * + * @param selected + * @param {string[]} email_fields + * @param {string} name_field + * @returns {Promise} + */ + async _getEmails(selected, email_fields = ["email"], name_field = 'n_fn') : Promise + { + if(email_fields.length == 0) + { + return []; + } + + // Check for all selected, don't resolve until all done + let nm = this.et2.getWidgetById('nm'); + let all = new Promise(function(resolve) + { + let fetching = fetchAll(selected, nm, ids => {resolve(ids.map(function(num) {return {id: 'addressbook::' + num};}))}); + if(!fetching) + { + resolve(selected); + } + }); + let awaited = await all; + + // Go through selected & pull email addresses from data + let emails = []; + for(let i = 0; i < awaited.length; i++) + { + // Pull data from global cache + const data = egw.dataGetUIDdata(awaited[i].id) || {data: {}}; + let emailAddresses = email_fields.map(field => + { + return data.data[field]; + }) + + // prefix email with full name + let personal = data.data[name_field] || ''; + if(personal.match(/[^a-z0-9. -]/i)) + { + personal = '"' + personal.replace(/"/, '\\"') + '"'; + } + + //remove comma in personal as it will confilict with mail content comma seperator in the process + personal = personal.replace(/,/g, ''); + + emailAddresses.forEach(mail => + { + emails.push((personal ? personal + ' <' : '') + mail + (personal ? '>' : '')); + }); + } + return emails; + } + /** * Merge the selected contacts into the target document. * From ff94af1f7d650357e22caac78b46ea9dd83140e3 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 17 May 2024 15:56:56 -0600 Subject: [PATCH 04/51] * Api: Add some options to merge: merge individually, merge & link, merge & email, download --- api/js/etemplate/Et2Dialog/Et2MergeDialog.ts | 145 +++++++++++ api/js/etemplate/etemplate2.ts | 1 + api/js/jsapi/egw_app.ts | 122 +++++---- api/src/Contacts/Merge.php | 3 +- api/src/Mail.php | 7 +- api/src/Storage/Merge.php | 257 ++++++++++++++++--- 6 files changed, 447 insertions(+), 88 deletions(-) create mode 100644 api/js/etemplate/Et2Dialog/Et2MergeDialog.ts diff --git a/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts b/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts new file mode 100644 index 0000000000..0938c7fbbf --- /dev/null +++ b/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts @@ -0,0 +1,145 @@ +import {customElement} from "lit/decorators/custom-element.js"; +import {Et2Widget} from "../Et2Widget/Et2Widget"; +import {css, html, LitElement} from "lit"; +import shoelace from "../Styles/shoelace"; +import {Et2VfsSelectDialog} from "../Et2Vfs/Et2VfsSelectDialog"; +import {property} from "lit/decorators/property.js"; +import {Et2Dialog} from "./Et2Dialog"; +import {state} from "lit/decorators/state.js"; + +@customElement("et2-merge-dialog") +export class Et2MergeDialog extends Et2Widget(LitElement) +{ + static get styles() + { + return [ + super.styles, + shoelace, + css` + :host { + } + + et2-details::part(content) { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + `, + ]; + } + + @property() + application : string + + @property() + path : string + + // Can't merge "& send" if no email template selected + @state() + canEmail = false; + + private get dialog() : Et2VfsSelectDialog + { + return this.shadowRoot.querySelector("et2-vfs-select-dialog"); + } + + public async getComplete() : Promise<{ + documents : { path : string, mime : string }[], + options : { [key : string] : string | boolean } + }> + { + await this.updateComplete; + const [button, value] = await this.dialog.getComplete(); + + if(!button) + { + return {documents: [], options: this.optionValues}; + } + + const documents = []; + Array.from(>value).forEach(value => + { + const fileInfo = this.dialog.fileInfo(value) ?? []; + documents.push({path: value, mime: fileInfo.mime}) + }); + let options = this.optionValues; + if(button == Et2Dialog.OK_BUTTON) + { + options.download = true; + } + return {documents: documents, options: options}; + } + + public get optionValues() + { + const optionValues = { + download: false + }; + this.dialog.querySelectorAll(":not([slot='footer'])").forEach(e => + { + if(typeof e.getValue == "function") + { + optionValues[e.getAttribute("id")] = e.getValue() === "true" ? true : e.getValue(); + } + }); + return optionValues; + } + + private option(component_name) + { + return this.dialog.querySelector("et2-details > [id='" + component_name + "']"); + } + + protected handleFileSelect(event) + { + // Disable PDF checkbox for only email files selected + let canPDF = false; + const oldCanEmail = this.canEmail; + this.canEmail = false; + + this.dialog.value.forEach(path => + { + if(this.dialog.fileInfo(path).mime !== "message/rfc822") + { + canPDF = true; + } + else + { + this.canEmail = true; + } + }); + this.option("pdf").disabled = !canPDF; + this.requestUpdate("canEmail", oldCanEmail); + } + + + render() + { + return html` + + ${this.canEmail ? + html` + ` : + html` + ` + } + + ${this.egw().lang("Merge options")} + + this.egw().link_get_registry(this.application, "entry") || this.egw().lang("entry"))}" + > + + + `; + } +} \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index a370aeb1f1..965f911f47 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -49,6 +49,7 @@ import './Et2Date/Et2DateTimeReadonly'; import './Et2Date/Et2DateTimeToday'; import './Et2Description/Et2Description'; import './Et2Dialog/Et2Dialog'; +import './Et2Dialog/Et2MergeDialog'; import './Et2DropdownButton/Et2DropdownButton'; import './Et2Email/Et2Email'; import './Expose/Et2ImageExpose'; diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index 6a763a1ee9..91abf38aff 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -17,13 +17,14 @@ import {et2_createWidget} from "../etemplate/et2_core_widget"; import type {IegwAppLocal} from "./egw_global"; import Sortable from 'sortablejs/modular/sortable.complete.esm.js'; import {et2_valueWidget} from "../etemplate/et2_core_valueWidget"; -import {nm_action} from "../etemplate/et2_extension_nextmatch_actions"; +import {fetchAll, nm_action} from "../etemplate/et2_extension_nextmatch_actions"; import {Et2Dialog} from "../etemplate/Et2Dialog/Et2Dialog"; import {Et2Favorites} from "../etemplate/Et2Favorites/Et2Favorites"; import {loadWebComponent} from "../etemplate/Et2Widget/Et2Widget"; -import {Et2VfsSelectDialog} from "../etemplate/Et2Vfs/Et2VfsSelectDialog"; -import {Et2Checkbox} from "../etemplate/Et2Checkbox/Et2Checkbox"; import type {EgwAction} from "../egw_action/EgwAction"; +import {Et2MergeDialog} from "../etemplate/Et2Dialog/Et2MergeDialog"; +import {EgwActionObject} from "../egw_action/EgwActionObject"; +import type {Et2Details} from "../etemplate/Layout/Et2Details/Et2Details"; /** * Type for push-message @@ -804,7 +805,6 @@ export abstract class EgwApp // Find what we need let nm = null; let action = _action; - let as_pdf = null; // Find Select all while(nm == null && action.parent != null) @@ -824,28 +824,76 @@ export abstract class EgwApp let split = _selected[i].id.split("::"); ids.push(split[1]); } - let document = await this._getMergeDocument(nm?.getInstanceManager(), _action); - if(!document.document) + let document = await this._getMergeDocument(nm?.getInstanceManager(), _action, _selected); + if(!document.documents || document.documents.length == 0) { return; } let vars = { ..._action.data.merge_data, - document: document.document, - pdf: document.pdf ?? false, + options: document.options, select_all: all, id: ids }; - if(document.mime == "message/rfc822") + if(document.options.link) { + vars.options.app = this.appname; + } + // Just one file, an email - merge & edit or merge & send + if(document.documents.length == 1 && document.documents[0].mime == "message/rfc822") + { + vars.document = document.documents[0].path; return this._mergeEmail(_action.clone(), vars); } else { - vars.id = JSON.stringify(ids); + vars.document = document.documents.map(f => f.path); + } + if(document.documents.length == 1 && !document.options.individual) + { + // Only 1 document, we can open it + vars.id = JSON.stringify(ids); + this.egw.open_link(this.egw.link('/index.php', vars), '_blank'); + } + else + { + // Multiple files, or merging individually - will result in multiple documents that we can't just open + vars.menuaction = vars.menuaction.replace("merge_entries", "ajax_merge_multiple"); + vars.menuaction += "&merge=" + vars.merge; + let mergedFiles = []; + + // Check for an email template - all other files will be merged & attached + let email = document.documents.find(f => f.mime == "message/rfc822"); + + // Can we do this in one, or do we have to split it up for feedback? + if(!vars.options.individual && !email) + { + // Handle it all on the server in one request + mergedFiles = await this.egw.request(vars.menuaction, [vars.id, vars.document, vars.options]); + } + else + { + // Merging documents, merge email, attach to email, send. + // Handled like this here so we can give feedback, server could do it all in one request + let idGroup = await new Promise((resolve) => + { + if(all) + { + fetchAll(ids, nm, idsArr => resolve(vars.options.individual ? idsArr : [idsArr])); + } + else + { + resolve(vars.options.individual ? ids : [ids]) + } + }); + Et2Dialog.long_task(null /*done*/, this.egw.lang("Merging"), + email ? this.egw.lang("Merging into %1 and sending", email.path) : this.egw.lang("Merging into %1", vars.document.join(", ")), + vars.menuaction, + idGroup.map((ids) => {return [Array.isArray(ids) ? ids : [ids], vars.document, vars.options];}), this.egw + ); + } } - this.egw.open_link(this.egw.link('/index.php', vars), '_blank'); } /** @@ -855,10 +903,9 @@ export abstract class EgwApp * @protected */ - protected _getMergeDocument(et2?, action? : EgwAction) : Promise<{ - document : string, - pdf : boolean, - mime : string + protected _getMergeDocument(et2?, action? : EgwAction, selected? : EgwActionObject[]) : Promise<{ + documents : { path : string; mime : string }[]; + options : { [p : string] : string | boolean } }> { let path = action?.data?.merge_data?.directory ?? ""; @@ -871,51 +918,28 @@ export abstract class EgwApp d = "/" + d; } }); - let fileSelect = loadWebComponent('et2-vfs-select-dialog', { - class: "egw_app_merge_document", - title: this.egw.lang("Insert in document"), - mode: "open", - path: path ?? dirs?.pop() ?? "", - open: true + let fileSelect = loadWebComponent('et2-merge-dialog', { + application: this.appname, + path: dirs.pop() || "" }, et2.widgetContainer); if(!et2) { document.body.append(fileSelect); } - let pdf = loadWebComponent("et2-checkbox", { - slot: "footer", - label: "As PDF" - }, fileSelect); - - // Disable PDF checkbox for emails - fileSelect.addEventListener("et2-select", e => + // Start details open when you have multiple selected + fileSelect.updateComplete.then(() => { - let canPDF = true; - fileSelect.value.forEach(path => - { - if(fileSelect.fileInfo(path).mime == "message/rfc822") - { - canPDF = false; - } - }); - pdf.disabled = !canPDF; + // @ts-ignore + (fileSelect.shadowRoot.querySelector('et2-details')).open = selected.length > 1; }); - return fileSelect.getComplete().then((values) => - { - if(!values[0]) - { - return {document: '', pdf: false, mime: ""}; - } + // Remove when done + fileSelect.getComplete().then(() => {fileSelect.remove();}); - const value = values[1].pop() ?? ""; - const fileInfo = fileSelect.fileInfo(value) ?? {}; - fileSelect.remove(); - return {document: value, pdf: pdf.getValue(), mime: fileInfo.mime ?? ""}; - }); + return fileSelect.getComplete(); } /** - * Merging into an email + * Merge into an email, then open it in compose for a single, send directly for multiple * * @param {object} data * @protected diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 2a4295511e..c16192bbae 100644 --- a/api/src/Contacts/Merge.php +++ b/api/src/Contacts/Merge.php @@ -31,7 +31,8 @@ class Merge extends Api\Storage\Merge var $public_functions = array( 'download_by_request' => true, 'show_replacements' => true, - "merge_entries" => true + 'merge_entries' => true, + 'ajax_merge_multiple' => true, ); /** diff --git a/api/src/Mail.php b/api/src/Mail.php index 4c13aea489..85dcbcc806 100644 --- a/api/src/Mail.php +++ b/api/src/Mail.php @@ -7002,9 +7002,10 @@ class Mail * @param string&|false $_folder (passed by reference) will set the folder used. must be set with a folder, but will hold modifications if * folder is modified. Set to false to not keep the message. * @param string& $importID ID for the imported message, used by attachments to identify them unambiguously + * @param string[] $attachments Files attached to the email - the same files for every email * @return mixed array of messages with success and failed messages or exception */ - function importMessageToMergeAndSend(Storage\Merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID='') + function importMessageToMergeAndSend(Storage\Merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID = '', $attachments = []) { $importfailed = false; $processStats = array('success'=>array(),'failed'=>array()); @@ -7075,6 +7076,10 @@ class Mail $mailObject->addReplyTo(Horde_Idna::encode($activeMailProfile['ident_email']),Mail::generateIdentityString($activeMailProfile,false)); } + foreach($attachments as $file) + { + $mailObject->addAttachment($file); + } if(count($SendAndMergeTocontacts) > 1) { foreach(Mailer::$type2header as $type => $h) diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index e91a0c02ac..cc557d57ec 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -15,6 +15,7 @@ namespace EGroupware\Api\Storage; use DOMDocument; use EGroupware\Api; +use EGroupware\Api\Mail; use EGroupware\Api\Vfs; use EGroupware\Collabora\Conversion; use EGroupware\Stylite; @@ -2091,7 +2092,7 @@ abstract class Merge * @return string with error-message on error * @throws Api\Exception */ - public function merge_file($document, $ids, &$name = '', $dirs = '', &$header = null) + public function merge_file($document, $ids, &$name = '', $dirs = '', &$header = null, $attachments = []) { //error_log(__METHOD__."('$document', ".array2string($ids).", '$name', dirs='$dirs') ->".function_backtrace()); if(($error = $this->check_document($document, $dirs))) @@ -2108,7 +2109,7 @@ abstract class Merge try { $_folder = $this->keep_emails ? '' : FALSE; - $msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder); + $msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder, $import_id, $attachments); } catch (Api\Exception\WrongUserinput $e) { @@ -2484,12 +2485,17 @@ abstract class Merge * * @param string[]|null $ids Allows extending classes to process IDs in their own way. Leave null to pull from request. * @param Merge|null $document_merge Already instantiated Merge object to do the merge. - * @param boolean|null $pdf Convert result to PDF + * @param Array options + * @param boolean options[individual] Instead of merging all entries into the file, merge each entry into its own file + * @param boolean options[pdf] Convert result to PDF + * @param boolean options[link] Link generated file to the entry + * @param boolean $return Return the path of the generated document instead of opening or downloading * @throws Api\Exception * @throws Api\Exception\AssertionFailed */ - public static function merge_entries(array $ids = null, Merge &$document_merge = null, $pdf = null) + public static function merge_entries(array $ids = null, Merge &$document_merge = null, $options = [], bool $return = null) { + // Setup & get what we need if(is_null($document_merge) && class_exists($_REQUEST['merge']) && is_subclass_of($_REQUEST['merge'], 'EGroupware\\Api\\Storage\\Merge')) { $document_merge = new $_REQUEST['merge'](); @@ -2499,10 +2505,17 @@ abstract class Merge $document_merge = new Api\Contacts\Merge(); } - if(($error = $document_merge->check_document($_REQUEST['document'], ''))) + $documents = (array)$_REQUEST['document']; + + // Check for an email + $email = null; + foreach($documents as $key => $d) { - error_log(__METHOD__ . "({$_REQUEST['document']}) $error"); - return; + if(Vfs::mime_content_type($d) == 'message/rfc822') + { + $email = $d; + unset($documents[$key]); + } } if(is_null(($ids))) @@ -2513,14 +2526,116 @@ abstract class Merge { $ids = self::get_all_ids($document_merge); } - - if(is_null($pdf)) + foreach(['pdf', 'individual', 'link'] as $option) { - $pdf = (boolean)$_REQUEST['pdf']; + $$option = is_null($options) ? (boolean)$_REQUEST['options'][$option] : (boolean)$options[$option]; + } + $app = (is_null($options) ? $_REQUEST['options']['app'] : $options['app']) ?? $GLOBALS['egw_info']['flags']['currentapp']; + + if(is_null($return)) + { + $return = (boolean)$_REQUEST['return']; } - $filename = $document_merge->get_filename($_REQUEST['document'], $ids); - $result = $document_merge->merge_file($_REQUEST['document'], $ids, $filename, '', $header); + $id_group = $individual ? $ids : [$ids]; + $merged = $attach = []; + $target = ''; + foreach($id_group as $ids) + { + foreach($documents as $document) + { + if($document != $email) + { + // Generate file + $target = $document_merge->merge_entries_into_document((array)$ids, $document); + } + + // PDF conversion + if($pdf) + { + $converted = $document_merge->pdf_conversion($target); + $target = $converted; + } + $merged[] = $target; + $attach[] = Vfs::PREFIX . $target; + + // Link to entry + if($link) + { + foreach((array)$ids as $id) + { + Api\Link::link($app, $id, Api\Link::VFS_APPNAME, $target); + } + } + } + // One email per id group + if($email) + { + // Trick merge into not trying to open in compose + if(is_string($ids)) + { + $ids = [$ids]; + } + if(count((array)$ids) == 1) + { + $ids[] = null; + } + try + { + $document_merge->merge_entries_into_document($ids, $email, $attach); + } + catch (\Exception $e) + { + // Merge on an email will throw exception if it can't make the file, which is always + $merged[] = str_replace("Unable to generate merge file\n", '', $e->getMessage()); + } + $attach = []; + } + } + + + // Find out what to do with it - can't handle multiple documents directly + if($return || count($merged) > 1) + { + return $merged; + } + + // Merge done, present to user + if($document_merge->get_editable_mimes()[Vfs::mime_content_type($target)] && + !in_array(Vfs::mime_content_type($target), explode(',', $GLOBALS['egw_info']['user']['preferences']['filemanager']['collab_excluded_mimes']))) + { + \Egroupware\Api\Egw::redirect_link('/index.php', array( + 'menuaction' => 'collabora.EGroupware\\Collabora\\Ui.editor', + 'path' => $target + )); + } + else + { + \Egroupware\Api\Egw::redirect_link(Vfs::download_url($target, true)); + } + } + + /** + * Merge the given IDs into the given document, saves to VFS, and returns the path + * + * @param array $ids + * @param $pdf + * @return string|void + * @throws Api\Exception\AssertionFailed + * @throws Api\Exception\NotFound + * @throws Api\Exception\WrongParameter + * @throws Vfs\Exception\ProtectedDirectory + */ + protected function merge_entries_into_document(array $ids = [], $document, $attachments = []) + { + if(($error = $this->check_document($document, ''))) + { + error_log(__METHOD__ . "({$_REQUEST['document']}) $error"); + return; + } + + $filename = $this->get_filename($document, $ids); + $result = $this->merge_file($document, $ids, $filename, '', $header, $attachments); if(!is_file($result) || !is_readable($result)) { @@ -2528,7 +2643,7 @@ abstract class Merge } // Put it into the vfs using user's preferred directory if writable, // or expected home dir (/home/username) if not - $target = $document_merge->get_save_path($filename); + $target = $this->get_save_path($filename); // Make sure we won't overwrite something already there $target = Vfs::make_unique($target); @@ -2536,7 +2651,52 @@ abstract class Merge copy($result, Vfs::PREFIX . $target); unlink($result); - // Find out what to do with it + return $target; + } + + /** + * Convert a file into PDF + * + * Removes the original file + * + * @param $path + * @return mixed|string Path to converted file + * @throws Api\Exception\AssertionFailed + * @throws Api\Exception\NotFound + * @throws Api\Exception\WrongParameter + * @throws Vfs\Exception\ProtectedDirectory + */ + protected function pdf_conversion($path) + { + $editable_mimes = $this->get_editable_mimes(); + if($editable_mimes[Vfs::mime_content_type($path)]) + { + $error = ''; + $converted_path = ''; + $convert = new Conversion(); + $convert->convert($path, $converted_path, 'pdf', $error); + + if($error) + { + error_log(__METHOD__ . "({$_REQUEST['document']}) $path => $converted_path Error in PDF conversion: $error"); + } + else + { + // Remove original + Vfs::unlink($path); + $path = $converted_path; + } + } + return $path; + } + + /** + * Get a list of editable mime types + * + * @return array|String[] + */ + protected function get_editable_mimes() + { $editable_mimes = array(); try { @@ -2554,38 +2714,61 @@ abstract class Merge // ignore failed discovery unset($e); } + return $editable_mimes; + } - // PDF conversion - if($editable_mimes[Vfs::mime_content_type($target)] && $pdf) + + /** + * Merge one or more entries into one or more documents. + * + * @param array|null $ids + * @param \EGroupware\Api\Contacts\Merge|null $document_merge + * @param $documents + * @param $options + * @return string[] location(s) of merged files + */ + public static function ajax_merge_multiple(array $ids = [], $documents = [], $options = []) + { + $response = Api\Json\Response::get(); + $_REQUEST['document'] = $documents; + $app = $options['app'] ?? $GLOBALS['egw_info']['flags']['currentapp']; + $message = implode(', ', Api\Link::titles($app, $ids)) . ":\n"; + + try { - $error = ''; - $converted_path = ''; - $convert = new Conversion(); - $convert->convert($target, $converted_path, 'pdf', $error); + $merge_result = static::merge_entries($ids, $document_merge, $options, true); + } + catch (\Exception $e) + { + $response->error($message . $e->getMessage()); + } - if($error) + foreach($merge_result as $result) + { + if(is_string($result)) { - error_log(__METHOD__ . "({$_REQUEST['document']}) $target => $converted_path Error in PDF conversion: $error"); + if($options['download']) + { + $response->apply('egw.open_link', [Vfs::download_url($result, true), '_browser']); + } + $message .= $result . "\n"; } else { - // Remove original - Vfs::unlink($target); - $target = $converted_path; + if($result['failed']) + { + $response->error($message . join(", ", $result['failed'])); + } + else + { + if($result['success']) + { + $message .= join(", ", $result['success']); + } + } } } - if($editable_mimes[Vfs::mime_content_type($target)] && - !in_array(Vfs::mime_content_type($target), explode(',', $GLOBALS['egw_info']['user']['preferences']['filemanager']['collab_excluded_mimes']))) - { - \Egroupware\Api\Egw::redirect_link('/index.php', array( - 'menuaction' => 'collabora.EGroupware\\Collabora\\Ui.editor', - 'path' => $target - )); - } - else - { - \Egroupware\Api\Egw::redirect_link(Vfs::download_url($target, true)); - } + $response->data($message); } /** From 35fa439a93a348d34579ab43486d2f25c1427745 Mon Sep 17 00:00:00 2001 From: ralf Date: Tue, 21 May 2024 09:46:37 +0200 Subject: [PATCH 05/51] fix PHP Fatal error: Declaration of calendar_merge::merge_entries(?array $ids = null, ?EGroupware\Api\Storage\Merge &$document_merge = null, $pdf = null) must be compatible with EGroupware\Api\Storage\Merge::merge_entries(?array $ids = null, ?EGroupware\Api\Storage\Merge &$document_merge = null, $options = [], ?bool $return = null) --- calendar/inc/class.calendar_merge.inc.php | 27 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/calendar/inc/class.calendar_merge.inc.php b/calendar/inc/class.calendar_merge.inc.php index 978ef11eb0..c27016f7e6 100644 --- a/calendar/inc/class.calendar_merge.inc.php +++ b/calendar/inc/class.calendar_merge.inc.php @@ -13,6 +13,7 @@ */ use EGroupware\Api; +use EGroupware\Api\Storage\Merge; /** * Calendar - document merge object @@ -129,7 +130,21 @@ class calendar_merge extends Api\Storage\Merge return parent::merge_string($content, $ids, $err, $mimetype, $fix, $charset); } - public static function merge_entries(array $ids = null, \EGroupware\Api\Storage\Merge &$document_merge = null, $pdf = null) + /** + * Merge the selected IDs into the given document, save it to the VFS, then + * either open it in the editor or have the browser download the file. + * + * @param string[]|null $ids Allows extending classes to process IDs in their own way. Leave null to pull from request. + * @param Merge|null $document_merge Already instantiated Merge object to do the merge. + * @param Array options + * @param boolean options[individual] Instead of merging all entries into the file, merge each entry into its own file + * @param boolean options[pdf] Convert result to PDF + * @param boolean options[link] Link generated file to the entry + * @param boolean $return Return the path of the generated document instead of opening or downloading + * @throws Api\Exception + * @throws Api\Exception\AssertionFailed + */ + public static function merge_entries(array $ids = null, Merge &$document_merge = null, $options = [], bool $return = null) { $document_merge = new calendar_merge(); @@ -195,15 +210,15 @@ class calendar_merge extends Api\Storage\Merge // Fall through default: $timespan = array(array( - 'start' => $first, - 'end' => $last - )); + 'start' => $first, + 'end' => $last + )); } // Add path into document static::check_document($_REQUEST['document'], $GLOBALS['egw_info']['user']['preferences']['calendar']['document_dir']); - return \EGroupware\Api\Storage\Merge::merge_entries(array_key_exists('0', $ids) ? $ids : $timespan, $document_merge); + return parent::merge_entries(array_key_exists('0', $ids) ? $ids : $timespan, $document_merge, $options, $return); } public function get_filename_placeholders($document, $ids) @@ -1238,4 +1253,4 @@ class calendar_merge extends Api\Storage\Merge } return $placeholders; } -} +} \ No newline at end of file From 6f2f0b71ec197e8320e70924e77e67a5433a760d Mon Sep 17 00:00:00 2001 From: ralf Date: Tue, 21 May 2024 14:31:35 +0200 Subject: [PATCH 06/51] some more changes to the eTemplate2 DTD --- doc/etemplate2-rng.php | 10 + doc/etemplate2/etemplate2.0.dtd | 180 ++++++++----- doc/etemplate2/etemplate2.0.rng | 457 +++++++++++++++++++++++--------- 3 files changed, 461 insertions(+), 186 deletions(-) diff --git a/doc/etemplate2-rng.php b/doc/etemplate2-rng.php index 4ad443e3e2..cdf87cead4 100755 --- a/doc/etemplate2-rng.php +++ b/doc/etemplate2-rng.php @@ -54,6 +54,7 @@ $overwrites = [ 'Et2InputWidget' => [ '.attrs' => [ 'tabindex' => 'int', + 'value' => 'string', ], ], 'Et2Textbox' => [ @@ -61,8 +62,12 @@ $overwrites = [ 'placeholder' => 'string', 'maxlength' => 'int', 'size' => 'int', + 'type' => 'string', ], ], + 'et2-textbox' => [ + '.children' => ['.quantity' => 'optional', 'et2-image'], + ], 'Et2InvokerMixin' => 'Et2TextBox', 'et2-description' => [ '.attrs' => [ @@ -75,6 +80,7 @@ $overwrites = [ 'rows' => 'int', 'resizeRatio' => 'number', // is this correct 'size' => 'int', + 'placeholder' => 'string', ], ], 'et2-date' => [ @@ -111,6 +117,9 @@ $overwrites = [ 'et2-tab-panel' => null, 'et2-details' => [ '.children' => 'Widgets', + '.attrs' => [ + 'summary' => 'string', + ], ], 'et2-split' => [ '.children' => 'Widgets', @@ -129,6 +138,7 @@ $overwrites = [ '.attrs' => [ 'image' => 'string', 'noSubmit' => 'boolean', + 'hideOnReadonly' => 'boolean', ], ], 'Et2ButtonIcon' => 'Et2Button', // no inheritance from Et2Button, but Et2ButtonMixin, which is not recognised diff --git a/doc/etemplate2/etemplate2.0.dtd b/doc/etemplate2/etemplate2.0.dtd index 254acf8f8e..202116ed10 100644 --- a/doc/etemplate2/etemplate2.0.dtd +++ b/doc/etemplate2/etemplate2.0.dtd @@ -25,11 +25,11 @@ |tracker-value|records-value|hidden|radio|radiogroup|diff|styles|customfields|customfields-list|html |htmlarea|toolbar|historylog|hrule|file|progress|vfs|vfs-name|vfs-size|vfs-mode|vfs-upload|video |audio|barcode|itempicker|script|countdown|customfields-types|nextmatch|nextmatch-header - |nextmatch-customfields|nextmatch-sortheader|et2-avatar|et2-avatar-group|et2-lavatar|et2-button - |et2-button-icon|et2-button-scroll|et2-button-timestamp|et2-checkbox|et2-colorpicker|et2-date + |nextmatch-customfields|nextmatch-sortheader|et2-avatar|et2-avatar-group|et2-lavatar|et2-checkbox + |et2-button|et2-button-icon|et2-button-scroll|et2-button-timestamp|et2-colorpicker|et2-date |et2-date-duration|et2-date-range|et2-date-since|et2-date-time|et2-date-timeonly|et2-date-time-today - |et2-description|et2-label|et2-dialog|et2-dropdown-button|et2-email|et2-favorites|et2-iframe - |et2-appicon|et2-image|et2-link|et2-link-add|et2-link-apps|et2-link-entry|et2-link-list + |et2-description|et2-label|et2-dialog|et2-merge-dialog|et2-dropdown-button|et2-email|et2-favorites + |et2-iframe|et2-appicon|et2-image|et2-link|et2-link-add|et2-link-apps|et2-link-entry|et2-link-list |et2-link-paste-dialog|et2-link-search|et2-link-string|et2-link-to|et2-portlet|et2-listbox |et2-select|et2-spinner|et2-switch|et2-textarea|et2-number|et2-password|et2-searchbox|et2-textbox |et2-tree|et2-tree-dropdown|et2-tree-cat|et2-url|et2-url-email|et2-url-fax|et2-url-phone @@ -1385,6 +1385,38 @@ span CDATA #IMPLIED slot CDATA #IMPLIED> + + + + + hideOnReadonly (false|true|1) #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1445,7 +1479,9 @@ slot CDATA #IMPLIED image CDATA #IMPLIED noSubmit (false|true|1) #IMPLIED - tabindex CDATA #IMPLIED> + hideOnReadonly (false|true|1) #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1456,7 +1492,8 @@ span CDATA #IMPLIED slot CDATA #IMPLIED image CDATA #IMPLIED - noSubmit (false|true|1) #IMPLIED> + noSubmit (false|true|1) #IMPLIED + hideOnReadonly (false|true|1) #IMPLIED> @@ -1490,38 +1527,9 @@ slot CDATA #IMPLIED image CDATA #IMPLIED noSubmit (false|true|1) #IMPLIED - tabindex CDATA #IMPLIED> - - - - + hideOnReadonly (false|true|1) #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1550,7 +1558,8 @@ height CDATA #IMPLIED span CDATA #IMPLIED slot CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1585,7 +1594,8 @@ slot CDATA #IMPLIED yearRange CDATA #IMPLIED dataFormat CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1622,7 +1632,8 @@ height CDATA #IMPLIED span CDATA #IMPLIED slot CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1708,7 +1719,8 @@ height CDATA #IMPLIED span CDATA #IMPLIED slot CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1741,7 +1753,8 @@ height CDATA #IMPLIED span CDATA #IMPLIED slot CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -1849,6 +1862,28 @@ span CDATA #IMPLIED slot CDATA #IMPLIED> + + + + + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2518,7 +2554,9 @@ rows CDATA #IMPLIED resizeRatio CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + placeholder CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2556,7 +2594,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2592,7 +2632,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2628,9 +2670,11 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> - + + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2815,7 +2861,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2850,7 +2898,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2884,7 +2934,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2918,7 +2970,9 @@ placeholder CDATA #IMPLIED maxlength CDATA #IMPLIED size CDATA #IMPLIED - tabindex CDATA #IMPLIED> + type CDATA #IMPLIED + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -2983,7 +3037,7 @@ + slot CDATA #IMPLIED + summary CDATA #IMPLIED> @@ -4341,7 +4396,8 @@ span CDATA #IMPLIED slot CDATA #IMPLIED emptyLabel CDATA #IMPLIED - tabindex CDATA #IMPLIED> + tabindex CDATA #IMPLIED + value CDATA #IMPLIED> @@ -4418,4 +4474,4 @@ slot CDATA #IMPLIED rows CDATA #IMPLIED tabindex CDATA #IMPLIED - allowFreeEntries (false|true|1) #IMPLIED> \ No newline at end of file + allowFreeEntries (false|true|1) #IMPLIED> diff --git a/doc/etemplate2/etemplate2.0.rng b/doc/etemplate2/etemplate2.0.rng index 3d508d82f5..2c1aa31e61 100644 --- a/doc/etemplate2/etemplate2.0.rng +++ b/doc/etemplate2/etemplate2.0.rng @@ -66,11 +66,11 @@ + - @@ -82,6 +82,7 @@ + @@ -4953,6 +4954,134 @@ + + + + + + + + + + + + + + + + + + + + false + true + 1 + + + + + + + false + true + 1 + + + + + + + + + + false + true + 1 + + + + + + + + + + + + + + + + + + + + + + false + true + 1 + + + + + + + false + true + 1 + + + + + + + + + + + + + + + + + + + false + true + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5080,9 +5209,21 @@ + + + + false + true + 1 + + + + + + @@ -5211,9 +5352,21 @@ + + + + false + true + 1 + + + + + + @@ -5249,6 +5402,15 @@ + + + + false + true + 1 + + + @@ -5387,27 +5549,7 @@ - - - - - - - - - - - - - - - - - - - - - + false true @@ -5415,105 +5557,12 @@ - - - - false - true - 1 - - - - - - - - - - false - true - 1 - - - - - - - - - - - - - - - - - - - - - - false - true - 1 - - - - - - - false - true - 1 - - - - - - - - - - - - - - - - - - - false - true - 1 - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -5633,6 +5682,9 @@ + + + @@ -5782,6 +5834,9 @@ + + + @@ -5949,6 +6004,9 @@ + + + @@ -6303,6 +6361,9 @@ + + + @@ -6446,6 +6507,9 @@ + + + @@ -6851,6 +6915,86 @@ + + + + + + + + + + + + + + + + + + + + false + true + 1 + + + + + + + false + true + 1 + + + + + + + + + + + + + + + + + + + + + + false + true + 1 + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6981,7 +7125,7 @@ - + @@ -8342,7 +8486,7 @@ - + @@ -9403,6 +9547,9 @@ + + + @@ -9534,9 +9681,15 @@ + + + + + + @@ -9680,9 +9833,15 @@ + + + + + + @@ -9826,9 +9985,15 @@ + + + + + + @@ -9978,14 +10143,22 @@ + + + + + + - + + + @@ -10112,9 +10285,15 @@ + + + + + + @@ -10741,9 +10920,15 @@ + + + + + + @@ -10878,9 +11063,15 @@ + + + + + + @@ -11012,9 +11203,15 @@ + + + + + + @@ -11146,9 +11343,15 @@ + + + + + + @@ -11390,7 +11593,7 @@ - + @@ -11545,7 +11748,7 @@ - + @@ -16295,6 +16498,9 @@ + + + @@ -16979,6 +17185,9 @@ + + + @@ -17299,4 +17508,4 @@ - \ No newline at end of file + From 92efbd75fdfbeb016a4b089f322b904cd904a97b Mon Sep 17 00:00:00 2001 From: ralf Date: Tue, 21 May 2024 18:51:16 +0200 Subject: [PATCH 07/51] add style attribute for grid row --- api/js/etemplate/et2_widget_grid.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/api/js/etemplate/et2_widget_grid.ts b/api/js/etemplate/et2_widget_grid.ts index 77c260a54c..6a8266619f 100644 --- a/api/js/etemplate/et2_widget_grid.ts +++ b/api/js/etemplate/et2_widget_grid.ts @@ -197,7 +197,8 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl class: "", valign: "top", span: "1", - disabled: false + disabled: false, + style: "" }; } @@ -285,6 +286,7 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl rowDataEntry["valign"] = et2_readAttrWithDefault(node, "valign", ""); rowDataEntry["span"] = et2_readAttrWithDefault(node, "span", "1"); rowDataEntry["part"] = et2_readAttrWithDefault(node, "part", "body"); + rowDataEntry["style"] = et2_readAttrWithDefault(node, "style", ""); const id = et2_readAttrWithDefault(node, "id", ""); if(id) @@ -549,6 +551,10 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl { this._getCell(cells, x, y).rowData.class = this.getArrayMgr("content").expandName(this._getCell(cells, x, y).rowData.class); } + if(this._getCell(cells, x, y).rowData.style) + { + this._getCell(cells, x, y).rowData.style = this.getArrayMgr("content").expandName(this._getCell(cells, x, y).rowData.style); + } } if(!nm && typeof cell.disabled === 'string') @@ -771,6 +777,11 @@ export class et2_grid extends et2_DOMWidget implements et2_IDetachedDOM, et2_IAl tr.attr("valign", this.rowData[y].valign); } + if(this.rowData[y].style) + { + tr.attr("style", this.rowData[y].style); + } + if(this.rowData[y].id) { tr.attr("id", this.rowData[y].id); @@ -1226,5 +1237,6 @@ interface RowEntry class : string, // "", valign : string, // "top", span : string | number, // "1", - disabled : boolean // false + disabled : boolean, // false + style: string } \ No newline at end of file From a127860ad66a1cfd0ffd84f07c35bc3676d6607b Mon Sep 17 00:00:00 2001 From: ralf Date: Tue, 21 May 2024 19:15:01 +0200 Subject: [PATCH 08/51] fix small 12pt font-size on login inputs after custom font introduction --- pixelegg/css/mobile.css | 2 +- pixelegg/css/monochrome.css | 2 +- pixelegg/css/monochrome.less | 1 - pixelegg/css/pixelegg.css | 2 +- pixelegg/less/layout_loginPage.less | 1 + pixelegg/mobile/fw_mobile.css | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pixelegg/css/mobile.css b/pixelegg/css/mobile.css index 5f2fe7429a..9346c81f56 100644 --- a/pixelegg/css/mobile.css +++ b/pixelegg/css/mobile.css @@ -27,7 +27,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @author Stefan Reinhard * @package pixelegg - * @version $Id$ */ /** * addapted from orginal styles.php @@ -2198,6 +2197,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form { border-bottom: 1px solid silver; padding-left: 25px; background-color: transparent; + font-size: 100%; } #loginMainDiv div#centerBox form table.divLoginbox input:focus { outline: none; diff --git a/pixelegg/css/monochrome.css b/pixelegg/css/monochrome.css index 2c34909f45..285028bd17 100644 --- a/pixelegg/css/monochrome.css +++ b/pixelegg/css/monochrome.css @@ -7,7 +7,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @author Stefan Reinhard * @package pixelegg - * @version $Id$ */ /** * addapted from orginal styles.php @@ -2178,6 +2177,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form { border-bottom: 1px solid silver; padding-left: 25px; background-color: transparent; + font-size: 100%; } #loginMainDiv div#centerBox form table.divLoginbox input:focus { outline: none; diff --git a/pixelegg/css/monochrome.less b/pixelegg/css/monochrome.less index 7ed6e2d473..c0520c299c 100644 --- a/pixelegg/css/monochrome.less +++ b/pixelegg/css/monochrome.less @@ -7,7 +7,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @author Stefan Reinhard * @package pixelegg - * @version $Id$ */ /** * addapted from orginal styles.php diff --git a/pixelegg/css/pixelegg.css b/pixelegg/css/pixelegg.css index 55f796ba02..5c64dbd181 100644 --- a/pixelegg/css/pixelegg.css +++ b/pixelegg/css/pixelegg.css @@ -17,7 +17,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @author Stefan Reinhard * @package pixelegg - * @version $Id$ */ /** * addapted from orginal styles.php @@ -2188,6 +2187,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form { border-bottom: 1px solid silver; padding-left: 25px; background-color: transparent; + font-size: 100%; } #loginMainDiv div#centerBox form table.divLoginbox input:focus { outline: none; diff --git a/pixelegg/less/layout_loginPage.less b/pixelegg/less/layout_loginPage.less index 2ed02c2414..1b8f6aaa1a 100644 --- a/pixelegg/less/layout_loginPage.less +++ b/pixelegg/less/layout_loginPage.less @@ -341,6 +341,7 @@ div#loginMainDiv.stockLoginBackground { border-bottom: 1px solid silver; padding-left: 25px; background-color: transparent; + font-size: 100%; } input:hover {} input:focus { diff --git a/pixelegg/mobile/fw_mobile.css b/pixelegg/mobile/fw_mobile.css index 2ca604b95b..84ea311903 100644 --- a/pixelegg/mobile/fw_mobile.css +++ b/pixelegg/mobile/fw_mobile.css @@ -188,7 +188,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @author Stefan Reinhard * @package pixelegg - * @version $Id$ */ /** * addapted from orginal styles.php @@ -2209,6 +2208,7 @@ div#loginMainDiv.stockLoginBackground div#centerBox form { border-bottom: 1px solid silver; padding-left: 25px; background-color: transparent; + font-size: 100%; } #loginMainDiv div#centerBox form table.divLoginbox input:focus { outline: none; From e596d60395007807d62070766f2e333a24751abe Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 21 May 2024 10:45:17 -0600 Subject: [PATCH 09/51] Et2LinkTo: Fix Link button did not show after selecting an entry Broken by ba744d32923995aa0317a50afb32c04058d414fb --- api/js/etemplate/Et2Link/Et2LinkTo.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/js/etemplate/Et2Link/Et2LinkTo.ts b/api/js/etemplate/Et2Link/Et2LinkTo.ts index 04e26af8ca..2aa3035241 100644 --- a/api/js/etemplate/Et2Link/Et2LinkTo.ts +++ b/api/js/etemplate/Et2Link/Et2LinkTo.ts @@ -437,7 +437,10 @@ export class Et2LinkTo extends Et2InputWidget(LitElement) handleEntrySelected(event) { // Could be the app, could be they selected an entry - if(event.target == this.select._searchNode) + if(event.target == this.select && ( + typeof this.select.value == "string" && this.select.value || + typeof this.select.value == "object" && this.select.value.id + )) { this.classList.add("can_link"); this.link_button.focus(); From 700cb987eaa83705bf6b8615525ecf09d3734280 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 21 May 2024 11:40:14 -0600 Subject: [PATCH 10/51] Fix calendar did not work with merge changes ff94af1f7d650357e22caac78b46ea9dd83140e3 --- api/js/jsapi/egw_app.ts | 2 +- calendar/inc/class.calendar_merge.inc.php | 121 +++++++++++----------- 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index 91abf38aff..78a6a7e9cb 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -921,7 +921,7 @@ export abstract class EgwApp let fileSelect = loadWebComponent('et2-merge-dialog', { application: this.appname, path: dirs.pop() || "" - }, et2.widgetContainer); + }, et2?.widgetContainer ?? null); if(!et2) { document.body.append(fileSelect); diff --git a/calendar/inc/class.calendar_merge.inc.php b/calendar/inc/class.calendar_merge.inc.php index c27016f7e6..1c762a0472 100644 --- a/calendar/inc/class.calendar_merge.inc.php +++ b/calendar/inc/class.calendar_merge.inc.php @@ -153,70 +153,73 @@ class calendar_merge extends Api\Storage\Merge $ids = json_decode($_REQUEST['id'], true); } - // Try to make time span into appropriate ranges to match - $template = $ids['view'] ?: ''; - if(stripos($_REQUEST['document'], 'month') !== false || stripos($_REQUEST['document'], lang('month')) !== false) + foreach($_REQUEST['document'] as &$document) { - $template = 'month'; - } - if(stripos($_REQUEST['document'], 'week') !== false || stripos($_REQUEST['document'], lang('week')) !== false) - { - $template = 'week'; - } + // Try to make time span into appropriate ranges to match + $template = $ids['view'] ?: ''; + if(stripos($document, 'month') !== false || stripos($document, lang('month')) !== false) + { + $template = 'month'; + } + if(stripos($document, 'week') !== false || stripos($document, lang('week')) !== false) + { + $template = 'week'; + } - //error_log("Detected template $template"); - $date = $ids['date']; - $first = $ids['first']; - $last = $ids['last']; + //error_log("Detected template $template"); + $date = $ids['date']; + $first = $ids['first']; + $last = $ids['last']; - // Pull dates from session if they're not in the request - if(!array_key_exists('first', $ids)) - { - $ui = new calendar_ui(); - $date = $ui->date; - $first = $ui->first; - $last = $ui->last; - } - switch($template) - { - case 'month': - // Trim to _only_ the month, do not pad to week start / end - $time = new Api\DateTime($date); - $timespan = array(array( - 'start' => Api\DateTime::to($time->format('Y-m-01 00:00:00'), 'ts'), - 'end' => Api\DateTime::to($time->format('Y-m-t 23:59:59'), 'ts') - )); - break; - case 'week': - $timespan = array(); - $start = new Api\DateTime($first); - $end = new Api\DateTime($last); - $t = clone $start; - $t->modify('+1 week')->modify('-1 second'); - if($t < $end) - { - do - { - $timespan[] = array( - 'start' => $start->format('ts'), - 'end' => $t->format('ts') - ); - $start->modify('+1 week'); - $t->modify('+1 week'); - } - while($start < $end); + // Pull dates from session if they're not in the request + if(!array_key_exists('first', $ids)) + { + $ui = new calendar_ui(); + $date = $ui->date; + $first = $ui->first; + $last = $ui->last; + } + switch($template) + { + case 'month': + // Trim to _only_ the month, do not pad to week start / end + $time = new Api\DateTime($date); + $timespan = array(array( + 'start' => Api\DateTime::to($time->format('Y-m-01 00:00:00'), 'ts'), + 'end' => Api\DateTime::to($time->format('Y-m-t 23:59:59'), 'ts') + )); break; - } - // Fall through - default: - $timespan = array(array( - 'start' => $first, - 'end' => $last - )); - } + case 'week': + $timespan = array(); + $start = new Api\DateTime($first); + $end = new Api\DateTime($last); + $t = clone $start; + $t->modify('+1 week')->modify('-1 second'); + if($t < $end) + { + do + { + $timespan[] = array( + 'start' => $start->format('ts'), + 'end' => $t->format('ts') + ); + $start->modify('+1 week'); + $t->modify('+1 week'); + } + while($start < $end); + break; + } + // Fall through + default: + $timespan = array(array( + 'start' => $first, + 'end' => $last + )); + } - // Add path into document - static::check_document($_REQUEST['document'], $GLOBALS['egw_info']['user']['preferences']['calendar']['document_dir']); + // Add path into document + static::check_document($document, $GLOBALS['egw_info']['user']['preferences']['calendar']['document_dir']); + } return parent::merge_entries(array_key_exists('0', $ids) ? $ids : $timespan, $document_merge, $options, $return); } From aeec9263efa8a9f2319d75d4bf4118f4391effa4 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 21 May 2024 15:10:19 -0600 Subject: [PATCH 11/51] Merge document fixes - Give some feedback for single document - Fix line breaks in long task messages - Fix link merged document to entries --- api/js/jsapi/egw_app.ts | 1 + api/src/Storage/Merge.php | 6 +++--- api/templates/default/etemplate2.css | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index 78a6a7e9cb..50660b7834 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -871,6 +871,7 @@ export abstract class EgwApp { // Handle it all on the server in one request mergedFiles = await this.egw.request(vars.menuaction, [vars.id, vars.document, vars.options]); + this.egw.message(mergedFiles, "success"); } else { diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index cc557d57ec..feb55eafa9 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -2528,7 +2528,7 @@ abstract class Merge } foreach(['pdf', 'individual', 'link'] as $option) { - $$option = is_null($options) ? (boolean)$_REQUEST['options'][$option] : (boolean)$options[$option]; + $$option = is_null($options) || empty($options) ? (boolean)$_REQUEST['options'][$option] : (boolean)$options[$option]; } $app = (is_null($options) ? $_REQUEST['options']['app'] : $options['app']) ?? $GLOBALS['egw_info']['flags']['currentapp']; @@ -2564,7 +2564,7 @@ abstract class Merge { foreach((array)$ids as $id) { - Api\Link::link($app, $id, Api\Link::VFS_APPNAME, $target); + Api\Link::link($app, $id, Api\Link::VFS_LINK, Vfs::PREFIX . $target); } } } @@ -3237,7 +3237,7 @@ abstract class Merge 'type' => 'taglist', 'label' => 'Merged document filename', 'name' => self::PREF_DOCUMENT_FILENAME, - 'values' => self::DOCUMENT_FILENAME_OPTIONS, + 'values' => static::DOCUMENT_FILENAME_OPTIONS, 'help' => 'Choose the default filename for merged documents.', 'xmlrpc' => True, 'admin' => False, diff --git a/api/templates/default/etemplate2.css b/api/templates/default/etemplate2.css index 57ac9cddad..2e35748a27 100644 --- a/api/templates/default/etemplate2.css +++ b/api/templates/default/etemplate2.css @@ -2770,6 +2770,7 @@ table.egwGridView_outer thead tr th.noResize:hover { } .long_task .message { + white-space: break-spaces; height: inherit; display: list-item; border: none; From 7858ed8fae1ab82773a12f0ea6f1a9edf8d511a3 Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 22 May 2024 10:36:29 -0600 Subject: [PATCH 12/51] Fix multi-merge into email did not save to VFS --- api/src/Storage/Merge.php | 62 ++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index feb55eafa9..5c3c4b28ac 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -2092,7 +2092,7 @@ abstract class Merge * @return string with error-message on error * @throws Api\Exception */ - public function merge_file($document, $ids, &$name = '', $dirs = '', &$header = null, $attachments = []) + public function merge_file($document, $ids, &$name = '', $dirs = '', &$header = null) { //error_log(__METHOD__."('$document', ".array2string($ids).", '$name', dirs='$dirs') ->".function_backtrace()); if(($error = $this->check_document($document, $dirs))) @@ -2109,7 +2109,7 @@ abstract class Merge try { $_folder = $this->keep_emails ? '' : FALSE; - $msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder, $import_id, $attachments); + $msgs = $mail_bo->importMessageToMergeAndSend($this, $content_url, $ids, $_folder, $import_id); } catch (Api\Exception\WrongUserinput $e) { @@ -2517,6 +2517,11 @@ abstract class Merge unset($documents[$key]); } } + if($email) + { + $mail_bo = Api\Mail::getInstance(); + $mail_bo->openConnection(); + } if(is_null(($ids))) { @@ -2559,35 +2564,64 @@ abstract class Merge $merged[] = $target; $attach[] = Vfs::PREFIX . $target; - // Link to entry + // Move to entry if($link) { foreach((array)$ids as $id) { - Api\Link::link($app, $id, Api\Link::VFS_LINK, Vfs::PREFIX . $target); + Api\Link::link($app, $id, Api\Link::VFS_APPNAME, Vfs::PREFIX . $target); } } } // One email per id group - if($email) + if($email && $mail_bo) { // Trick merge into not trying to open in compose - if(is_string($ids)) + $mail_ids = $ids; + if(is_string($mail_ids)) { - $ids = [$ids]; + $mail_ids = [$mail_ids]; } - if(count((array)$ids) == 1) + if(count((array)$mail_ids) == 1) { - $ids[] = null; + $mail_ids[] = null; } try { - $document_merge->merge_entries_into_document($ids, $email, $attach); + // Special email handling so we can grab it and stick it where we want + $mail_folder = $document_merge->keep_emails ? '' : FALSE; + $mail_id = ''; + $msgs = $mail_bo->importMessageToMergeAndSend($document_merge, Api\Vfs::PREFIX . $email, $mail_ids, $mail_folder, $mail_id, $attach); } catch (\Exception $e) { - // Merge on an email will throw exception if it can't make the file, which is always - $merged[] = str_replace("Unable to generate merge file\n", '', $e->getMessage()); + throw new Api\Exception("Unable to send email", 100, $e); + } + // Save to VFS so we can link to entry + if($link) + { + // Load message + $message = $mail_bo->getMessageRawBody($mail_id, '', $mail_folder); + if(!$message) + { + throw new Api\Exception\AssertionFailed("Unable to read merged email\n" . $mail_folder . "/$mail_id"); + } + + $filename = $document_merge->get_filename($email, (array)$ids); + if(!str_ends_with($filename, pathinfo($email, PATHINFO_EXTENSION))) + { + $filename .= '.' . pathinfo($email, PATHINFO_EXTENSION); + } + foreach((array)$ids as $id) + { + $target = Api\Link::vfs_path($app, $id, $filename); + + // Make sure we won't overwrite something already there + $target = Vfs::make_unique($target); + + file_put_contents(Vfs::PREFIX . $target, $message); + $merged[] = $target; + } } $attach = []; } @@ -2626,7 +2660,7 @@ abstract class Merge * @throws Api\Exception\WrongParameter * @throws Vfs\Exception\ProtectedDirectory */ - protected function merge_entries_into_document(array $ids = [], $document, $attachments = []) + protected function merge_entries_into_document(array $ids = [], $document) { if(($error = $this->check_document($document, ''))) { @@ -2635,7 +2669,7 @@ abstract class Merge } $filename = $this->get_filename($document, $ids); - $result = $this->merge_file($document, $ids, $filename, '', $header, $attachments); + $result = $this->merge_file($document, $ids, $filename, '', $header); if(!is_file($result) || !is_readable($result)) { From 20da951b59d2b82725bccad6f9786df4cc78a066 Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 23 May 2024 14:39:19 -0600 Subject: [PATCH 13/51] Merge changes - Disable individual checkbox when only one entry is selected - One entry + email document opens compose with other documents attached instead of sending directly - Download button gives emails too --- api/js/jsapi/egw_app.ts | 25 +++++++++++++++++++--- api/src/Storage/Merge.php | 45 ++++++++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index 50660b7834..cbb13095cd 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -25,6 +25,7 @@ import type {EgwAction} from "../egw_action/EgwAction"; import {Et2MergeDialog} from "../etemplate/Et2Dialog/Et2MergeDialog"; import {EgwActionObject} from "../egw_action/EgwActionObject"; import type {Et2Details} from "../etemplate/Layout/Et2Details/Et2Details"; +import {Et2Checkbox} from "../etemplate/Et2Checkbox/Et2Checkbox"; /** * Type for push-message @@ -867,11 +868,24 @@ export abstract class EgwApp let email = document.documents.find(f => f.mime == "message/rfc822"); // Can we do this in one, or do we have to split it up for feedback? - if(!vars.options.individual && !email) + if(!vars.options.individual && (!email || email && !all && ids.length == 1)) { + vars.options.open_email = !vars.options.download && typeof email != "undefined"; + // Handle it all on the server in one request + this.egw.loading_prompt(vars.menuaction, true); mergedFiles = await this.egw.request(vars.menuaction, [vars.id, vars.document, vars.options]); - this.egw.message(mergedFiles, "success"); + this.egw.loading_prompt(vars.menuaction, false); + + // One entry, email template selected - we can open that in the compose window + if(email) + { + debugger; + } + else + { + this.egw.message(mergedFiles, "success"); + } } else { @@ -927,11 +941,16 @@ export abstract class EgwApp { document.body.append(fileSelect); } - // Start details open when you have multiple selected + // Customize dialog fileSelect.updateComplete.then(() => { + // Start details open when you have multiple selected // @ts-ignore (fileSelect.shadowRoot.querySelector('et2-details')).open = selected.length > 1; + + // Disable individual when only one entry is selected + // @ts-ignore + (fileSelect.shadowRoot.querySelector("et2-details > [id='individual']")).disabled = selected.length == 1; }); // Remove when done fileSelect.getComplete().then(() => {fileSelect.remove();}); diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index 5c3c4b28ac..a95a367399 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -2531,7 +2531,7 @@ abstract class Merge { $ids = self::get_all_ids($document_merge); } - foreach(['pdf', 'individual', 'link'] as $option) + foreach(['pdf', 'individual', 'link', 'download'] as $option) { $$option = is_null($options) || empty($options) ? (boolean)$_REQUEST['options'][$option] : (boolean)$options[$option]; } @@ -2582,14 +2582,14 @@ abstract class Merge { $mail_ids = [$mail_ids]; } - if(count((array)$mail_ids) == 1) + if(count((array)$mail_ids) == 1 && !$open_email) { $mail_ids[] = null; } try { // Special email handling so we can grab it and stick it where we want - $mail_folder = $document_merge->keep_emails ? '' : FALSE; + $mail_folder = $document_merge->keep_emails ? (count($id_group) == 1 ? $mail_bo->getDraftFolder() : '') : FALSE; $mail_id = ''; $msgs = $mail_bo->importMessageToMergeAndSend($document_merge, Api\Vfs::PREFIX . $email, $mail_ids, $mail_folder, $mail_id, $attach); } @@ -2598,7 +2598,7 @@ abstract class Merge throw new Api\Exception("Unable to send email", 100, $e); } // Save to VFS so we can link to entry - if($link) + if($link || $download) { // Load message $message = $mail_bo->getMessageRawBody($mail_id, '', $mail_folder); @@ -2612,28 +2612,51 @@ abstract class Merge { $filename .= '.' . pathinfo($email, PATHINFO_EXTENSION); } - foreach((array)$ids as $id) + if($download) { - $target = Api\Link::vfs_path($app, $id, $filename); + $target = $document_merge->get_save_path($filename); // Make sure we won't overwrite something already there $target = Vfs::make_unique($target); - file_put_contents(Vfs::PREFIX . $target, $message); $merged[] = $target; } + if($link) + { + foreach((array)$ids as $id) + { + $target = Api\Link::vfs_path($app, $id, $filename); + + // Make sure we won't overwrite something already there + $target = Vfs::make_unique($target); + + file_put_contents(Vfs::PREFIX . $target, $message); + if(!$download) + { + $merged[] = $target; + } + } + } } $attach = []; } } - // Find out what to do with it - can't handle multiple documents directly if($return || count($merged) > 1) { return $merged; } - + // Open email in compose? + if($email && count($id_group) == 1 && $mail_id && class_exists("mail_ui")) + { + $mail_uid = \mail_ui::generateRowID($mail_bo->profileID, $mail_folder, $mail_id); + $mail_popup = ''; + $mail_info = Api\Link::edit('mail', $mail_uid, $mail_popup); + $mail_info['from'] = 'composefromdraft'; + Api\Framework::popup(Api\Framework::link("/index.php", $mail_info), '_blank', $mail_popup); + return; + } // Merge done, present to user if($document_merge->get_editable_mimes()[Vfs::mime_content_type($target)] && !in_array(Vfs::mime_content_type($target), explode(',', $GLOBALS['egw_info']['user']['preferences']['filemanager']['collab_excluded_mimes']))) @@ -2767,10 +2790,12 @@ abstract class Merge $_REQUEST['document'] = $documents; $app = $options['app'] ?? $GLOBALS['egw_info']['flags']['currentapp']; $message = implode(', ', Api\Link::titles($app, $ids)) . ":\n"; + $return = true; + $open_email = $options['open_email']; try { - $merge_result = static::merge_entries($ids, $document_merge, $options, true); + $merge_result = static::merge_entries($ids, $document_merge, $options, !$open_email); } catch (\Exception $e) { From 08826c98d3be07bdfa6ef62393b298731782ed87 Mon Sep 17 00:00:00 2001 From: StefanU Date: Fri, 24 May 2024 11:06:14 +0200 Subject: [PATCH 14/51] Update accounts.svg Change accounts.svg with single.svg. Gender-neutral w/and without tie --- api/templates/default/images/accounts.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/templates/default/images/accounts.svg b/api/templates/default/images/accounts.svg index 5af6b81ffa..19c3b07637 100644 --- a/api/templates/default/images/accounts.svg +++ b/api/templates/default/images/accounts.svg @@ -1,4 +1,4 @@ - + From b2476816f158647766f27d2e27b9d2cef4824179 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 24 May 2024 15:22:51 -0600 Subject: [PATCH 15/51] Merge dialog - Add a hint that selecting from multiple directories isn't supported - Add "Email" to mime selection - Translations from Birgit - Missing translations --- api/js/etemplate/Et2Dialog/Et2MergeDialog.ts | 12 ++++---- api/js/etemplate/Et2Vfs/Et2VfsSelectDialog.ts | 28 +++++++++++++++---- api/js/etemplate/Et2Vfs/Et2VfsSelectRow.ts | 8 +++++- api/lang/egw_de.lang | 13 +++++++++ api/lang/egw_en.lang | 13 +++++++++ 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts b/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts index 0938c7fbbf..b8106b801b 100644 --- a/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts +++ b/api/js/etemplate/Et2Dialog/Et2MergeDialog.ts @@ -119,26 +119,26 @@ export class Et2MergeDialog extends Et2Widget(LitElement) class=egw_app_merge_document" path=${this.path} multiple="true" - buttonLabel="Download" + buttonLabel=${this.egw().lang("Download")} .title="${this.egw().lang("Insert in document")}" .open=${true} @et2-select=${this.handleFileSelect} > ${this.canEmail ? html` - ` : html` - ` } ${this.egw().lang("Merge options")} - + this.egw().link_get_registry(this.application, "entry") || this.egw().lang("entry"))}" + label="${this.egw().lang("Link to each entry")}" > - + `; } diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelectDialog.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelectDialog.ts index a82850f4a7..b037e524fe 100644 --- a/api/js/etemplate/Et2Vfs/Et2VfsSelectDialog.ts +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelectDialog.ts @@ -8,7 +8,7 @@ */ import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; -import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; +import {html, LitElement, nothing, PropertyValues, render, TemplateResult} from "lit"; import shoelace from "../Styles/shoelace"; import styles from "./Et2VfsSelect.styles"; import {property} from "lit/decorators/property.js"; @@ -110,7 +110,8 @@ export class Et2VfsSelectDialog label: "Spreadsheets" }, {value: "image/", label: "Images"}, - {value: "video/", label: "Videos"} + {value: "video/", label: "Videos"}, + {value: "message/rfc822", label: "Email"} ]; /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ @@ -249,11 +250,26 @@ export class Et2VfsSelectDialog public setPath(path) { + const oldValue = this.path; + + // Selection doesn't stay across sub-dirs. Notify user we dropped them. + if(this.value.length && path != oldValue) + { + const length = this.value.length; + this.value = []; + this.updateComplete.then(() => + { + render(html` + + + ${this.egw().lang("Selection of files can only be done in one folder. %1 files unselected.", length)} + `, this); + }); + } if(path == '..') { path = this.dirname(this.path); } - const oldValue = this.path; this._pathNode.value = this.path = path; this.requestUpdate("path", oldValue); this.currentResult = null; @@ -745,7 +761,7 @@ export class Et2VfsSelectDialog image="filemanager/fav_filter" noSubmit="true" @click=${() => this.setPath("/apps/favorites")} > - this.setPath("/apps/" + e.target.value)} > @@ -813,7 +829,7 @@ export class Et2VfsSelectDialog const buttons = [ {id: "ok", label: this.buttonLabel, image: image, button_id: Et2Dialog.OK_BUTTON}, - {id: "cancel", label: "cancel", image: "cancel", button_id: Et2Dialog.CANCEL_BUTTON} + {id: "cancel", label: this.egw().lang("cancel"), image: "cancel", button_id: Et2Dialog.CANCEL_BUTTON} ]; return html` @@ -882,6 +898,7 @@ export class Et2VfsSelectDialog > ${this.searchResultsTemplate()} + ${this.egw().lang("mime filter")} @@ -901,7 +918,6 @@ export class Et2VfsSelectDialog > ${this.mimeOptionsTemplate()} -
element. */ -export class Et2VfsSelectRow extends Et2Widget(LitElement) +export class Et2VfsSelectRow extends Et2Widget(LitElement) implements SearchResultElement { static get styles() { @@ -43,6 +44,11 @@ export class Et2VfsSelectRow extends Et2Widget(LitElement) this.setAttribute('aria-selected', 'false'); } + @property() + get label() + { + return this.value?.label || this.value?.name || ""; + } private handleMouseEnter() { this.hasHover = true; diff --git a/api/lang/egw_de.lang b/api/lang/egw_de.lang index 91e96ddd15..e68d41d1da 100644 --- a/api/lang/egw_de.lang +++ b/api/lang/egw_de.lang @@ -119,6 +119,7 @@ all addressbooks groupdav de Alle Adressbücher all categories common de Alle Kategorien all days common de Alle Tage all fields common de Alle Felder +all files common de Alle Dateien all in one groupdav de Gemeinsam in einem all languages common de Alle Sprachen all operations save the template! common de Alle Operation speichern das Template! @@ -443,6 +444,7 @@ document '%1' does not exist or is not readable for you! preferences de Das Doku document properties common de Dokument Eigenschaften document title: common de Titel des Dokuments: documentation common de Dokumentation +documents common de Dokumente doesn't matter common de Spielt keine Rolle domain common de Domain domain name for mail-address, eg. "%1" common de Domainname für E-Mail Adresse, z.B. "%1" @@ -490,6 +492,7 @@ el salvador common de EL SALVADOR element role title common de Element Rolle Titel email common de E-Mail email-address of the user, eg. "%1" common de E-Mail-Adresse des Benutzers, z.B. "%1" +emails common de E-Mails embeded css styles, eg. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!) common de Eingebettete CSS Stile, zb. '.red { background: red; }' (man beachte den '.' vor der CSS class) oder '@import url(...)' (Angaben gelten für die gesamte Seite!) empty file common de Datei leeren enable javascript onchange submit common de JavaScript absenden bei Änderung (onChange) aktivieren @@ -700,6 +703,7 @@ if you use "2-factor-authentication", please enter the code here. common de Wenn image common de Grafik image directory relative to document root (use / !), example: common de Bildverzeichnis entsprechend zur Dokumentroot (benutze / !), Beispiel: image url common de Bild-URL +images common de Bilder import common de Import import an etemplate from a xml-file common de Importiert ein eTemplate aus einer XML-Datei import table-definitions from existing db-table common de Importiert die Tabellen-Definition aus einer bestehenden Datenbank-Tabelle @@ -811,6 +815,7 @@ link is appended to mail allowing recipients to download or modify up to date ve link is appended to mail allowing recipients to download up to date version of files common de Link wird an die E-Mail angefügt und erlaubt Adressaten den aktuellen Inhalt der an gehangenen Dateien herunter zu laden link target %1 not found! common de Keine Verknüpfung zu %1 gefunden! link title of current record common de Link-Titel des aktuellen Datensatzes +link to each entry common de Verknüpfung zum einzelnen Eintrag linkapps common de Verknüpfung Anwendungen linked common de Verknüpft linkentry common de Verknüpfung Eintrag @@ -866,6 +871,10 @@ maybe common de Vieleicht mayotte common de MAYOTTE medium common de Mittel menu common de Menü +merge common de Zusammenführen +merge & send common de Zusammenführen & Versenden +merge individually common de einzeln zusammenführen +merge options common de Optionen beim Zusammenführen merged document filename preferences de Dateiname des zusammengeführten Dokuments message common de Nachricht message ... common de Nachricht ... @@ -1184,6 +1193,7 @@ savant2 version differs from savant2 wrapper.
this version: %1
savants save common de Speichern save all common de Alle speichern save as common de Speichern unter +save as PDF common de Als PDF speichern save as zip common de Als ZIP-Datei speichern save selected columns as default preference for all users. common de Speichert die ausgewählten Spalten als Vorgabe für alle Benutzer. save the changes made and close the window common de Speichert die Änderungen uns schließt das Fenster @@ -1264,6 +1274,7 @@ select work email address common de Geschäftl. E-Mail-Adresse auswählen select year common de Jahr auswählen selectbox common de Auswahlbox selection common de Auswahl +selection of files can only be done in one folder. %1 files unselected. common de Die Auswahl von Dateien kann nur in einem Ordner erfolgen. %1 Dateien nicht ausgewählt. send common de Senden send succeeded to %1 common de Versand erfolgreich zu %1 senegal common de SENEGAL @@ -1342,6 +1353,7 @@ spain common de SPANIEN span common de Überspannt span, class common de Span, Class special characters common de Sonderzeichen +spreadsheets common de Tabellenkalkulationen sri lanka common de SRI LANKA stack common de Stapel start a new search, cancel this link common de Neue Suche starten, diese Verknüpfung abbrechen @@ -1503,6 +1515,7 @@ version-number, should be in the form: major.minor.revision.number (eg. 0.9.13.0 vertical alignment of row common de Vertikale Ausrichtung der Zeile vfs upload directory common de Dateimanager Upload-Ordner video tutorials common de Video-Tutorials +videos common de Videos viet nam common de VIETNAM view common de Anzeigen view linked %1 entries common de Verknüpfte %1 anzeigen diff --git a/api/lang/egw_en.lang b/api/lang/egw_en.lang index 828459152b..514cdd8600 100644 --- a/api/lang/egw_en.lang +++ b/api/lang/egw_en.lang @@ -119,6 +119,7 @@ all addressbooks groupdav en All addressbooks all categories common en All categories all days common en All days all fields common en All fields +all files common en All files all in one groupdav en All in one all languages common en All languages all operations save the template! common en All operations save the template! @@ -444,6 +445,7 @@ document '%1' does not exist or is not readable for you! preferences en Document document properties common en Document properties document title: common en Document title: documentation common en Documentation +documents common en Documents doesn't matter common en Doesn't matter domain common en Domain domain name for mail-address, eg. "%1" common en Domain name for mail address, eg. "%1" @@ -491,6 +493,7 @@ el salvador common en EL SALVADOR element role title common en Element role title email common en Email email-address of the user, eg. "%1" common en Email address of the user, eg. "%1" +emails common en Emails embeded css styles, eg. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!) common en Embedded CSS styles, e.g. '.red { background: red; }' (note the '.' before the class-name) or '@import url(...)' (class names are global for the whole page!) empty file common en Empty file enable javascript onchange submit common en Enable JavaScript onchange submit @@ -701,6 +704,7 @@ if you use "2-factor-authentication", please enter the code here. common en If y image common en Image image directory relative to document root (use / !), example: common en Image folder relative to document root (use / !), example: image url common en Image URL +images common en Images import common en Import import an etemplate from a xml-file common en Import an eTemplate from a XML-file import table-definitions from existing db-table common en Import table definitions from existing DB table @@ -812,6 +816,7 @@ link is appended to mail allowing recipients to download or modify up to date ve link is appended to mail allowing recipients to download up to date version of files common en Link is appended to mail allowing recipients to download up to date version of files link target %1 not found! common en Link target %1 not found! link title of current record common en Link title of current record +link to each entry common en Link to each entry linkapps common en Link apps linked common en Linked linkentry common en Link entry @@ -867,6 +872,10 @@ maybe common en Maybe mayotte common en MAYOTTE medium common en Medium menu common en Menu +merge common en Merge +merge & send common en Merge & send +merge individually common en Merge individually +merge options common en Merge options merged document filename preferences en Merged document filename message common en Message message ... common en Message ... @@ -1185,6 +1194,7 @@ savant2 version differs from savant2 wrapper.
this version: %1
savants save common en Save save all common en Save all save as common en Save as +save as PDF common en Save as PDF save as zip common en Save as ZIP save selected columns as default preference for all users. common en Save columns as default preference for all users. save the changes made and close the window common en Save changes and close @@ -1265,6 +1275,7 @@ select work email address common en Select work email address select year common en Select year selectbox common en Select box selection common en Selection +selection of files can only be done in one folder. %1 files unselected. common en Selection of files can only be done in one folder. %1 files unselected. send common en Send send succeeded to %1 common en Send succeeded to %1 senegal common en SENEGAL @@ -1343,6 +1354,7 @@ spain common en SPAIN span common en Span span, class common en Span, Class special characters common en special characters +spreadsheets common en Spreadsheets sri lanka common en SRI LANKA stack common en Stack start a new search, cancel this link common en Start new search, cancel this link @@ -1504,6 +1516,7 @@ version-number, should be in the form: major.minor.revision.number (eg. 0.9.13.0 vertical alignment of row common en Vertical alignment of row vfs upload directory common en VFS upload folder video tutorials common en Video Tutorials +videos common en Videos viet nam common en VIET NAM view common en View view linked %1 entries common en View linked %1 entries From 3ba69d542dc7f748f147c720ec4c5f8f588ecac3 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 May 2024 10:49:06 -0600 Subject: [PATCH 16/51] Framework WIP - Get admin tree loading in side - Popups working --- admin/js/app.ts | 9 +++- admin/templates/default/index.xet | 2 +- kdots/js/EgwFramework.ts | 79 ++++++++++++++++++++++++++++--- kdots/js/EgwFrameworkApp.ts | 9 +++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/admin/js/app.ts b/admin/js/app.ts index 484c9ea54f..a949957bc6 100644 --- a/admin/js/app.ts +++ b/admin/js/app.ts @@ -423,7 +423,14 @@ class AdminApp extends EgwApp if(!_data || _data.type != undefined) return; // Insert the content, etemplate will load into it - jQuery(this.ajax_target.getDOMNode()).append(typeof _data === 'string' ? _data : _data[0]); + if(typeof _data === "string" || typeof _data[0] !== "undefined") + { + jQuery(this.ajax_target.getDOMNode()).append(typeof _data === 'string' ? _data : _data[0]); + } + else if(typeof _data.DOMNodeID == "string") + { + this.ajax_target.setAttribute("id", _data.DOMNodeID); + } } /** diff --git a/admin/templates/default/index.xet b/admin/templates/default/index.xet index bcebf05698..45eef7a5a8 100644 --- a/admin/templates/default/index.xet +++ b/admin/templates/default/index.xet @@ -83,7 +83,7 @@