diff --git a/api/src/Mail.php b/api/src/Mail.php index e832c24212..46d9001cbf 100644 --- a/api/src/Mail.php +++ b/api/src/Mail.php @@ -6530,22 +6530,22 @@ class Mail /** * importMessageToMergeAndSend * - * @param object &bo_merge bo_merge object + * @param Storage\Merge bo_merge bo_merge object * @param string $document the full filename * @param array $SendAndMergeTocontacts array of contact ids - * @param string $_folder (passed by reference) will set the folder used. must be set with a folder, but will hold modifications if + * @param string& $_folder (passed by reference) will set the folder used. must be set with a folder, but will hold modifications if * folder is modified - * @param string $importID ID for the imported message, used by attachments to identify them unambiguously + * @param string& $importID ID for the imported message, used by attachments to identify them unambiguously * @return mixed array of messages with success and failed messages or exception */ - function importMessageToMergeAndSend(bo_merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID='') + function importMessageToMergeAndSend(Storage\Merge $bo_merge, $document, $SendAndMergeTocontacts, &$_folder, &$importID='') { $importfailed = false; $processStats = array('success'=>array(),'failed'=>array()); if (empty($SendAndMergeTocontacts)) { $importfailed = true; - $alert_msg .= lang("Import of message %1 failed. No Contacts to merge and send to specified.",$_formData['name']); + $alert_msg .= lang("Import of message %1 failed. No Contacts to merge and send to specified.", ''); } // check if formdata meets basic restrictions (in tmp dir, or vfs, mimetype, etc.) @@ -6636,7 +6636,7 @@ class Mail $nfn = ($contact['n_fn'] ? $contact['n_fn'] : $contact['n_given'].' '.$contact['n_family']); if($email) { - $mailObject->addAddress(Horde_Idna::encode($email),$mailObject->EncodeHeader($nfn)); + $mailObject->addAddress(Horde_Idna::encode($email), $nfn); } } @@ -6692,7 +6692,7 @@ class Mail $nfn = ($contact['n_fn'] ? $contact['n_fn'] : $contact['n_given'].' '.$contact['n_family']); if($email) { - $mailObject->addAddress(Horde_Idna::encode($email),$mailObject->EncodeHeader($nfn)); + $mailObject->addAddress(Horde_Idna::encode($email), $nfn); } } $mailObject->addHeader('Subject', $bo_merge->merge_string($Subject, $val, $e, 'text/plain', array(), self::$displayCharset)); diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php new file mode 100644 index 0000000000..68128a8955 --- /dev/null +++ b/api/src/Storage/Merge.php @@ -0,0 +1,2046 @@ + + * @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 EGroupware\Api; +use EGroupware\Stylite; + +use DOMDocument; +use XSLTProcessor; +use tidy; +use ZipArchive; + +// explicit import old, non-namespaced phpgwapi classes +use uiaccountsel; +use egw; // link + +/** + * 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 +{ + /** + * 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 = array(); + + /** + * 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; + + + /** + * 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; + + /** + * Constructor + * + * @return bo_merge + */ + 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 + 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.openxmlformats-officedocument.spreadsheetml.sheet': // ms excel 2007 xml format + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.shee': + 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) + { + 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]; + switch($name) + { + case 'created': case 'modified': + if($value) $value = Api\DateTime::to($value); + break; + case 'bday': + if ($value) + { + $value = Api\DateTime::to($value, true); + } + 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 = ($GLOBALS['egw_info']['server']['webserver_url'][0] == '/' ? + ($_SERVER['HTTPS'] ? 'https://' : 'http://').$_SERVER['HTTP_HOST'] : ''). + $GLOBALS['egw']->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; + } + // 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; + $replacements['$$'.($prefix ? $prefix.'/':'').$name.'$$'] = + // use raw data for yaml, no user-preference specific formatting + $this->mimetype == 'application/x-yaml' ? (string)$contact[$name] : + Customfields::format($field, (string)$contact[$name]); + } + + // 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(); + } + } + 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 app Name of current app + * @param id ID of current entry + * @param only_app Restrict links to only given application + * @param exclude Exclude links to these applications + * @param style String One of: + * 'title' - plain text, just the title of the link + * 'link' - URL to the entry + * 'href' - HREF tag wrapped around the title + */ + 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(class_exists('EGroupware\Stylite\Vfs\Links\StreamWrapper') && $link_info['app'] != Api\Link::VFS_APPNAME) + { + $title = Stylite\Vfs\Links\StreamWrapper::entry2name($link_info['app'], $link_info['id'], $title); + } + 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',egw::link('/index.php',$link, false)); + } + else + { + $link = egw::link($link, array()); + } + // Prepend site + if ($link{0} == '/') + { + $link = ($_SERVER['HTTPS'] || $GLOBALS['egw_info']['server']['enforce_ssl'] ? 'https://' : 'http://'). + ($GLOBALS['egw_info']['server']['hostname'] ? $GLOBALS['egw_info']['server']['hostname'] : $_SERVER['HTTP_HOST']).$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 = '@\$(link|links|attachments|links_attachments)\/?(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, 1, -1); + 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',egw::link('/index.php',$link, false)); + } + else + { + $link = egw::link($link, array()); + } + // Prepend site + if ($link{0} == '/') + { + $link = ($_SERVER['HTTPS'] || $GLOBALS['egw_info']['server']['enforce_ssl'] ? 'https://' : 'http://'). + ($GLOBALS['egw_info']['server']['hostname'] ? $GLOBALS['egw_info']['server']['hostname'] : $_SERVER['HTTP_HOST']).$link; + } + $array[($prefix?$prefix.'/':'').$placeholder] = Api\Html::a_href(Api\Html::htmlspecialchars($title), $link); + break; + case 'links': + $array[($prefix?$prefix.'/':'').$placeholder] = $this->get_links($app, $id, '!'.Api\Link::VFS_APPNAME, 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]; + } + } + // Need to set each app, to make sure placeholders are removed + foreach(array_keys($GLOBALS['egw_info']['user']['apps']) as $_app) + { + $array[($prefix?$prefix.'/':'')."links/$app"] = $this->get_links($app,$id,$_app); + } + return $array; + } + + /** + * 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 + * @deprecated use Api\DateTime::to($time='now',$format='') + * @return string + */ + 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 = $GLOBALS['egw']->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); + return false; + } + + 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); + return false; + } + + // 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) { + $err = $e->getMessage(); + return false; + } + 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': // open office + case 'application/vnd.oasis.opendocument.spreadsheet': + // It seems easier to split the parent tags here + $replace_tags = array( + '/<(ol|ul|table)( [^>]*)?>/' => '<$1$2>', + '/<\/(ol|ul|table)>/' => '', + //'/<(li)(.*?)>(.*?)<\/\1>/' => '<$1 $2>$3', + ); + $content = preg_replace(array_keys($replace_tags),array_values($replace_tags),$content); + + $doc = new DOMDocument(); + $xslt = new XSLTProcessor(); + $doc->load(EGW_INCLUDE_ROOT.'/etemplate/templates/default/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.openxmlformats-officedocument.spreadsheetml.sheet': + // 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.'/etemplate/templates/default/'.$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); + + // 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) + { + $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',create_function('$p','return \'$$\'.strip_tags(substr($p[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); //get differt parts of document, seperatet by Pagerepeat + if ($mimetype == 'text/plain' && 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 ($mimetype == 'application/vnd.oasis.opendocument.text' && 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 ($mimetype == '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); //get the Lable content + preg_match_all('/\$\$labelplacement\$\$/',$contentrepeat,$countlables, PREG_SPLIT_NO_EMPTY); + $countlables = count($countlables[0]); + preg_replace('/\$\$labelplacement\$\$/','',$Labelrepeat,1); + if ($countlables > 1) $lableprint = true; + if (count($ids) > 1 && !$contentrepeat) + { + $err = lang('for more than one contact in a document use the tag pagerepeat!'); + return false; + } + 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': + case 'application/vnd.oasis.opendocument.spreadsheet': + case 'application/xml': + case 'text/html': + $joiner = ''; + break; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + $joiner = ''; + break; + case 'text/plain': + $joiner = "\r\n"; + break; + default: + $err = lang('%1 not implemented for %2!','$$pagerepeat$$',$mimetype); + return false; + } + } + foreach ((array)$ids as $n => $id) + { + if ($contentrepeat) $content = $contentrepeat; //content to repeat + if ($lableprint) $content = $Labelrepeat; + + // generate replacements; if exeption is thrown, catch it set error message and return false + try + { + if(!($replacements = $this->get_replacements($id,$content))) + { + $err = lang('Entry not found!'); + return false; + } + } + catch (Api\Exception\WrongUserinput $e) + { + // if this returns with an exeption, something failed big time + $err = $e->getMessage(); + return false; + } + 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/') !== null && ($user = $GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'],'person_id'))) + { + $replacements += $this->contact_replacements($user,'user'); + $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); + + // 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': + return $contentstart.implode('\\par \\page\\pard\\plain',$contentrepeatpages).$contentend; + case 'application/vnd.oasis.opendocument.text': + return $contentstart.implode('',$contentrepeatpages).$contentend; + case 'application/vnd.oasis.opendocument.spreadsheet': + return $contentstart.implode('
',$contentrepeatpages).$contentend; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + return $contentstart.implode('',$contentrepeatpages).$contentend; + case 'text/plain': + return $contentstart.implode("\r\n",$contentrep).$contentend; + } + $err = lang('%1 not implemented for %2!','$$labelplacement$$',$mimetype); + return false; + } + + if ($contentrepeat) + { + fwrite($content_stream, $contentend); + rewind($content_stream); + return 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.openxmlformats-officedocument.wordprocessingml.document': // ms office 2007 + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + case 'application/xml': + case 'text/xml': + $is_xml = true; + $charset = 'utf-8'; // xml files --> always use 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.'$$'])) + { + $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( + '','','','','','','
    ','