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); } /**