Tinebase_Export_Doc - fix sub templates
[tine20] / tine20 / Tinebase / Export / Doc.php
1 <?php
2 /**
3  * Tinebase Doc/Docx generation class
4  *
5  * @package     Tinebase
6  * @subpackage  Export
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Paul Mehrer <p.mehrer@metaways.de>
9  * @copyright   Copyright (c) 2017 Metaways Infosystems GmbH (http://www.metaways.de)
10  */
11
12 /**
13  * Tinebase Doc/Docx generation class
14  *
15  * @package     Tinebase
16  * @subpackage  Export
17  */
18
19 class Tinebase_Export_Doc extends Tinebase_Export_Abstract implements Tinebase_Record_IteratableInterface
20 {
21
22     /**
23      * the document
24      *
25      * @var \PhpOffice\PhpWord\PhpWord
26      */
27     protected $_docObject;
28
29     /**
30      * the template to work on
31      *
32      * @var Tinebase_Export_Richtext_TemplateProcessor
33      */
34     protected $_docTemplate = null;
35
36     /**
37      * format strings
38      *
39      * @var string
40      */
41     protected $_format = 'docx';
42
43     /**
44      * @var int
45      */
46     protected $_rowCount = 0;
47
48     /**
49      * @var array
50      */
51     protected $_templateVariables = null;
52
53     /**
54      * @var array
55      */
56     protected $_dataSources = array();
57
58     /**
59      * @var string
60      */
61     protected $_currentDataSource = null;
62
63     /**
64      * @var boolean
65      */
66     protected $_skip = false;
67
68     /**
69      * @var Tinebase_Export_Richtext_TemplateProcessor
70      */
71     protected $_currentProcessor = null;
72
73     protected $_subTwigTemplates = array();
74     protected $_subTwigMappings = array();
75
76
77
78     /**
79      * get download content type
80      *
81      * @return string
82      */
83     public function getDownloadContentType()
84     {
85         return 'application/vnd.ms-word';
86     }
87
88
89     /**
90      * return download filename
91      *
92      * @param string $_appName
93      * @param string $_format
94      * @return string
95      */
96     public function getDownloadFilename($_appName, $_format)
97     {
98         return 'letter_' . strtolower($_appName) . '.docx';
99     }
100
101     public static function getDefaultFormat()
102     {
103         return 'docx';
104     }
105
106     /**
107      * generate export
108      */
109     public function generate()
110     {
111         $this->_rowCount = 0;
112         $this->_writeGenericHeader = false;
113         $this->_dumpRecords = false;
114         $this->_createDocument();
115         $this->_exportRecords();
116         if (null !== $this->_docTemplate) {
117             $this->_docTemplate->replaceTine20ImagePaths();
118         }
119     }
120
121     /**
122      * output result
123      */
124     public function write()
125     {
126         $document = $this->getDocument();
127         $tempfile = $document->save();
128         readfile($tempfile);
129         unlink($tempfile);
130     }
131
132     public function save($filename)
133     {
134         $document = $this->getDocument();
135         $tempfile = $document->save();
136
137         copy($tempfile, $filename);
138         unlink($tempfile);
139     }
140
141     /**
142      * @param string $str
143      * @return string
144      */
145     protected function _cutXml($str)
146     {
147         return substr($str, 5);
148     }
149
150     /**
151      * @param $_name
152      */
153     protected function _startDataSource($_name)
154     {
155         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' starting datasource ' . $_name);
156
157         if (!isset($this->_dataSources[$_name])) {
158             if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' datasource not found, skipping data!');
159             $this->_skip = true;
160             return;
161         }
162
163         $this->_firstIteration = true;
164         $this->_currentProcessor = $this->_dataSources[$_name];
165         $this->_currentDataSource = $_name;
166         $this->_rowCount = 0;
167     }
168
169     /**
170      * @param $_name
171      */
172     protected function _endDataSource($_name)
173     {
174         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ending datasource ' . $_name);
175
176         $data = '';
177
178         if (false === $this->_skip) {
179
180             $this->_unwrapProcessors();
181
182             /** @var Tinebase_Export_Richtext_TemplateProcessor $processor */
183             $processor = $this->_dataSources[$_name];
184             $data = $this->_cutXml($processor->getMainPart());
185         }
186
187         $this->_lastGroupValue = null;
188         $this->_skip = false;
189         $this->_currentProcessor = $this->_docTemplate;
190         $this->_docTemplate->setValue('DATASOURCE_' . $_name, $data);
191     }
192
193     protected function _unwrapProcessors()
194     {
195         /*if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBRECORD) {
196             $parent = $this->_currentProcessor->getParent();
197             $name = '${R' . $this->_currentProcessor->getConfig('name') . '}';
198             if ($parent->getType()  === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBGROUP) {
199                 $this->_currentProcessor = $parent;
200             } elseif ($parent->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
201                 $parent = $parent->getParent();
202             }
203             $parent->setValue($name, '');
204         }
205         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBGROUP) {
206             $parent = $this->_currentProcessor->getParent();
207             $name = '${R' . $this->_currentProcessor->getConfig('name') . '}';
208             if ($parent->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
209                 $parent = $parent->getParent();
210             }
211             $parent->setValue($name, '');
212         }*/
213
214         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
215             $this->_currentProcessor = $this->_currentProcessor->getParent();
216             $this->_currentProcessor->setValue('${RECORD_BLOCK}', '');
217         }
218
219         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_STANDARD) {
220             $this->_currentProcessor->setValue('${RECORD_ROW}', '');
221         }
222
223         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_GROUP) {
224             $processor = $this->_currentProcessor->getParent();
225             $processor->setValue('${GROUP_BLOCK}', $this->_cutXml($this->_currentProcessor->getMainPart()));
226             $this->_currentProcessor = $processor;
227         }
228     }
229
230     protected function _startGroup()
231     {
232         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' starting group...');
233
234         if (true === $this->_skip) {
235             return;
236         }
237
238         if ($this->_currentProcessor->hasConfig('group')) {
239             $this->_currentProcessor = $this->_currentProcessor->getConfig('group');
240         }
241
242         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_GROUP ||
243                 $this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBGROUP) {
244             if ($this->_rowCount > 0 && $this->_currentProcessor->hasConfig('groupSeparator')) {
245                 $this->_currentProcessor->append($this->_currentProcessor->getConfig('groupSeparator'));
246             }
247
248             if ($this->_currentProcessor->hasConfig('groupHeader')) {
249                 $this->_currentProcessor->append($this->_currentProcessor->getConfig('groupHeader'));
250             }
251
252             $this->_currentProcessor->append($this->_currentProcessor->getConfig('groupXml'));
253         } elseif ($this->_currentProcessor->hasConfig('recordRow')) {
254             $recordRow = $this->_currentProcessor->getConfig('recordRow');
255
256             if ($this->_rowCount > 0 && isset($recordRow['groupSeparatorRow'])) {
257                 $this->_currentProcessor->setValue('${RECORD_ROW}', $recordRow['groupSeparatorRow'] . '${RECORD_ROW}');
258             }
259
260             if (isset($recordRow['groupHeaderRow'])) {
261                 $this->_currentProcessor->setValue('${RECORD_ROW}', $recordRow['groupHeaderRow'] . '${RECORD_ROW}');
262             }
263         }
264     }
265
266     protected function _endGroup()
267     {
268         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ending group...');
269
270         if (true === $this->_skip) {
271             return;
272         }
273
274         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_GROUP) {
275             $this->_currentProcessor->setValue('${RECORD_ROW}', '');
276         }
277
278         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
279             $this->_currentProcessor = $this->_currentProcessor->getParent();
280             if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_GROUP) {
281                 $this->_currentProcessor->setValue('${RECORD_BLOCK}', '');
282             }
283         }
284
285         if ($this->_currentProcessor->hasConfig('recordRow')) {
286             $recordRow = $this->_currentProcessor->getConfig('recordRow');
287             if (isset($recordRow['groupFooterRow'])) {
288                 $this->_currentProcessor->setValue('${RECORD_ROW}', $recordRow['groupFooterRow'] . '${RECORD_ROW}');
289             }
290         }
291
292         if ($this->_currentProcessor->hasConfig('groupFooter')) {
293             $this->_currentProcessor->append($this->_currentProcessor->getConfig('groupFooter'));
294         }
295     }
296
297     protected function _startRow()
298     {
299         if (true === $this->_skip) {
300             return;
301         }
302
303         $this->_rowCount += 1;
304
305         if ($this->_currentProcessor->hasConfig('recordRow')) {
306             $recordRow = $this->_currentProcessor->getConfig('recordRow');
307             $this->_currentProcessor->setValue('${RECORD_ROW}', $recordRow['recordRow'] . '${RECORD_ROW}');
308         } else {
309             if ($this->_currentProcessor->hasConfig('record')) {
310                 $this->_currentProcessor = $this->_currentProcessor->getConfig('record');
311             }
312
313             if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD ||
314                     $this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBRECORD) {
315                 $data = '';
316                 if ($this->_rowCount > 1 && $this->_currentProcessor->hasConfig('separator')) {
317                     $data .= $this->_currentProcessor->getConfig('separator');
318                 }
319
320                 if ($this->_currentProcessor->hasConfig('header')) {
321                     $data .= $this->_currentProcessor->getConfig('header');
322                 }
323
324                 if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
325                     $data .= $this->_currentProcessor->getConfig('recordXml') . '${RECORD_BLOCK}';
326                     $this->_currentProcessor->getParent()->setValue('${RECORD_BLOCK}', $data);
327                 } else {
328                     $name = '${R' . $this->_currentProcessor->getConfig('name') . '}';
329                     $data .= $this->_currentProcessor->getConfig('recordXml') . $name;
330                     if (($parent = $this->_currentProcessor->getParent()) && $parent->getType() ===
331                             Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
332                         $parent = $parent->getParent();
333                     }
334                     $parent->setValue($name, $data);
335                 }
336             } else {
337                 throw new Tinebase_Exception_UnexpectedValue('template and definition do not match');
338             }
339         }
340     }
341
342     protected function _endRow()
343     {
344         if (true === $this->_skip) {
345             return;
346         }
347
348         if ($this->_currentProcessor->hasConfig('subgroups')) {
349             foreach ($this->_currentProcessor->getConfig('subgroups') as $property => $group) {
350                 $this->_executeSubTemplate($property, $group);
351             }
352         }
353         if ($this->_currentProcessor->hasConfig('subrecords')) {
354             foreach ($this->_currentProcessor->getConfig('subrecords') as $property => $record) {
355                 $this->_executeSubTemplate($property, $record);
356             }
357         }
358
359         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD &&
360                 $this->_currentProcessor->hasConfig('footer')) {
361             $this->_currentProcessor->getParent()->setValue('${RECORD_BLOCK}',
362                 $this->_currentProcessor->getConfig('footer') . '${RECORD_BLOCK}');
363         }
364     }
365
366     /**
367      * @param string $_name
368      * @param Tinebase_Export_Richtext_TemplateProcessor $_processor
369      */
370     protected function _executeSubTemplate($_name, Tinebase_Export_Richtext_TemplateProcessor $_processor)
371     {
372         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' starting sub tempalte: ' . $_name);
373
374         $recordSet = $this->_currentRecord->{$_name};
375         if (is_array($recordSet)) {
376             if (count($recordSet) === 0) {
377                 return;
378             }
379             if (($record = reset($recordSet)) instanceof Tinebase_Record_Abstract) {
380                 $recordSet = new Tinebase_Record_RecordSet(get_class($record), $recordSet);
381             } else {
382                 $realRecordSet = new Tinebase_Record_RecordSet(Tinebase_Record_Generic::class, array());
383                 $mergedRecords = array();
384                 foreach($recordSet as $recordArray) {
385                     if (!is_array($recordArray)) {
386                         return;
387                     }
388                     $mergedRecords = array_merge($recordArray, $mergedRecords);
389                 }
390                 $validators = array_fill_keys(array_keys($mergedRecords), array(Zend_Filter_Input::ALLOW_EMPTY => true));
391                 foreach($recordSet as $recordArray) {
392                     $record = new Tinebase_Record_Generic(array(), true);
393                     $record->setValidators($validators);
394                     $record->setFromArray($recordArray);
395                     $realRecordSet->addRecord($record);
396                 }
397                 $recordSet = $realRecordSet;
398             }
399         } elseif (is_object($recordSet)) {
400             if ($recordSet instanceof Tinebase_Record_Abstract) {
401                 $recordSet = new Tinebase_Record_RecordSet(get_class($recordSet), array($recordSet));
402             } elseif (!$recordSet instanceof Tinebase_Record_RecordSet) {
403                 return;
404             }
405         } else {
406             return;
407         }
408         $oldTemplateVariables = $this->_templateVariables;
409         $oldProcessor = $this->_currentProcessor;
410         $oldDocTemplate = $this->_docTemplate;
411         $oldRowCount = $this->_rowCount;
412         $subTempName = $this->_currentDataSource . '_' . $_name;
413         $oldState = $this->_getCurrentState();
414         foreach (array_keys($oldState) as $key) {
415             $this->{$key} = null;
416         }
417         $this->_templateVariables = null;
418
419
420         $this->_currentProcessor = $_processor;
421         $this->_rowCount = 0;
422         $this->_docTemplate = $_processor;
423
424         if (!isset($this->_subTwigTemplates[$subTempName])) {
425             $this->_twigEnvironment->getLoader()->addLoader(
426                 new Tinebase_Twig_CallBackLoader($this->_templateFileName . $subTempName, $this->_getLastModifiedTimeStamp(),
427                     array($this, '_getTwigSource')));
428
429             $this->_twigTemplate = $this->_twigEnvironment->load($this->_templateFileName . $subTempName);
430             $this->_subTwigTemplates[$subTempName] = $this->_twigTemplate;
431             $this->_subTwigMappings[$subTempName] = $this->_twigMapping;
432         } else {
433             $this->_twigTemplate = $this->_subTwigTemplates[$subTempName];
434             $this->_twigMapping = $this->_subTwigMappings[$subTempName];
435         }
436
437         $this->processIteration($recordSet);
438
439         $result = $this->_cutXml($this->_currentProcessor->getMainPart());
440         $replacementName = ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBGROUP ?
441             'RSUBGROUP_' : 'RSUBRECORD_') . $_name;
442
443         $this->_templateVariables = $oldTemplateVariables;
444         $this->_docTemplate = $oldDocTemplate;
445         $this->_currentProcessor = $oldProcessor;
446         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
447             $this->_currentProcessor->getParent()->setValue($replacementName, $result);
448         } else {
449             $this->_currentProcessor->setValue($replacementName, $result);
450         }
451         $this->_rowCount = $oldRowCount;
452         $this->_setCurrentState($oldState);
453     }
454
455     /**
456      * get word object
457      *
458      * @return \PhpOffice\PhpWord\PhpWord | \PhpOffice\PhpWord\TemplateProcessor
459      */
460     public function getDocument()
461     {
462         return $this->_docTemplate ? $this->_docTemplate : $this->_docObject;
463     }
464
465
466     /**
467      * create new PhpWord document
468      *
469      * @return void
470      */
471     protected function _createDocument()
472     {
473         \PhpOffice\PhpWord\Settings::setTempDir(Tinebase_Core::getTempDir());
474
475         $templateFile = $this->_getTemplateFilename();
476         $this->_docObject = new \PhpOffice\PhpWord\PhpWord();
477
478         if ($templateFile !== null) {
479             $this->_hasTemplate = true;
480             $this->_docTemplate = new Tinebase_Export_Richtext_TemplateProcessor($templateFile);
481         }
482     }
483
484     /**
485      * @param string $_key
486      * @param string $_value
487      */
488     protected function _setValue($_key, $_value)
489     {
490         if (true === $this->_skip) {
491             return;
492         }
493
494         $this->_currentProcessor->setValue($_key, $_value);
495
496         if ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
497             $this->_currentProcessor->getParent()->setValue($_key, $_value);
498         } elseif ($this->_currentProcessor->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBRECORD) {
499             $this->_currentProcessor->getParent()->setValue($_key, $_value);
500             if ($this->_currentProcessor->getParent()->getType() === Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD) {
501                 $this->_currentProcessor->getParent()->getParent()->setValue($_key, $_value);
502             }
503         }
504     }
505
506     /**
507      * @param string $_value
508      * @throws Tinebase_Exception_NotImplemented
509      */
510     protected function _writeValue($_value)
511     {
512         throw new Tinebase_Exception_NotImplemented(__CLASS__ . ' can not provide a meaningful default '
513                 . 'implementation. Subclass needs to provide or avoid it from being called');
514     }
515
516     public function _getTwigSource()
517     {
518         if (null === $this->_currentProcessor) {
519             $this->_onBeforeExportRecords();
520         }
521         $i = 0;
522         $source = '[';
523         foreach ($this->_getTemplateVariables() as $placeholder) {
524             if (strpos($placeholder, 'twig:') === 0) {
525                 $this->_twigMapping[$i] = $placeholder;
526                 $source .= ($i === 0 ? '' : ',') . '{{' . html_entity_decode(substr($placeholder, 5), ENT_QUOTES | ENT_XML1) . '}}';
527                 ++$i;
528             }
529         }
530         $source .= ']';
531
532         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
533             . ' returning twig template source: ' . $source);
534
535         return $source;
536     }
537
538     protected function _onBeforeExportRecords()
539     {
540         if (null !== $this->_docTemplate && null === $this->_currentProcessor) {
541             $this->_currentProcessor = $this->_docTemplate;
542             $this->_findAndReplaceDatasources();
543
544             if (empty($this->_dataSources)) {
545                 $this->_findAndReplaceGroup($this->_docTemplate);
546             }
547         }
548     }
549
550     protected function _findAndReplaceDatasources()
551     {
552         foreach ($this->_getTemplateVariables() as $placeholder) {
553             if (strpos($placeholder, 'DATASOURCE') === 0 && preg_match('/DATASOURCE_(.*)/', $placeholder, $match)) {
554                 if (null === ($dataSource = $this->_docTemplate->findBlock($placeholder, '${' . $match[0] . '}'))) {
555                     throw new Tinebase_Exception_UnexpectedValue('find&replace block for ' . $placeholder . ' failed');
556                 }
557
558                 $dataSource = '<?xml' . $dataSource;
559                 $processor = new Tinebase_Export_Richtext_TemplateProcessor($dataSource, true,
560                     Tinebase_Export_Richtext_TemplateProcessor::TYPE_DATASOURCE);
561                 $this->_dataSources[$match[1]] = $processor;
562
563                 $this->_findAndReplaceGroup($processor);
564             }
565         }
566     }
567
568     /**
569      * @param Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor
570      */
571     protected function _findAndReplaceGroup(Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor)
572     {
573         $config = array();
574
575         if (null !== ($group = $_templateProcessor->findBlock('GROUP_BLOCK', '${GROUP_BLOCK}'))) {
576             $groupProcessor = new Tinebase_Export_Richtext_TemplateProcessor('<?xml' . $group, true,
577                 Tinebase_Export_Richtext_TemplateProcessor::TYPE_GROUP, $_templateProcessor);
578
579             if (null === ($recordProcessor = $this->_findAndReplaceRecord($groupProcessor))) {
580                 $config['recordRow'] = $this->_findAndReplaceRecordRow($groupProcessor);
581             } else {
582                 $config['record'] = $recordProcessor;
583             }
584             if (null !== ($groupHeader = $_templateProcessor->findBlock('GROUP_HEADER', ''))) {
585                 $config['groupHeader'] = $groupHeader;
586             }
587             if (null !== ($groupFooter = $_templateProcessor->findBlock('GROUP_FOOTER', ''))) {
588                 $config['groupFooter'] = $groupFooter;
589             }
590             if (null !== ($groupSeparator = $_templateProcessor->findBlock('GROUP_SEPARATOR', ''))) {
591                 $config['groupSeparator'] = $groupSeparator;
592             }
593             if (isset($config['recordRow']) && (isset($config['groupHeader']) || isset($config['groupFooter']) ||
594                     isset($config['groupSeparator'])) && (isset($config['recordRow']['groupHeaderRow']) ||
595                     isset($config['recordRow']['groupFooterRow']) || isset($config['recordRow']['groupSeparatorRow']))) {
596                 throw new Tinebase_Exception_UnexpectedValue('GROUP with record row must not contain header, footer or separator as table row and group block at the same time');
597             }
598             $config['groupXml'] = $this->_cutXml($groupProcessor->getMainPart());
599             $groupProcessor->setMainPart('<?xml');
600             $groupProcessor->setConfig($config);
601             $config = array('group' => $groupProcessor);
602
603         } elseif (null === ($record = $this->_findAndReplaceRecord($_templateProcessor))) {
604             $config['recordRow'] = $this->_findAndReplaceRecordRow($_templateProcessor);
605         } else {
606             $config['record'] = $record;
607         }
608
609         $_templateProcessor->setConfig($config);
610     }
611
612     /**
613      * @param Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor
614      * @return Tinebase_Export_Richtext_TemplateProcessor|null
615      */
616     protected function _findAndReplaceRecord(Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor)
617     {
618         if (null !== ($recordBlock = $_templateProcessor->findBlock('RECORD_BLOCK', '${RECORD_BLOCK}'))) {
619             $processor = new Tinebase_Export_Richtext_TemplateProcessor('<?xml' . $recordBlock, true,
620                 Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD, $_templateProcessor);
621             $this->_findAndReplaceSubGroup($processor);
622             $config = array(
623                 'recordXml'     => $this->_cutXml($processor->getMainPart())
624             );
625             $processor->setMainPart('<?xml');
626
627             if (null !== ($recordHeader = $_templateProcessor->findBlock('RECORD_HEADER', ''))) {
628                 $config['header'] = $recordHeader;
629             }
630
631             if (null !== ($recordFooter = $_templateProcessor->findBlock('RECORD_FOOTER', ''))) {
632                 $config['footer'] = $recordFooter;
633             }
634
635             if (null !== ($recordSeparator = $_templateProcessor->findBlock('RECORD_SEPARATOR', ''))) {
636                 $config['separator'] = $recordSeparator;
637             }
638             $processor->setConfig(array_merge($processor->getConfig(), $config));
639             return $processor;
640         }
641
642         return null;
643     }
644
645     /**
646      * @param Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor
647      * @return array
648      * @throws Tinebase_Exception_UnexpectedValue
649      * @throws \PhpOffice\PhpWord\Exception\Exception
650      */
651     protected function _findAndReplaceRecordRow(Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor)
652     {
653         $result = array();
654
655         if (preg_match('/<w:tbl.*?(\$\{twig:[^}]*record[^}]*})/is', $_templateProcessor->getMainPart(), $matches)) {
656             $result['recordRow'] = $_templateProcessor->replaceRow($matches[1], '${RECORD_ROW}');
657             $processor = new Tinebase_Export_Richtext_TemplateProcessor('<?xml' . $result['recordRow'], true,
658                 Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD, $_templateProcessor);
659             $this->_findAndReplaceSubGroup($processor);
660             $processor->setConfig(array(
661                 'recordXml'     => $this->_cutXml($processor->getMainPart())
662             ));
663             $processor->setMainPart('<?xml');
664             $result['recordRowProcessor'] = $processor;
665
666             if (strpos($_templateProcessor->getMainPart(), '${GROUP_HEADER}') !== false) {
667                 $result['groupHeaderRow'] = str_replace('${GROUP_HEADER}', '', $_templateProcessor->replaceRow('${GROUP_HEADER}', ''));
668             }
669
670             if (strpos($_templateProcessor->getMainPart(), '${GROUP_FOOTER}') !== false) {
671                 $result['groupFooterRow'] = str_replace('${GROUP_FOOTER}', '', $_templateProcessor->replaceRow('${GROUP_FOOTER}', ''));
672             }
673
674             if (strpos($_templateProcessor->getMainPart(), '${GROUP_SEPARATOR}') !== false) {
675                 $result['groupSeparatorRow'] = str_replace('${GROUP_SEPARATOR}', '', $_templateProcessor->replaceRow('${GROUP_SEPARATOR}', ''));
676             }
677         } else {
678             throw new Tinebase_Exception_UnexpectedValue('template without RECORD_BLOCK needs to contain a table row with a replacement variable');
679         }
680
681         return $result;
682     }
683
684     /**
685      * @param Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor
686      * @throws Tinebase_Exception
687      */
688     protected function _findAndReplaceSubGroup(Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor)
689     {
690         $parentConfig = array('subgroups' => array(), 'subrecords' => array());
691
692         do {
693             $foundGroup = null;
694             $foundRecord = null;
695             foreach ($_templateProcessor->getVariables() as $var) {
696                 if (strpos($var, 'SUBGROUP') === 0) {
697                     $foundGroup = $var;
698                     break;
699                 }
700                 if (null === $foundRecord && strpos($var, 'SUBRECORD') === 0) {
701                     $foundRecord = $var;
702                 }
703             }
704
705             $config = array();
706             if (null !== $foundGroup) {
707                 if (null !== ($group = $_templateProcessor->findBlock($foundGroup, '${R' . $foundGroup . '}'))) {
708                     list(,$propertyName) = explode('_', $foundGroup);
709                     $groupProcessor = new Tinebase_Export_Richtext_TemplateProcessor('<?xml' . $group, true,
710                         Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBGROUP, $_templateProcessor);
711
712                     if (null === ($recordProcessor = $this->_findAndReplaceSubRecord($groupProcessor))) {
713                         throw new Tinebase_Exception('subgroup without record block: ' . $foundGroup);
714                     } else {
715                         $config['record'] = $recordProcessor;
716                     }
717                     if (null !== ($groupHeader = $_templateProcessor->findBlock('SUBG_HEADER_' . $propertyName, ''))) {
718                         $config['groupHeader'] = $groupHeader;
719                     }
720                     if (null !== ($groupFooter = $_templateProcessor->findBlock('SUBG_FOOTER_' . $propertyName, ''))) {
721                         $config['groupFooter'] = $groupFooter;
722                     }
723                     if (null !== ($groupSeparator = $_templateProcessor->findBlock('SUBG_SEPARATOR_' . $propertyName, ''))) {
724                         $config['groupSeparator'] = $groupSeparator;
725                     }
726                     $config['groupXml'] = $this->_cutXml($groupProcessor->getMainPart());
727                     $groupProcessor->setMainPart('<?xml');
728                     $groupProcessor->setConfig($config);
729                     $parentConfig['subgroups'][$propertyName] = $groupProcessor;
730                 } else {
731                     throw new Tinebase_Exception('find&replace block failed after subgroup was found: ' . $foundGroup);
732                 }
733             } elseif (null !== $foundRecord) {
734                 if (null === ($recordProcessor = $this->_findAndReplaceSubRecord($_templateProcessor))) {
735                     throw new Tinebase_Exception('subrecord block failed: ' . $foundRecord);
736                 }
737                 list(,$propertyName) = explode('_', $foundRecord);
738                 $parentConfig['subrecords'][$propertyName] = $recordProcessor;
739             } else {
740                 break;
741             }
742         } while (true);
743
744         $config = $_templateProcessor->getConfig();
745         if (count($parentConfig['subgroups']) > 0) {
746             $config['subgroups'] = $parentConfig['subgroups'];
747         }
748         if (count($parentConfig['subrecords']) > 0) {
749             $config['subrecords'] = $parentConfig['subrecords'];
750         }
751         $_templateProcessor->setConfig($config);
752     }
753
754     /**
755      * @param Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor
756      * @return null|Tinebase_Export_Richtext_TemplateProcessor
757      * @throws Tinebase_Exception
758      */
759     protected function _findAndReplaceSubRecord(Tinebase_Export_Richtext_TemplateProcessor $_templateProcessor)
760     {
761         $foundRecord = null;
762         foreach ($_templateProcessor->getVariables() as $var) {
763             if (null === $foundRecord && strpos($var, 'SUBRECORD') === 0) {
764                 $foundRecord = $var;
765                 break;
766             }
767         }
768         if (null === $foundRecord) {
769             return null;
770         }
771
772         if (null !== ($recordBlock = $_templateProcessor->findBlock($foundRecord, '${R' . $foundRecord . '}'))) {
773             list(,$propertyName) = explode('_', $foundRecord);
774             $processor = new Tinebase_Export_Richtext_TemplateProcessor('<?xml', true,
775                 Tinebase_Export_Richtext_TemplateProcessor::TYPE_SUBRECORD, $_templateProcessor);
776             $config = array(
777                 'recordXml'     => $recordBlock,
778                 'name'          => $foundRecord,
779             );
780
781             if (null !== ($recordHeader = $_templateProcessor->findBlock('SUBR_HEADER_' . $propertyName, ''))) {
782                 $config['header'] = $recordHeader;
783             }
784
785             if (null !== ($recordFooter = $_templateProcessor->findBlock('SUBR_FOOTER_' . $propertyName, ''))) {
786                 $config['footer'] = $recordFooter;
787             }
788
789             if (null !== ($recordSeparator = $_templateProcessor->findBlock('SUBR_SEPARATOR_' . $propertyName, ''))) {
790                 $config['separator'] = $recordSeparator;
791             }
792             $processor->setConfig($config);
793             return $processor;
794         } else {
795             throw new Tinebase_Exception('find&replace block failed after subrecord was found: ' . $foundRecord);
796         }
797     }
798
799
800     /**
801      * now simulate processIteration and finish with _onAfterExportRecords
802      *
803      * @param array $_result
804      */
805     protected function _onAfterExportRecords(array $_result)
806     {
807         $this->_unwrapProcessors();
808
809         parent::_onAfterExportRecords($_result);
810     }
811
812     protected function _getTemplateVariables()
813     {
814         if (null === $this->_templateVariables) {
815             $this->_templateVariables = $this->_docTemplate->getVariables();
816         }
817
818         return $this->_templateVariables;
819     }
820 }