* @package api * @subpackage storage * @copyright (c) 2007-16 by Ralf Becker * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @version $Id$ */ namespace EGroupware\Api\Storage; use ceLTIc\LTI\Service\Result; use DOMDocument; use EGroupware\Api; use EGroupware\Api\Vfs; use EGroupware\Collabora\Conversion; use EGroupware\Stylite; use tidy; use uiaccountsel; use XSLTProcessor; use ZipArchive; // explicit import old, non-namespaced phpgwapi classes /** * Document merge print * * @todo move apply_styles call into merge_string to run for each entry merged and not all together to lower memory requirements */ abstract class Merge { /** * Preference, path where we look for merge templates */ public const PREF_TEMPLATE_DIR = 'document_dir'; /** * Preference, path to special documents that are listed first */ public const PREF_DEFAULT_TEMPLATE = 'default_document'; /** * Preference, path where we will put the generated document */ const PREF_STORE_LOCATION = "merge_store_path"; /** * Preference, placeholders for creating the filename of the generated document */ const PREF_DOCUMENT_FILENAME = "document_download_name"; /** * List of placeholders */ const DOCUMENT_FILENAME_OPTIONS = [ '$$document$$' => 'Template name', '$$link_title$$' => 'Entry link-title', '$$contact_title$$' => 'Contact link-title', '$$current_date$$' => 'Current date', ]; /** * Instance of the addressbook_bo class * * @var addressbook_bo */ var $contacts; /** * Datetime format according to user preferences * * @var string */ var $datetime_format = 'Y-m-d H:i'; /** * Fields that are to be treated as datetimes, when merged into spreadsheets */ var $date_fields = []; /** * Fields that are numeric, for special numeric handling */ protected $numeric_fields = []; /** * Mimetype of document processed by merge * * @var string */ var $mimetype; /** * Plugins registered by extending class to create a table with multiple rows * * $$table/$plugin$$ ... $$endtable$$ * * Callback returns replacements for row $n (stringing with 0) or null if no further rows * * @var array $plugin => array callback($plugin,$id,$n) */ var $table_plugins = array(); /** * Export limit in number of entries or some non-numerical value, if no export allowed at all, empty means no limit * * Set by constructor to $GLOBALS[egw_info][server][export_limit] * * @var int|string */ public $export_limit; public $public_functions = array( "merge_entries" => true ); /** * Configuration for HTML Tidy to clean up any HTML content that is kept */ public static $tidy_config = array( 'output-xml' => true, // Entity encoding 'show-body-only' => true, 'output-encoding' => 'utf-8', 'input-encoding' => 'utf-8', 'quote-ampersand' => false, // Prevent double encoding 'quote-nbsp' => true, // XSLT can handle spaces easier 'preserve-entities' => true, 'wrap' => 0, // Wrapping can break output ); /** * Parse HTML styles into target document style, if possible * * Apps not using html in there own data should set this with Customfields::use_html($app) * to avoid memory and time consuming html processing. */ protected $parse_html_styles = true; /** * Enable this to report memory_usage to error_log * * @var boolean */ public $report_memory_usage = false; /** * Save sent emails. Used when merge template is an email. Default is true, * to save sent emails in your sent folder. * * @var boolean */ public $keep_emails = true; /** * Constructor */ function __construct() { // Common messages are in preferences Api\Translation::add_app('preferences'); // All contact fields are in addressbook Api\Translation::add_app('addressbook'); $this->contacts = new Api\Contacts(); $this->datetime_format = $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'] . ' ' . ($GLOBALS['egw_info']['user']['preferences']['common']['timeformat'] == 12 ? 'h:i a' : 'H:i'); $this->export_limit = self::getExportLimit(); } /** * Hook returning options for export_limit_excepted groups * * @param array $config */ public static function hook_export_limit_excepted($config) { $accountsel = new uiaccountsel(); return '' . $accountsel->selection('newsettings[export_limit_excepted]', 'export_limit_excepted', $config['export_limit_excepted'], 'both', 4); } /** * Get all replacements, must be implemented in extending class * * Can use eg. the following high level methods: * - contact_replacements($contact_id,$prefix='') * - format_datetime($time,$format=null) * * @param int $id id of entry * @param string &$content =null content to create some replacements only if they are use * @return array|boolean array with replacements or false if entry not found */ abstract protected function get_replacements($id, &$content = null); /** * Return if merge-print is implemented for given mime-type (and/or extension) * * @param string $mimetype eg. text/plain * @param string $extension only checked for applications/msword and .rtf */ static public function is_implemented($mimetype, $extension = null) { static $zip_available = null; if(is_null($zip_available)) { $zip_available = check_load_extension('zip') && class_exists('ZipArchive'); // some PHP has zip extension, but no ZipArchive (eg. RHEL5!) } switch($mimetype) { case 'application/msword': if(strtolower($extension) != '.rtf') { break; } case 'application/rtf': case 'text/rtf': return true; // rtf files case 'application/vnd.oasis.opendocument.text': // oo text case 'application/vnd.oasis.opendocument.spreadsheet': // oo spreadsheet case 'application/vnd.oasis.opendocument.presentation': case 'application/vnd.oasis.opendocument.text-template': case 'application/vnd.oasis.opendocument.spreadsheet-template': case 'application/vnd.oasis.opendocument.presentation-template': if(!$zip_available) { break; } return true; // open office write xml files case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': // ms word 2007 xml format case 'application/vnd.openxmlformats-officedocument.wordprocessingml.d': // mimetypes in vfs are limited to 64 chars case 'application/vnd.ms-word.document.macroenabled.12': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': // ms excel 2007 xml format case 'application/vnd.openxmlformats-officedocument.spreadsheetml.shee': case 'application/vnd.ms-excel.sheet.macroenabled.12': if(!$zip_available) { break; } return true; // ms word xml format case 'application/xml': return true; // alias for text/xml, eg. ms office 2003 word format case 'message/rfc822': return true; // ToDo: check if you are theoretical able to send mail case 'application/x-yaml': return true; // yaml file, plain text with marginal syntax support for multiline replacements default: if(substr($mimetype, 0, 5) == 'text/') { return true; // text files } break; } return false; // As browsers not always return correct mime types, one could use a negative list instead //return !($mimetype == Api\Vfs::DIR_MIME_TYPE || substr($mimetype,0,6) == 'image/'); } /** * Return replacements for a contact * * @param int|string|array $contact contact-array or id * @param string $prefix ='' prefix like eg. 'user' * @param boolean $ignore_acl =false true: no acl check * @return array */ public function contact_replacements($contact, $prefix = '', $ignore_acl = false, &$content = '') { if(!is_array($contact)) { $contact = $this->contacts->read($contact, $ignore_acl); } if(!is_array($contact)) { return array(); } $replacements = array(); foreach(array_keys($this->contacts->contact_fields) as $name) { $value = $contact[$name] ?? ''; if(!$value) { continue; } switch($name) { case 'created': case 'modified': if($value) { $value = Api\DateTime::to($value); } break; case 'bday': if($value) { try { $value = Api\DateTime::to($value, true); } catch (\Exception $e) { unset($e); // ignore exception caused by wrongly formatted date } } break; case 'owner': case 'creator': case 'modifier': $value = Api\Accounts::username($value); break; case 'cat_id': if($value) { // if cat-tree is displayed, we return a full category path not just the name of the cat $use = $GLOBALS['egw_info']['server']['cat_tab'] == 'Tree' ? 'path' : 'name'; $cats = array(); foreach(is_array($value) ? $value : explode(',', $value) as $cat_id) { $cats[] = $GLOBALS['egw']->categories->id2name($cat_id, $use); } $value = implode(', ', $cats); } break; case 'jpegphoto': // returning a link might make more sense then the binary photo if($contact['photo']) { $value = Api\Framework::getUrl(Api\Framework::link('/index.php', $contact['photo'])); } break; case 'tel_prefer': if($value && $contact[$value]) { $value = $contact[$value]; } break; case 'account_id': if($value) { $replacements['$$' . ($prefix ? $prefix . '/' : '') . 'account_lid$$'] = $GLOBALS['egw']->accounts->id2name($value); } break; } if($name != 'photo') { $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = $value; } } // Formatted address, according to preference or country foreach(['one', 'two'] as $adr) { switch($this->contacts->addr_format_by_country($contact["adr_{$adr}_countryname"])) { case 'city_state_postcode': $formatted_placeholder = $contact["adr_{$adr}_locality"] . " " . $contact["adr_{$adr}_region"] . " " . $contact["adr_{$adr}_postalcode"]; break; case 'postcode_city': default: $formatted_placeholder = $contact["adr_{$adr}_postalcode"] . ' ' . $contact["adr_{$adr}_locality"]; break; } $replacements['$$adr_' . $adr . '_formatted$$'] = $formatted_placeholder; } // set custom fields, should probably go to a general method all apps can use // need to load all cfs for $ignore_acl=true foreach($ignore_acl ? Customfields::get('addressbook', true) : $this->contacts->customfields as $name => $field) { $name = '#' . $name; if(!array_key_exists($name, $contact) || !$contact[$name]) { $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = ''; continue; } // Format date cfs per user Api\Preferences if($this->mimetype !== 'application/x-yaml' && $contact[$name] && ($field['type'] == 'date' || $field['type'] == 'date-time')) { $this->date_fields[] = '#' . $name; $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = Api\DateTime::to($contact[$name], $field['type'] == 'date' ? true : ''); } $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = // use raw data for yaml, no user-preference specific formatting $this->mimetype == 'application/x-yaml' || $field['type'] == 'htmlarea' ? (string)$contact[$name] : Customfields::format($field, (string)$contact[$name]); } if($content && strpos($content, '$$#') !== FALSE) { $this->cf_link_to_expand($contact, $content, $replacements, 'addressbook'); } // Add in extra cat field $cats = array(); foreach(is_array($contact['cat_id']) ? $contact['cat_id'] : explode(',', $contact['cat_id']) as $cat_id) { if(!$cat_id) { continue; } if($GLOBALS['egw']->categories->id2name($cat_id, 'main') != $cat_id) { $path = explode(' / ', $GLOBALS['egw']->categories->id2name($cat_id, 'path')); unset($path[0]); // Drop main $cats[$GLOBALS['egw']->categories->id2name($cat_id, 'main')][] = implode(' / ', $path); } elseif($cat_id) { $cats[$cat_id] = array(); } } $replacements['$$' . ($prefix ? $prefix . '/' : '') . 'categories$$'] = ''; foreach($cats as $main => $cat) { $replacements['$$' . ($prefix ? $prefix . '/' : '') . 'categories$$'] .= $GLOBALS['egw']->categories->id2name($main, 'name') . (count($cat) > 0 ? ': ' : '') . implode(', ', $cats[$main]) . "\n"; } return $replacements; } /** * Get links for the given record * * Uses egw_link system to get link titles * * @param string app Name of current app * @param string id ID of current entry * @param string only_app Restrict links to only given application * @param string[] exclude Exclude links to these applications * @param string style One of: * 'title' - plain text, just the title of the link * 'link' - URL to the entry * 'href' - HREF tag wrapped around the title */ protected function get_links($app, $id, $only_app = '', $exclude = array(), $style = 'title') { $links = Api\Link::get_links($app, $id, $only_app); $link_titles = array(); foreach($links as $link_info) { // Using only_app only returns the ID if(!is_array($link_info) && $only_app && $only_app[0] !== '!') { $link_info = array( 'app' => $only_app, 'id' => $link_info ); } if($exclude && in_array($link_info['id'], $exclude)) { continue; } $title = Api\Link::title($link_info['app'], $link_info['id']); if($style == 'href' || $style == 'link') { $link = Api\Link::view($link_info['app'], $link_info['id'], $link_info); if($link_info['app'] != Api\Link::VFS_APPNAME) { // Set app to false so we always get an external link $link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php', $link, false)); } else { $link = Api\Framework::link($link, array()); } // Prepend site if($link[0] == '/') { $link = Api\Framework::getUrl($link); } $title = $style == 'href' ? Api\Html::a_href(Api\Html::htmlspecialchars($title), $link) : $link; } $link_titles[] = $title; } return implode("\n", $link_titles); } /** * Get all link placeholders * * Calls get_links() repeatedly to get all the combinations for the content. * * @param $app String appname * @param $id String ID of record * @param $prefix * @param $content String document content */ protected function get_all_links($app, $id, $prefix, &$content) { $array = array(); $pattern = '@\$\$(links_attachments|links|attachments|link)\/?(title|href|link)?\/?([a-z]*)\$\$@'; static $link_cache = null; $matches = null; if(preg_match_all($pattern, $content, $matches)) { foreach($matches[0] as $i => $placeholder) { $placeholder = substr($placeholder, 2, -2); if($link_cache[$id][$placeholder]) { $array[$placeholder] = $link_cache[$id][$placeholder]; continue; } switch($matches[1][$i]) { case 'link': // Link to current record $title = Api\Link::title($app, $id); if(class_exists('EGroupware\Stylite\Vfs\Links\StreamWrapper') && $app != Api\Link::VFS_APPNAME) { $title = Stylite\Vfs\Links\StreamWrapper::entry2name($app, $id, $title); } $link = Api\Link::view($app, $id); if($app != Api\Link::VFS_APPNAME) { // Set app to false so we always get an external link $link = str_replace(',', '%2C', $GLOBALS['egw']->framework->link('/index.php', $link, false)); } else { $link = Api\Framework::link($link, array()); } // Prepend site if($link[0] == '/') { $link = Api\Framework::getUrl($link); } // Formatting if($matches[2][$i] == 'title') { $link = $title; } else { if($matches[2][$i] == 'href') { // Turn on HTML style parsing or the link will be escaped $this->parse_html_styles = true; $link = Api\Html::a_href(Api\Html::htmlspecialchars($title), $link); } } $array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $link; break; case 'links': $link_app = $matches[3][$i] ? $matches[3][$i] : '!' . Api\Link::VFS_APPNAME; $array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, $link_app, array(), $matches[2][$i]); break; case 'attachments': $array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, Api\Link::VFS_APPNAME, array(), $matches[2][$i]); break; default: $array['$$' . ($prefix ? $prefix . '/' : '') . $placeholder . '$$'] = $this->get_links($app, $id, $matches[3][$i], array(), $matches[2][$i]); break; } $link_cache[$id][$placeholder] = $array[$placeholder]; } } return $array; } /** * Get share placeholder * * If the placeholder is present in the content, the share will be automatically * created. */ protected function share_placeholder($app, $id, $prefix, &$content) { $replacements = array(); // Skip if no content or content has no share placeholder if(!$content || strpos($content, '$$share') === FALSE) { return $replacements; } if(!$GLOBALS['egw_info']['user']['apps']['stylite']) { $replacements['$$' . $prefix . 'share$$'] = lang('EPL Only'); return $replacements; } // Get or create the share $share = $this->create_share($app, $id, $content); if($share) { $replacements['$$' . $prefix . 'share$$'] = $link = Api\Sharing::share2link($share); } return $replacements; } /** * Create a share for an entry * * @param string $app * @param string $id * @param String $content * @return \EGroupware\Api\Sharing */ protected function create_share($app, $id, &$content) { // Check if some other process created the share (with custom options) // and put it in the session cache for us $path = "$app::$id"; $session = \EGroupware\Api\Cache::getSession(Api\Sharing::class, $path); if($session && $session['share_path'] == $path) { return $session; } // Need to create the share here. // No way to know here if it should be writable, or who it's going to $mode = /* ? ? Sharing::WRITABLE :*/ Api\Sharing::READONLY; $recipients = array(); $extra = array(); //$extra['share_writable'] |= ($mode == Sharing::WRITABLE ? 1 : 0); return \EGroupware\Stylite\Link\Sharing::create('', $path, $mode, NULL, $recipients, $extra); } /** * Format a datetime * * @param int|string|DateTime $time unix timestamp or Y-m-d H:i:s string (in user time!) * @param string $format =null format string, default $this->datetime_format * @return string * @deprecated use Api\DateTime::to($time='now',$format='') */ protected function format_datetime($time, $format = null) { trigger_error(__METHOD__ . ' is deprecated, use Api\DateTime::to($time, $format)', E_USER_DEPRECATED); if(is_null($format)) { $format = $this->datetime_format; } return Api\DateTime::to($time, $format); } /** * Checks if current user is excepted from the export-limit: * a) access to admin application * b) he or one of his memberships is named in export_limit_excepted config var * * @return boolean */ public static function is_export_limit_excepted() { static $is_excepted = null; if(is_null($is_excepted)) { $is_excepted = isset($GLOBALS['egw_info']['user']['apps']['admin']); // check export-limit and fail if user tries to export more entries then allowed if(!$is_excepted && (is_array($export_limit_excepted = $GLOBALS['egw_info']['server']['export_limit_excepted']) || is_array($export_limit_excepted = unserialize($export_limit_excepted)))) { $id_and_memberships = $GLOBALS['egw']->accounts->memberships($GLOBALS['egw_info']['user']['account_id'], true); $id_and_memberships[] = $GLOBALS['egw_info']['user']['account_id']; $is_excepted = (bool)array_intersect($id_and_memberships, $export_limit_excepted); } } return $is_excepted; } /** * Checks if there is an exportlimit set, and returns * * @param string $app ='common' checks and validates app_limit, if not set returns the global limit * @return mixed - no if no export is allowed, false if there is no restriction and int as there is a valid restriction * you may have to cast the returned value to int, if you want to use it as number */ public static function getExportLimit($app = 'common') { static $exportLimitStore = array(); if(empty($app)) { $app = 'common'; } //error_log(__METHOD__.__LINE__.' called with app:'.$app); if(!array_key_exists($app, $exportLimitStore)) { //error_log(__METHOD__.__LINE__.' -> '.$app_limit.' '.function_backtrace()); $exportLimitStore[$app] = $GLOBALS['egw_info']['server']['export_limit']; if($app != 'common') { $app_limit = Api\Hooks::single('export_limit', $app); if($app_limit) { $exportLimitStore[$app] = $app_limit; } } //error_log(__METHOD__.__LINE__.' building cache for app:'.$app.' -> '.$exportLimitStore[$app]); if(empty($exportLimitStore[$app])) { $exportLimitStore[$app] = false; return false; } if(is_numeric($exportLimitStore[$app])) { $exportLimitStore[$app] = (int)$exportLimitStore[$app]; } else { $exportLimitStore[$app] = 'no'; } //error_log(__METHOD__.__LINE__.' -> '.$exportLimit); } //error_log(__METHOD__.__LINE__.' app:'.$app.' -> '.$exportLimitStore[$app]); return $exportLimitStore[$app]; } /** * hasExportLimit * checks wether there is an exportlimit set, and returns true or false * @param mixed $app_limit app_limit, if not set checks the global limit * @param string $checkas [AND|ISALLOWED], AND default; if set to ISALLOWED it is checked if Export is allowed * * @return bool - true if no export is allowed or a limit is set, false if there is no restriction */ public static function hasExportLimit($app_limit, $checkas = 'AND') { if(strtoupper($checkas) == 'ISALLOWED') { return (empty($app_limit) || ($app_limit != 'no' && $app_limit > 0)); } if(empty($app_limit)) { return false; } if($app_limit == 'no') { return true; } if($app_limit > 0) { return true; } } /** * Merges a given document with contact data * * @param string $document path/url of document * @param array $ids array with contact id(s) * @param string &$err error-message on error * @param string $mimetype mimetype of complete document, eg. text/*, application/vnd.oasis.opendocument.text, application/rtf * @param array $fix =null regular expression => replacement pairs eg. to fix garbled placeholders * @return string|boolean merged document or false on error */ public function &merge($document, $ids, &$err, $mimetype, array $fix = null) { if(!($content = file_get_contents($document))) { $err = lang("Document '%1' does not exist or is not readable for you!", $document); $ret = false; return $ret; } if(self::hasExportLimit($this->export_limit) && !self::is_export_limit_excepted() && count($ids) > (int)$this->export_limit) { $err = lang('No rights to export more than %1 entries!', (int)$this->export_limit); $ret = false; return $ret; } // fix application/msword mimetype for rtf files if($mimetype == 'application/msword' && strtolower(substr($document, -4)) == '.rtf') { $mimetype = 'application/rtf'; } try { $content = $this->merge_string($content, $ids, $err, $mimetype, $fix); } catch (\Exception $e) { _egw_log_exception($e); $err = $e->getMessage(); $ret = false; return $ret; } return $content; } protected function apply_styles(&$content, $mimetype, $mso_application_progid = null) { if(!isset($mso_application_progid)) { $matches = null; $mso_application_progid = $mimetype == 'application/xml' && preg_match('/' . preg_quote('', '/') . '/', substr($content, 0, 200), $matches) ? $matches[1] : ''; } // Tags we can replace with the target document's version $replace_tags = array(); switch($mimetype . $mso_application_progid) { case 'application/vnd.oasis.opendocument.text': // oo text case 'application/vnd.oasis.opendocument.spreadsheet': // oo spreadsheet case 'application/vnd.oasis.opendocument.presentation': case 'application/vnd.oasis.opendocument.text-template': case 'application/vnd.oasis.opendocument.spreadsheet-template': case 'application/vnd.oasis.opendocument.presentation-template': $doc = new DOMDocument(); $xslt = new XSLTProcessor(); $doc->load(EGW_INCLUDE_ROOT . '/api/templates/default/Merge/openoffice.xslt'); $xslt->importStyleSheet($doc); //echo $content;die(); break; case 'application/xmlWord.Document': // Word 2003*/ case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': // ms office 2007 case 'application/vnd.ms-word.document.macroenabled.12': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': case 'application/vnd.ms-excel.sheet.macroenabled.12': // It seems easier to split the parent tags here $replace_tags = array( // Tables, lists don't go inside '/<(ol|ul|table)( [^>]*)?>/' => '<$1$2>', '/<\/(ol|ul|table)>/' => '', // Fix for things other than text (newlines) inside table row '/<(td)( [^>]*)?>((?!))(.*?)<\/td>[\s]*?/' => '<$1$2>$4', // Remove extra whitespace '/]*?)>[^:print:]*?(.*?)<\/li>/' => '$2', // This doesn't get it all '/[\s]+(.*?)<\/w:t>/' => '$1', // Remove spans with no attributes, linebreaks inside them cause problems '/(.*?)<\/span>/' => '$1' ); $content = preg_replace(array_keys($replace_tags), array_values($replace_tags), $content); /* In the case where you have something like (invalid - mismatched tags), it takes multiple runs to get rid of both spans. So, loop. OO.o files have not yet been shown to have this problem. */ $count = $i = 0; do { $content = preg_replace('/(.*?)<\/span>/', '$1', $content, -1, $count); $i++; } while($count > 0 && $i < 10); $doc = new DOMDocument(); $xslt = new XSLTProcessor(); $xslt_file = $mimetype == 'application/xml' ? 'wordml.xslt' : 'msoffice.xslt'; $doc->load(EGW_INCLUDE_ROOT . '/api/templates/default/Merge/' . $xslt_file); $xslt->importStyleSheet($doc); break; } // XSLT transform known tags if($xslt) { // does NOT work with php 5.2.6: Catchable fatal error: argument 1 to transformToXml() must be of type DOMDocument //$element = new SimpleXMLelement($content); $element = new DOMDocument('1.0', 'utf-8'); $result = $element->loadXML($content); if(!$result) { throw new Api\Exception('Unable to parse merged document for styles. Check warnings in log for details.'); } $content = $xslt->transformToXml($element); //echo $content;die(); // Word 2003 needs two declarations, add extra declaration back in if($mimetype == 'application/xml' && $mso_application_progid == 'Word.Document' && strpos($content, '' . $content; } // Validate /* $doc = new DOMDocument(); $doc->loadXML($content); $doc->schemaValidate(*Schema (xsd) file*); */ } } /** * Merges a given document with contact data * * @param string $_content * @param array $ids array with contact id(s) * @param string &$err error-message on error * @param string $mimetype mimetype of complete document, eg. text/*, application/vnd.oasis.opendocument.text, application/rtf * @param array $fix =null regular expression => replacement pairs eg. to fix garbled placeholders * @param string $charset =null charset to override default set by mimetype or export charset * @return string|boolean merged document or false on error */ public function &merge_string($_content, $ids, &$err, $mimetype, array $fix = null, $charset = null) { $ids = empty($ids) ? [] : (array)$ids; $matches = null; if($mimetype == 'application/xml' && preg_match('/' . preg_quote('', '/') . '/', substr($_content, 0, 200), $matches)) { $mso_application_progid = $matches[1]; } else { $mso_application_progid = ''; } // alternative syntax using double curly brackets (eg. {{cat_id}} instead $$cat_id$$), // agressivly removing all xml-tags eg. Word adds within placeholders $content = preg_replace_callback('/{{[^}]+}}/i', function ($matches) { return '$$' . strip_tags(substr($matches[0], 2, -2)) . '$$'; }, $_content); // Handle escaped placeholder markers in RTF, they won't match when escaped if($mimetype == 'application/rtf') { $content = preg_replace('/\\\{\\\{([^\\}]+)\\\}\\\}/i', '$$\1$$', $content); } // make currently processed mimetype available to class methods; $this->mimetype = $mimetype; // fix garbled placeholders if($fix && is_array($fix)) { $content = preg_replace(array_keys($fix), array_values($fix), $content); //die("
".htmlspecialchars($content)."
\n"); } list($contentstart, $contentrepeat, $contentend) = preg_split('/\$\$pagerepeat\$\$/', $content, -1, PREG_SPLIT_NO_EMPTY) + [null, null, null]; //get differt parts of document, seperatet by Pagerepeat if($mimetype == 'text/plain' && $ids && count($ids) > 1) { // textdocuments are simple, they do not hold start and end, but they may have content before and after the $$pagerepeat$$ tag // header and footer should not hold any $$ tags; if we find $$ tags with the header, we assume it is the pagerepeatcontent $nohead = false; if(stripos($contentstart, '$$') !== false) { $nohead = true; } if($nohead) { $contentend = $contentrepeat; $contentrepeat = $contentstart; $contentstart = ''; } } if(in_array($mimetype, array('application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.text-template')) && count($ids) > 1) { if(strpos($content, '$$pagerepeat') === false) { //for odt files we have to split the content and add a style for page break to the style area list($contentstart, $contentrepeat, $contentend) = preg_split('/office:body>/', $content, -1, PREG_SPLIT_NO_EMPTY); //get differt parts of document, seperatet by Pagerepeat $contentstart = substr($contentstart, 0, strlen($contentstart) - 1); //remove "<" $contentrepeat = substr($contentrepeat, 0, strlen($contentrepeat) - 2); //remove "/', $content, -1, PREG_SPLIT_NO_EMPTY); //get differt parts of document style sheets $contentstart = $stylestart . ''; $contentstart .= ''; $contentend = ''; } else { // Template specifies where to repeat list($contentstart, $contentrepeat, $contentend) = preg_split('/\$\$pagerepeat\$\$/', $content, -1, PREG_SPLIT_NO_EMPTY); //get different parts of document, seperated by pagerepeat } } if(in_array($mimetype, array('application/vnd.ms-word.document.macroenabled.12', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) && count($ids) > 1) { //for Word 2007 XML files we have to split the content and add a style for page break to the style area list($contentstart, $contentrepeat, $contentend) = preg_split('/w:body>/', $content, -1, PREG_SPLIT_NO_EMPTY); //get differt parts of document, seperatet by Pagerepeat $contentstart = substr($contentstart, 0, strlen($contentstart) - 1); //remove "'; $contentend = ''; } list($Labelstart, $Labelrepeat, $Labeltend) = preg_split('/\$\$label\$\$/', $contentrepeat, -1, PREG_SPLIT_NO_EMPTY) + [null, null, null]; //get the label content preg_match_all('/\$\$labelplacement\$\$/', $contentrepeat, $countlables, PREG_SPLIT_NO_EMPTY); $countlables = count($countlables[0]); preg_replace('/\$\$labelplacement\$\$/', '', $Labelrepeat, 1); $lableprint = $countlables > 1; if(count($ids) > 1 && !$contentrepeat) { $err = lang('for more than one contact in a document use the tag pagerepeat!'); $ret = false; return $ret; } if($this->report_memory_usage) { error_log(__METHOD__ . "(count(ids)=" . count($ids) . ") strlen(contentrepeat)=" . strlen($contentrepeat) . ', strlen(labelrepeat)=' . strlen($Labelrepeat)); } if($contentrepeat) { $content_stream = fopen('php://temp', 'r+'); fwrite($content_stream, $contentstart); $joiner = ''; switch($mimetype) { case 'application/rtf': case 'text/rtf': $joiner = '\\par \\page\\pard\\plain'; break; case 'application/vnd.oasis.opendocument.text': // oo text case 'application/vnd.oasis.opendocument.spreadsheet': // oo spreadsheet case 'application/vnd.oasis.opendocument.presentation': case 'application/vnd.oasis.opendocument.text-template': case 'application/vnd.oasis.opendocument.spreadsheet-template': case 'application/vnd.oasis.opendocument.presentation-template': case 'application/xml': case 'text/html': case 'text/csv': $joiner = ''; break; case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': case 'application/vnd.ms-word.document.macroenabled.12': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': case 'application/vnd.ms-excel.sheet.macroenabled.12': $joiner = ''; break; case 'text/plain': $joiner = "\r\n"; break; default: $err = lang('%1 not implemented for %2!', '$$pagerepeat$$', $mimetype); $ret = false; return $ret; } } foreach((array)$ids as $n => $id) { if($contentrepeat) { $content = $contentrepeat; } //content to repeat if($lableprint) { $content = $Labelrepeat; } // generate replacements; if exception is thrown, catch it set error message and return false try { if(!($replacements = $this->get_replacements($id, $content))) { $err = lang('Entry not found!'); $ret = false; return $ret; } } catch (Api\Exception\WrongUserinput $e) { // if this returns with an exeption, something failed big time $err = $e->getMessage(); $ret = false; return $ret; } if($this->report_memory_usage) { error_log(__METHOD__ . "() $n: $id " . Api\Vfs::hsize(memory_get_usage(true))); } // some general replacements: current user, date and time if(strpos($content, '$$user/') !== false && ($user = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'], 'person_id'))) { $replacements += $this->contact_replacements($user, 'user', false, $content); $replacements['$$user/primary_group$$'] = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'], 'account_primary_group')); } $replacements['$$date$$'] = Api\DateTime::to('now', true); $replacements['$$datetime$$'] = Api\DateTime::to('now'); $replacements['$$time$$'] = Api\DateTime::to('now', false); $app = $this->get_app(); $replacements += $this->share_placeholder($app, $id, '', $content); // does our extending class registered table-plugins AND document contains table tags if($this->table_plugins && preg_match_all('/\\$\\$table\\/([A-Za-z0-9_]+)\\$\\$(.*?)\\$\\$endtable\\$\\$/s', $content, $matches, PREG_SET_ORDER)) { // process each table foreach($matches as $match) { $plugin = $match[1]; // plugin name $callback = $this->table_plugins[$plugin]; $repeat = $match[2]; // line to repeat $repeats = ''; if(isset($callback)) { for($n = 0; ($row_replacements = $this->$callback($plugin, $id, $n, $repeat)); ++$n) { $_repeat = $this->process_commands($repeat, $row_replacements); $repeats .= $this->replace($_repeat, $row_replacements, $mimetype, $mso_application_progid); } } $content = str_replace($match[0], $repeats, $content); } } $content = $this->process_commands($this->replace($content, $replacements, $mimetype, $mso_application_progid, $charset), $replacements); // remove not existing replacements (eg. from calendar array) if(strpos($content, '$$') !== null) { $content = preg_replace('/\$\$[a-z0-9_\/]+\$\$/i', '', $content); } if($contentrepeat) { fwrite($content_stream, ($n == 0 ? '' : $joiner) . $content); } if($lableprint) { $contentrep[is_array($id) ? implode(':', $id) : $id] = $content; } } if($Labelrepeat) { $countpage = 0; $count = 0; $contentrepeatpages[$countpage] = $Labelstart . $Labeltend; foreach($contentrep as $Label) { $contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/', $Label, $contentrepeatpages[$countpage], 1); $count = $count + 1; if(($count % $countlables) == 0 && count($contentrep) > $count) //new page { $countpage = $countpage + 1; $contentrepeatpages[$countpage] = $Labelstart . $Labeltend; } } $contentrepeatpages[$countpage] = preg_replace('/\$\$labelplacement\$\$/', '', $contentrepeatpages[$countpage], -1); //clean empty fields switch($mimetype) { case 'application/rtf': case 'text/rtf': $ret = $contentstart . implode('\\par \\page\\pard\\plain', $contentrepeatpages) . $contentend; break; case 'application/vnd.oasis.opendocument.text': case 'application/vnd.oasis.opendocument.presentation': case 'application/vnd.oasis.opendocument.text-template': case 'application/vnd.oasis.opendocument.presentation-template': $ret = $contentstart . implode('', $contentrepeatpages) . $contentend; break; case 'application/vnd.oasis.opendocument.spreadsheet': case 'application/vnd.oasis.opendocument.spreadsheet-template': $ret = $contentstart . implode('', $contentrepeatpages) . $contentend; break; case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': case 'application/vnd.ms-word.document.macroenabled.12': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': case 'application/vnd.ms-excel.sheet.macroenabled.12': $ret = $contentstart . implode('', $contentrepeatpages) . $contentend; break; case 'text/plain': $ret = $contentstart . implode("\r\n", $contentrep) . $contentend; break; default: $err = lang('%1 not implemented for %2!', '$$labelplacement$$', $mimetype); $ret = false; } return $ret; } if($contentrepeat) { fwrite($content_stream, $contentend); rewind($content_stream); $content = stream_get_contents($content_stream); } if($this->report_memory_usage) { error_log(__METHOD__ . "() returning " . Api\Vfs::hsize(memory_get_peak_usage(true))); } return $content; } /** * Replace placeholders in $content of $mimetype with $replacements * * @param string $content * @param array $replacements name => replacement pairs * @param string $mimetype mimetype of content * @param string $mso_application_progid ='' MS Office 2003: 'Excel.Sheet' or 'Word.Document' * @param string $charset =null charset to override default set by mimetype or export charset * @return string */ protected function replace($content, array $replacements, $mimetype, $mso_application_progid = '', $charset = null) { switch($mimetype) { case 'application/vnd.oasis.opendocument.text': // open office case 'application/vnd.oasis.opendocument.spreadsheet': case 'application/vnd.oasis.opendocument.presentation': case 'application/vnd.oasis.opendocument.text-template': case 'application/vnd.oasis.opendocument.spreadsheet-template': case 'application/vnd.oasis.opendocument.presentation-template': case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': // ms office 2007 case 'application/vnd.ms-word.document.macroenabled.12': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': case 'application/vnd.ms-excel.sheet.macroenabled.12': case 'application/xml': case 'text/xml': $is_xml = true; $charset = 'utf-8'; // xml files --> always use utf-8 break; case 'application/rtf': case 'text/rtf': $charset = 'iso-8859-1'; // rtf seems to user iso-8859-1 or equivalent windows charset, not utf-8 break; case 'text/html': $is_xml = true; $matches = null; if(preg_match('/ use our export-charset, defined in addressbook prefs if(empty($charset)) { $charset = $this->contacts->prefs['csv_charset']; } break; } //error_log(__METHOD__."('$document', ... ,$mimetype) --> $charset (egw=".Api\Translation::charset().', export='.$this->contacts->prefs['csv_charset'].')'); // do we need to convert charset if($charset && $charset != Api\Translation::charset()) { $replacements = Api\Translation::convert($replacements, Api\Translation::charset(), $charset); } // Date only placeholders for timestamps if(is_array($this->date_fields)) { foreach($this->date_fields as $field) { if(($value = $replacements['$$' . $field . '$$'] ?? null)) { $time = Api\DateTime::createFromFormat('+' . Api\DateTime::$user_dateformat . ' ' . Api\DateTime::$user_timeformat . '*', $value); $replacements['$$' . $field . '/date$$'] = $time ? $time->format(Api\DateTime::$user_dateformat) : ''; } } } if($is_xml) // zip'ed xml document (eg. OO) { // Numeric fields $names = array(); // Tags we can replace with the target document's version $replace_tags = array(); // only keep tags, if we have xsl extension available if(class_exists('XSLTProcessor') && class_exists('DOMDocument') && $this->parse_html_styles) { switch($mimetype . $mso_application_progid) { case 'text/html': $replace_tags = array( '', '', '', '', '', '', '
    ', '