db6d5f2b0c152885e284b2ec29c5bf2419b9d696
[tine20] / tine20 / Tinebase / Import / Abstract.php
1 <?php
2 /**
3  * Tine 2.0
4  * 
5  * @package     Tinebase
6  * @subpackage  Import
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Cornelius Weiss <c.weiss@metaways.de>
9  * @copyright   Copyright (c) 2010-2013 Metaways Infosystems GmbH (http://www.metaways.de)
10  */
11
12 /**
13  * abstract Tinebase Import
14  * 
15  * @package Tinebase
16  * @subpackage  Import
17  */
18 abstract class Tinebase_Import_Abstract implements Tinebase_Import_Interface
19 {
20     /**
21      * import result array
22      * 
23      * @var array
24      */
25     protected $_importResult = array(
26         'results'           => NULL,
27         'exceptions'        => NULL,
28         'totalcount'        => 0,
29         'updatecount'       => 0,
30         'failcount'         => 0,
31         'duplicatecount'    => 0,
32     );
33     
34     /**
35      * possible configs with default values
36      * 
37      * @var array
38      */
39     protected $_options = array(
40         'dryrun'            => false,
41         'updateMethod'      => 'update',
42         'createMethod'      => 'create',
43         'model'             => '',
44         'shared_tags'       => 'create', //'onlyexisting',
45         'autotags'          => array(),
46         'encoding'          => 'auto',
47         'encodingTo'        => 'UTF-8',
48         'useStreamFilter'   => true,
49         'postMappingHook'   => null,
50         // if this is set, always resolve duplicates
51         'duplicateResolveStrategy' => null,
52     );
53     
54     /**
55      * additional config options (to be added by child classes)
56      * 
57      * @var array
58      */
59     protected $_additionalOptions = array();
60     
61     /**
62      * the record controller
63      *
64      * @var Tinebase_Controller_Record_Interface
65      */
66     protected $_controller = NULL;
67     
68     /**
69      * constructs a new importer from given config
70      * 
71      * @param array $_options
72      */
73     public function __construct(array $_options = array())
74     {
75         $this->_options = array_merge($this->_options, $this->_additionalOptions);
76         
77         foreach($_options as $key => $cfg) {
78             if ((isset($this->_options[$key]) || array_key_exists($key, $this->_options))) {
79                 $this->_options[$key] = $cfg;
80             }
81         }
82         
83         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
84             . ' Creating importer with following config: ' . print_r($this->_options, TRUE));
85     }
86     
87     /**
88      * import given filename
89      * 
90      * @param string $_filename
91      * @param array $_clientRecordData
92      * @return @see{Tinebase_Import_Interface::import}
93      */
94     public function importFile($_filename, $_clientRecordData = array())
95     {
96         if (preg_match('/^win/i', PHP_OS)) {
97            $_filename = utf8_decode($_filename);
98         }
99         if (! file_exists($_filename)) {
100             throw new Tinebase_Exception_NotFound("File $_filename not found.");
101         }
102         $resource = fopen($_filename, 'r');
103         
104         $retVal = $this->import($resource, $_clientRecordData);
105         fclose($resource);
106         
107         return $retVal;
108     }
109     
110     /**
111      * import from given data
112      * 
113      * @param string $_data
114      * @param array $_clientRecordData
115      * @return @see{Tinebase_Import_Interface::import}
116      */
117     public function importData($_data, $_clientRecordData = array())
118     {
119         $resource = fopen('php://memory', 'w+');
120         fwrite($resource, $_data);
121         rewind($resource);
122         
123         $retVal = $this->import($resource);
124         fclose($resource);
125         
126         return $retVal;
127     }
128     
129     /**
130      * import the data
131      *
132      * @param resource $_resource (if $_filename is a stream)
133      * @param array $_clientRecordData
134      * @return array with import data (imported records, failures, duplicates and totalcount)
135      */
136     public function import($_resource = NULL, $_clientRecordData = array())
137     {
138         if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ 
139             . ' Starting import of ' . ((! empty($this->_options['model'])) ? $this->_options['model'] . 's' : ' records'));
140         
141         $this->_initImportResult();
142         $this->_appendStreamFilters($_resource);
143         $this->_beforeImport($_resource);
144         $this->_doImport($_resource, $_clientRecordData);
145         $this->_logImportResult();
146         $this->_afterImport();
147         
148         return $this->_importResult;
149     }
150     
151     /**
152      * append stream filter for correct linebreaks
153      * - iconv with IGNORE
154      * - replace linebreaks
155      * 
156      * @param resource $resource
157      */
158     protected function _appendStreamFilters($resource)
159     {
160         if (! $resource || ! isset($this->_options['useStreamFilter']) || ! $this->_options['useStreamFilter']) {
161             return;
162         }
163
164         if (! isset($this->_options['encoding']) || $this->_options['encoding'] === 'auto' && extension_loaded('mbstring')) {
165             require_once 'StreamFilter/ConvertMbstring.php';
166             $filter = 'convert.mbstring';
167         } else if (isset($this->_options['encoding']) && $this->_options['encoding'] !== $this->_options['encodingTo']) {
168             $filter = 'convert.iconv.' . $this->_options['encoding'] . '/' . $this->_options['encodingTo'] . '//IGNORE';
169         } else {
170             $filter = NULL;
171         }
172             
173         if ($filter !== NULL) {
174             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
175                 . ' Add convert stream filter: ' . $filter);
176             stream_filter_append($resource, $filter);
177         }
178         
179         if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ 
180             . ' Adding streamfilter for correct linebreaks');
181         require_once 'StreamFilter/StringReplace.php';
182         stream_filter_append($resource, 'str.replace', STREAM_FILTER_READ, array(
183             'search'            => '/\r\n{0,1}/',
184             'replace'           => "\r\n",
185             'searchIsRegExp'    => TRUE
186         ));
187     }
188     
189     /**
190      * init import result data
191      */
192     protected function _initImportResult()
193     {
194         $this->_importResult['results']     = (! empty($this->_options['model'])) ? new Tinebase_Record_RecordSet($this->_options['model']) : array();
195         $this->_importResult['exceptions']  = new Tinebase_Record_RecordSet('Tinebase_Model_ImportException');
196     }
197     
198     /**
199      * do something before the import
200      * 
201      * @param resource $_resource
202      */
203     protected function _beforeImport($_resource = NULL)
204     {
205     }
206
207     /**
208      * do something after the import
209      */
210     protected function _afterImport()
211     {
212     }
213
214     /**
215      * do import: loop data -> convert to records -> import records
216      * 
217      * @param mixed $_resource
218      * @param array $_clientRecordData
219      */
220     protected function _doImport($_resource = NULL, $_clientRecordDatas = array())
221     {
222         $clientRecordDatas = $this->_sortClientRecordsByIndex($_clientRecordDatas);
223         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
224             . ' Client record data: ' . print_r($clientRecordDatas, TRUE));
225         
226         $recordIndex = 0;
227         while (($recordData = $this->_getRawData($_resource)) !== FALSE) {
228             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
229                 . ' Importing record ' . $recordIndex . ' ...');
230             $recordToImport = NULL;
231             try {
232                 // client record overwrites record in import data (only if set)
233                 $clientRecordData = (isset($clientRecordDatas[$recordIndex]) || array_key_exists($recordIndex, $clientRecordDatas)) ? $clientRecordDatas[$recordIndex]['recordData'] : NULL;
234                 if ($clientRecordData && Tinebase_Core::isLogLevel(Zend_Log::TRACE)) {
235                     Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Client record: ' . print_r($clientRecordData, TRUE));
236                 }
237                 
238                 // NOTE _processRawData might return multiple recordDatas
239                 // NOTE $clientRecordData is always one record
240                 $recordDataToImport = $clientRecordData ? array($clientRecordData) : $this->_processRawData($recordData);
241                 $resolveStrategy = $clientRecordData ? $clientRecordDatas[$recordIndex]['resolveStrategy'] : NULL;
242                 
243                 foreach ($recordDataToImport as $idx => $processedRecordData) {
244                     $recordToImport = $this->_createRecordToImport($processedRecordData);
245                     if ($resolveStrategy !== 'discard') {
246                         $importedRecord = $this->_importRecord($recordToImport, $resolveStrategy, $processedRecordData);
247                         $this->_inspectAfterImport($importedRecord);
248                     } else {
249                         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
250                                 . ' Discarding record ' . $recordIndex);
251                         
252                         // just add autotags to record (if id is available)
253                         if ($recordToImport->getId()) {
254                             $record = call_user_func(array($this->_controller, 'get'), $recordToImport->getId());
255                             $this->_addAutoTags($record);
256                             call_user_func(array($this->_controller, $this->_options['updateMethod']), $record);
257                         }
258                     }
259                 }
260             } catch (Exception $e) {
261                 $this->_handleImportException($e, $recordIndex, $recordToImport);
262             }
263             $recordIndex++;
264         }
265     }
266
267     /**
268      * do something with the imported record
269      *
270      * @param $importedRecord
271      */
272     protected function _inspectAfterImport($importedRecord)
273     {
274
275     }
276     
277     /**
278      * sort client data array
279      * 
280      * @param array $_clientRecordData
281      * @return array
282      */
283     protected function _sortClientRecordsByIndex($_clientRecordData)
284     {
285         $result = array();
286         
287         foreach ($_clientRecordData as $data) {
288             if (isset($data['index'])) {
289                 $result[$data['index']] = $data;
290             }
291         }
292         
293         return $result;
294     }
295     
296     /**
297      * process raw data (mapping + conversions)
298      * 
299      * NOTE: returns empty Traversable if record should be skipped on purpose
300      * NOTE: If there will occur any import error the client management will only work for 1 imported entry
301      *       and not if multiple were stored in ArrayObject
302      *
303      * @param array $_data
304      * @return Traversable
305      * @throws Tinebase_Exception_Record_Validation on broken mapping
306      */
307     protected function _processRawData($_data)
308     {
309         $result = array();
310         
311         $mappedData = $this->_doMapping($_data);
312         
313         if ((isset($this->_options["postMappingHook"]) || array_key_exists("postMappingHook", $this->_options))) {
314             if (isset($this->_options['postMappingHook']['path'])) {
315                $mappedData = $this->_postMappingHook($mappedData);
316             }
317         }
318         
319         if (empty($mappedData) && empty($_data)) {
320             Throw new Tinebase_Exception_UnexpectedValue("_processRawData got no data and could not map any.");
321         }
322         
323         $mappedData = $mappedData instanceof ArrayObject ? $mappedData : new ArrayObject(array($mappedData), ArrayObject::STD_PROP_LIST);
324
325         if (! empty($mappedData)) {
326             foreach ($mappedData as $idx => $recordArray) {
327                 $convertedData = $this->_doConversions($recordArray);
328                 $mappedData[$idx] = array_merge($convertedData, $this->_addData($convertedData));
329             }
330             $result = $mappedData;
331             
332             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
333                 . ' Merged data: ' . print_r($result, true));
334         } else {
335             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
336                 . ' Got empty record from mapping! Was: ' . print_r($_data, TRUE));
337         }
338         return $result;
339     }
340     
341     /**
342      * do the mapping and replacements
343      *
344      * @param array $_data
345      * @return array
346      */
347     protected function _doMapping($_data)
348     {
349         return $_data;
350     }
351     
352     /**
353      * Runs a user defined script
354      *
355      * After your data are mapped and the hook is enabled in your definition every data set will be
356      *  parsed through this hook.
357      * 
358      * It will convert the data set to a json object and send it to the stdin of a script.
359      *  The script should usualy print a json object of the extended, manipulated or corrected data set. 
360      * 
361      * But if you intend to split the data to two or more sets or import data from different sources
362      *  you have to print a json array.
363      * 
364      * @param array $data
365      * @return Array|ArrayObject
366      * @throws Tinebase_Exception_UnexpectedValue
367      * @throws Tinebase_Exception
368      */
369     protected function _postMappingHook ($data)
370     {
371         $jsonEncodedData = Zend_Json::encode($data);
372         $jsonDecodedData = null;
373         
374         $script = $this->_options['postMappingHook']['path'];
375         //The path given in the xml is not dynamic. Therefore it must be absolute or relative to the tine20 directory.
376         if ($script[0] !== DIRECTORY_SEPARATOR) {
377             $basedir = dirname(dirname(dirname(__FILE__)));
378             $script = $basedir . DIRECTORY_SEPARATOR . $script;
379         }
380
381         if (! is_executable($script)) {
382             throw new Tinebase_Exception_UnexpectedValue("Script does not exists or isn't executable. Path: " . $script);
383         }
384         
385         $jDataToSend =  escapeshellarg($jsonEncodedData);
386
387         try {
388             $jsonReceivedData = shell_exec(escapeshellcmd($script) . " $jDataToSend");
389         } catch (Exception $e) {
390             $jsonReceivedData = null;
391
392             throw new Tinebase_Exception('Could not execute script: ' . $script);
393         }
394
395         $returnJDecodedData = Zend_Json_Decoder::decode($jsonReceivedData);
396         if (! $returnJDecodedData) {
397             throw new Tinebase_Exception_UnexpectedValue("Something went wrong by decoding the received json data!");
398         }
399         
400         if (strpos($jsonReceivedData, '[') === 0) {
401             $return = new ArrayObject(array(), ArrayObject::STD_PROP_LIST);
402             foreach ($returnJDecodedData as $key => $val)
403                 $return[$key] = $val;
404         } else {
405             $return = $returnJDecodedData;
406         }
407         
408         return $return;
409     }
410     
411     /**
412      * do conversions (transformations, charset, replacements ...)
413      *
414      * @param array $_data
415      * @return array
416      * 
417      * @todo add date and other conversions
418      * @todo add generic mechanism for value pre/postfixes? (see accountLoginNamePrefix in Admin_User_Import)
419      */
420     protected function _doConversions($_data)
421     {
422         if (isset($this->_options['mapping'])) {
423             $data = $this->_doMappingConversion($_data);
424         } else {
425             $data = $_data;
426         }
427         
428         foreach ($data as $key => $value) { 
429             $data[$key] = $this->_convertEncoding($value);
430         }
431         
432         return $data;
433     }
434     
435     /**
436      * convert encoding
437      * NOTE: always do encoding with //IGNORE as we do not know the actual encoding in some cases
438      * 
439      * @param string|array $_value
440      * @return string|array
441      */
442     protected function _convertEncoding($_value)
443     {
444         if (empty($_value) || (! isset($this->_options['encodingTo']) || (isset($this->_options['useStreamFilter']) && $this->_options['useStreamFilter']))) {
445             return $_value;
446         }
447         
448         if (is_array($_value)) {
449             $result = array();
450             foreach ($_value as $singleValue) {
451                 $result[] = $this->_doConvert($singleValue);
452             }
453         } else {
454             $result = $this->_doConvert($_value);
455         }
456         
457         return $result;
458     }
459     
460     /**
461      * convert string with iconv or mb_convert_encoding
462      * 
463      * @param string $string
464      * @return string
465      */
466     protected function _doConvert($string)
467     {
468         $result = $string;
469
470         if ((!isset($this->_options['encoding']) || $this->_options['encoding'] === 'auto') && extension_loaded('mbstring')) {
471             $encoding = mb_detect_encoding($string, array('utf-8', 'iso-8859-1', 'windows-1252', 'iso-8859-15'));
472             if ($encoding !== FALSE) {
473                 $encodingFn = 'mb_convert_encoding';
474                 $result = @mb_convert_encoding($string, $this->_options['encodingTo'], $encoding);
475             }
476         } else if (isset($this->_options['encoding'])) {
477             $encoding = $this->_options['encoding'];
478             $encodingFn = 'iconv';
479             $result = @iconv($encoding, $this->_options['encodingTo'] . '//TRANSLIT', $string);
480         }
481
482         if (isset($encoding) && isset($encodingFn) && Tinebase_Core::isLogLevel(Zend_Log::TRACE)) {
483             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
484                 . ' Encoded ' . $string . ' from ' . $encoding . ' to ' . $this->_options['encodingTo']
485                 . ' using ' . $encodingFn . ' . => ' . $result);
486         }
487
488         return $result;
489     }
490     
491     /**
492      * do the mapping conversions defined in field configs
493      *
494      * @param array $_data
495      * @return array
496      */
497     protected function _doMappingConversion($_data)
498     {
499         $data = $_data;
500         foreach ($this->_options['mapping']['field'] as $index => $field) {
501             if (! (isset($field['destination']) || array_key_exists('destination', $field)) || $field['destination'] == '' || ! isset($_data[$field['destination']])) {
502                 continue;
503             }
504         
505             $key = $field['destination'];
506         
507             if (isset($field['replace'])) {
508                 if ($field['replace'] === '\n') {
509                     $data[$key] = str_replace("\\n", "\r\n", $_data[$key]);
510                 }
511             } else if (isset($field['relation'])) {
512                 if (! isset($data['relations'])) {
513                     $data['relations'] = array();
514                 }
515                 $data['relations'] = array_merge($data['relations'], $this->_mapRelation($_data[$key], $field, $data));
516             } else if (isset($field['separator'])) {
517                 $data[$key] = $this->_splitBySeparator($field['separator'], $_data[$key]);
518             } else if (isset($field['fixed'])) {
519                 $data[$key] = $field['fixed'];
520             } else if (isset($field['append'])) {
521                 $data[$key] .= $field['append'] . $_data[$key];
522             } else if (isset($field['typecast'])) {
523                 switch ($field['typecast']) {
524                     case 'int':
525                     case 'integer':
526                         $data[$key] = (integer) $_data[$key];
527                         break; 
528                     case 'string':
529                         $data[$key] = (string) $_data[$key];
530                         break;
531                     case 'bool':
532                     case 'boolean':
533                         $data[$key] = (string) $_data[$key];
534                         break;
535                     case 'datetime':
536                         if (isset($_data[$key])) {
537                             $datetime = isset($field["datetime_pattern"]) ?
538                                 DateTime::createFromFormat($field["datetime_pattern"], $_data[$key]) :
539                                 new DateTime($_data[$key]);
540                             
541                             $data[$key] = $datetime instanceof DateTime ? $datetime->format('Y-m-d H:i:s') : null;
542                         }
543                         break;
544                     default:
545                         $data[$key] = $_data[$key];
546                 }
547             } else {
548                 $data[$key] = $_data[$key];
549             }
550         }
551         
552         return $data;
553     }
554     
555     protected function _splitBySeparator($separator, $value)
556     {
557         return preg_split('/\s*' . $separator . '\s*/', $value);
558     }
559
560     /**
561      * map import relation
562      * 
563      * @param array $fieldValue
564      * @param array $field definition
565      * @param array $data
566      * @return array
567      *
568      * <field>
569      *      <source>RESPONSIBLE</source>
570      *      <destination>RESPONSIBLE-n_fn</destination>
571      *      <relation>1</relation>
572      *      <filter>query</filter>
573      *      <filterValueAdd>RESPONSIBLE_adr_one_locality</filterValueAdd> // if this is found in import data,
574      *                                                                 // add it to filter value. for example to add
575      *                                                                 // locality to name for finding the right contact
576      *      <operator>contains</operator>
577      *      <related_model>Addressbook_Model_Contact</related_model>
578      *      <related_field>n_family</related_field> // map data to this field if no existing record found
579      *      <degree>parent</degree>
580      *      <targetField>lead_name</targetField>
581      *      <targetFieldData>n_family, adr_one_locality</targetFieldData>
582      * </field>
583      */
584     protected function _mapRelation($fieldValue, $field, &$data)
585     {
586         if (empty($fieldValue)) {
587             // no need to continue here
588             return array();
589         }
590
591         if (! isset($field['related_model'])) {
592             throw new Tinebase_Exception_UnexpectedValue('field config missing');
593         }
594         
595         $values = (isset($field['separator'])) ? $this->_splitBySeparator($field['separator'], $fieldValue): array($fieldValue);
596
597         $relations = array();
598         foreach ($values as $value) {
599             $relation = $this->_getRelationForValue($value, $field, $data);
600             $relations[] = $relation;
601         }
602
603         // TODO how do we handle this with multiple relations/values?
604         if (isset($field['targetField']) && isset($field['targetFieldData']) && count($relations) > 0) {
605             $this->_setTargetFieldFromRelation($field, $data, $relations[0]);
606         }
607         
608         return $relations;
609     }
610
611     protected function _setTargetFieldFromRelation($field, &$data, $relation)
612     {
613         $unreplaced = $targetField = $field['targetFieldData'];
614         $recordArray = $relation['related_record'];
615         foreach ($recordArray as $key => $value) {
616             if (preg_match('/' . preg_quote($key) . '/', $targetField) && is_scalar($value)) {
617                 $targetField = preg_replace('/' . preg_quote($key) . '/', $value, $targetField);
618                 $unreplaced = preg_replace('/^[, ]*' . preg_quote($key) . '/', '', $unreplaced);
619             }
620         }
621
622         // remove unreplaced stuff
623         $targetField = str_replace($unreplaced, '', $targetField);
624
625         // finally set the target field value
626         $data[$field['targetField']] = trim($targetField);
627     }
628
629     protected function _getRelationForValue($value, $field, $data)
630     {
631         $existingRelation = null;
632         if (isset($field['filter'])) {
633             $existingRelation = $this->_findExistingRelation($value, $field, $data);
634         }
635         $relation = $this->_getRelationData($existingRelation, $field, $data, $value);
636         
637         return $relation;
638     }
639     
640     protected function _findExistingRelation($value, $field, $data)
641     {
642         // check if related record exists
643         $controller = Tinebase_Core::getApplicationInstance($field['related_model']);
644         $filterModel = $field['related_model'] . 'Filter';
645         $operator = isset($field['operator']) ? $field['operator'] : 'equals';
646         
647         $filterValueToAdd = '';
648         if (isset($field['filterValueAdd'])) {
649             if ($field['filter'] === 'query') {
650                 $filters = explode(',', $field['filterValueAdd']);
651                 foreach ($filters as $newFilter) {
652                     if(isset($data[$newFilter])) {
653                         $filterValueToAdd = $filterValueToAdd . ' ' . $data[$newFilter];
654                     }
655                 }
656             } else {
657                 if (Tinebase_Core::isLogLevel(Zend_Log::WARN)) {
658                     Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__
659                     . ' "filterValueAdd" Currently only working for query filter');
660                 }
661             }
662         }
663         
664         $filter = new $filterModel(array(
665                 array('field' => $field['filter'], 'operator' => $operator, 'value' => $value . $filterValueToAdd)
666         ));
667         $result = $controller->search($filter, null, /* $_getRelations */ true);
668         return $result->getFirstRecord();
669     }
670
671     protected function _getRelationData($record, $field, $data, $value)
672     {
673         $relationType = $field['destination'];
674         $relation = array(
675             'type'          => $relationType,
676             'related_model' => $field['related_model'],
677             // TODO move this to product (modelconfig?)
678             'remark'        => $relationType == 'PRODUCT' ? array('quantity' => 1) : null,
679         );
680
681         if ($record) {
682             $relation['related_id'] = $record->getId();
683             $recordArray = $record->toArray();
684         } else {
685             // create new related record
686             $recordArray = array(
687                 (isset($field['related_field']) ? $field['related_field'] : $field['filter']) => $value
688             );
689             if (! empty($filterValueToAdd)) {
690                 $recordArray[str_replace($relationType . '_', '', $field['filterValueAdd'])] = trim($filterValueToAdd);
691             }
692         }
693
694         // add more data for this relation if available
695         foreach ($data as $key => $value) {
696             $regex = '/^' . preg_quote($relationType) . '_/';
697             if (preg_match($regex, $key)) {
698                 $relatedField = preg_replace($regex, '', $key);
699                 $recordArray[$relatedField] = trim($value);
700             }
701         }
702
703         $relation['related_record'] = $recordArray;
704
705         return $relation;
706     }
707
708     /**
709      * add some more values (overwrite that if you need some special/dynamic fields)
710      *
711      * @return  array
712      */
713     protected function _addData()
714     {
715         return array();
716     }
717     
718     /**
719      * create record from record data
720      * 
721      * @param array $_recordData
722      * @return Tinebase_Record_Abstract
723      */
724     protected function _createRecordToImport($_recordData)
725     {
726         $record = new $this->_options['model'](array(), TRUE);
727         $record->setFromJsonInUsersTimezone($_recordData);
728
729         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) {
730             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Record to import: ' . print_r($record->toArray(), TRUE));
731         }
732         
733         return $record;
734     }
735     
736     /**
737      * import single record
738      *
739      * @param Tinebase_Record_Abstract $_record
740      * @param string $_resolveStrategy
741      * @param array $_recordData not needed here but in other import classes (i.a. Admin_Import_Csv)
742      * @return Tinebase_Record_Abstract the imported record
743      * @throws Tinebase_Exception_Record_Validation
744      */
745     protected function _importRecord($_record, $_resolveStrategy = NULL, $_recordData = array())
746     {
747         $_record->isValid(TRUE);
748         
749         if ($this->_options['dryrun']) {
750             Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
751         }
752         
753         $this->_handleTags($_record, $_resolveStrategy);
754         
755         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
756             . ' Record to import: ' . print_r($_record->toArray(), true));
757         
758         $importedRecord = $this->_importAndResolveConflict($_record, $_resolveStrategy);
759         
760         $this->_importResult['results']->addRecord($importedRecord);
761         
762         if ($this->_options['dryrun']) {
763             Tinebase_TransactionManager::getInstance()->rollBack();
764         } else if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) {
765             Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Successfully imported record with id ' . $importedRecord->getId());
766         }
767         
768         $this->_importResult['totalcount']++;
769
770         return $importedRecord;
771     }
772     
773     /**
774      * handle record tags
775      * 
776      * @param Tinebase_Record_Abstract $_record
777      * @param string $_resolveStrategy
778      */
779     protected function _handleTags($_record, $_resolveStrategy = NULL)
780     {
781         if (isset($_record->tags) && is_array($_record->tags)) {
782             $_record->tags = $this->_addSharedTags($_record->tags);
783         } else {
784             $_record->tags = NULL;
785         }
786         
787         if ($_resolveStrategy === NULL && ! empty($this->_options['autotags'])) {
788             // only add autotags for "new" records
789             $this->_addAutoTags($_record);
790         }
791     }
792     
793     /**
794     * add/create shared tags if they don't exist
795     *
796     * @param   array $_tags array of tag strings
797     * @return  Tinebase_Record_RecordSet with Tinebase_Model_Tag
798     */
799     protected function _addSharedTags($_tags)
800     {
801         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Adding tags: ' . print_r($_tags, TRUE));
802     
803         $result = new Tinebase_Record_RecordSet('Tinebase_Model_Tag');
804         foreach ($_tags as $tagData) {
805             $tagData = (is_array($tagData)) ? $tagData : array('name' => $tagData);
806             $tagName = trim($tagData['name']);
807     
808             // only check non-empty tags
809             if (empty($tagName)) {
810                 continue;
811             }
812     
813             $createTag = (isset($this->_options['shared_tags']) && $this->_options['shared_tags'] == 'create');
814             $tagToAdd = $this->_getSingleTag($tagName, $tagData, $createTag);
815             if ($tagToAdd) {
816                 $result->addRecord($tagToAdd);
817             }
818         }
819         
820         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
821             . ' ' . print_r($result->toArray(), TRUE));
822     
823         return $result;
824     }
825     
826     /**
827      * get tag / create on the fly
828      * 
829      * @param string $_name
830      * @param array $_tagData
831      * @param boolean $_create
832      * @return Tinebase_Model_Tag
833      */
834     protected function _getSingleTag($_name, $_tagData = array(), $_create = TRUE)
835     {
836         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
837             . ' Tag name: ' . $_name . ' / data: ' . print_r($_tagData, TRUE));
838         
839         $name = $_name;
840         if (isset($_tagData['name'])) {
841             $_tagData['name'] = $name;
842         }
843         
844         $tag = NULL;
845         
846         if (isset($_tagData['id'])) {
847             try {
848                 $tag = Tinebase_Tags::getInstance()->get($_tagData['id']);
849                 return $tag;
850             } catch (Tinebase_Exception_NotFound $tenf) {
851                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
852                     . ' Could not find tag by id: ' . $_tagData['id']);
853             }
854         }
855         
856         try {
857             $tag = Tinebase_Tags::getInstance()->getTagByName($name, Tinebase_Model_TagRight::USE_RIGHT, NULL);
858             return $tag;
859         } catch (Tinebase_Exception_NotFound $tenf) {
860             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
861                 . ' Could not find tag by name: ' . $name);
862         }
863         
864         if ($_create) {
865             $tagData = (! empty($_tagData)) ? $_tagData : array(
866                 'name' => $name,
867             );
868             $tag = $this->_createTag($tagData);
869         }
870         
871         return $tag;
872     }
873     
874     /**
875      * create new tag
876      * 
877      * @param array $_tagData
878      * @return Tinebase_Model_Tag
879      * 
880      * @todo allow to set contexts / application / rights
881      * @todo only ignore acl for autotags that are present in import definition
882      */
883     protected function _createTag($_tagData)
884     {
885         $description  = substr((isset($_tagData['description'])) ? $_tagData['description'] : $_tagData['name'] . ' (imported)', 0, 50);
886         $type         = (isset($_tagData['type']) && ! empty($_tagData['type'])) ? $_tagData['type'] : Tinebase_Model_Tag::TYPE_SHARED;
887         $color        = (isset($_tagData['color'])) ? $_tagData['color'] : '#ffffff';
888         
889         $newTag = new Tinebase_Model_Tag(array(
890             'name'          => $_tagData['name'],
891             'description'   => $description,
892             'type'          => strtolower($type),
893             'color'         => $color,
894         ));
895         
896         if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ 
897             . ' Creating new ' . $type . ' tag: ' . $_tagData['name']);
898         
899         $tag = Tinebase_Tags::getInstance()->createTag($newTag, TRUE);
900         
901         // @todo should be moved to Tinebase_Tags / always be done for all kinds of tags on create
902         if ($type === Tinebase_Model_Tag::TYPE_SHARED) {
903             $right = new Tinebase_Model_TagRight(array(
904                 'tag_id'        => $newTag->getId(),
905                 'account_type'  => Tinebase_Acl_Rights::ACCOUNT_TYPE_ANYONE,
906                 'account_id'    => 0,
907                 'view_right'    => TRUE,
908                 'use_right'     => TRUE,
909             ));
910             Tinebase_Tags::getInstance()->setRights($right);
911             Tinebase_Tags::getInstance()->setContexts(array('any'), $newTag->getId());
912         }
913         
914         return $tag;
915     }
916     
917     /**
918     * add auto tags from options
919     *
920     * @param Tinebase_Record_Abstract $_record
921     */
922     protected function _addAutoTags($_record)
923     {
924         $autotags = $this->_sanitizeAutotagsOption();
925         
926         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
927             ' Trying to add ' . count($autotags) . ' autotag(s) to record.');
928         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ' . print_r($autotags, TRUE));
929         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ' . print_r($_record->toArray(), TRUE));
930         
931         $tags = ($_record->tags instanceof Tinebase_Record_RecordSet) ? $_record->tags : new Tinebase_Record_RecordSet('Tinebase_Model_Tag');
932         foreach ($autotags as $tagData) {
933             if (is_string($tagData)) {
934                 try {
935                     $tag = Tinebase_Tags::getInstance()->get($tagData);
936                 } catch (Tinebase_Exception_NotFound $tenf) {
937                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ' . $tenf);
938                     $tag = NULL;
939                 }
940             } else {
941                 $tagData = $this->_doAutoTagReplacements($tagData);
942                 $tag = $this->_getSingleTag($tagData['name'], $tagData);
943             }
944             if ($tag !== NULL) {
945                 $tags->addRecord($tag);
946             }
947         }
948         $_record->tags = $tags;
949         
950         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' ' . print_r($tags->toArray(), TRUE));
951     }
952     
953     /**
954      * replace some strings in autotags (name + description)
955      * 
956      * @param array $_tagData
957      * @return array
958      */
959     protected function _doAutoTagReplacements($_tagData)
960     {
961         $result = $_tagData;
962         
963         $search = array(
964             '###CURRENTDATE###', 
965             '###CURRENTTIME###', 
966             '###USERFULLNAME###'
967         );
968         $now = Tinebase_DateTime::now();
969         $replacements = array(
970             Tinebase_Translation::dateToStringInTzAndLocaleFormat($now, NULL, NULL, 'date'),
971             Tinebase_Translation::dateToStringInTzAndLocaleFormat($now, NULL, NULL, 'time'),
972             Tinebase_Core::getUser()->accountDisplayName
973         );
974         $fields = array('name', 'description');
975         
976         foreach ($fields as $field) {
977             if (isset($result[$field])) {
978                 $result[$field] = str_replace($search, $replacements, $result[$field]);
979             }
980         }
981         
982         return $result;
983     }
984     
985     /**
986      * sanitize autotag option
987      * 
988      * @return array
989      */
990     protected function _sanitizeAutotagsOption()
991     {
992         $autotags = ((isset($this->_options['autotags']['tag']) || array_key_exists('tag', $this->_options['autotags'])) && count($this->_options['autotags']) == 1) 
993             ? $this->_options['autotags']['tag'] : $this->_options['autotags'];
994
995         $autotags = ((isset($autotags['name']) || array_key_exists('name', $autotags))) ? array($autotags) : $autotags;
996         
997         if ((isset($autotags['tag']) || array_key_exists('tag', $autotags))) {
998             unset($autotags['tag']);
999         }
1000         
1001         return $autotags;
1002     }
1003     
1004     /**
1005      * import record and resolve possible conflicts
1006      * 
1007      * supports $_resolveStrategy(s): ['mergeTheirs', ('Merge, keeping existing details')],
1008      *                                ['mergeMine',   ('Merge, keeping my details')],
1009      *                                ['keep',        ('Keep both records')]
1010      * 
1011      * @param Tinebase_Record_Abstract $record
1012      * @param string $resolveStrategy
1013      * @param Tinebase_Record_Abstract $clientRecord
1014      * @return Tinebase_Record_Abstract
1015      * 
1016      * @todo we should refactor the merge handling: this function should always get the merged record OR always do the merging itself
1017      */
1018     protected function _importAndResolveConflict(Tinebase_Record_Abstract $record, $resolveStrategy = null, $clientRecord = null)
1019     {
1020         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
1021             . ' ResolveStrategy: ' . $resolveStrategy);
1022         if ($clientRecord && Tinebase_Core::isLogLevel(Zend_Log::TRACE) && $clientRecord) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
1023             . ' Client record: ' . print_r($clientRecord->toArray(), TRUE));
1024         
1025         switch ($resolveStrategy) {
1026             case 'mergeTheirs':
1027             case 'mergeMine':
1028                 if ($clientRecord) {
1029                     if ($resolveStrategy === 'mergeTheirs') {
1030                         $recordToUpdate = $this->_mergeRecord($record, $clientRecord);
1031                     } else {
1032                         $recordToUpdate = $this->_mergeRecord($clientRecord, $record);
1033                     }
1034                 } else {
1035                     $recordToUpdate = $record;
1036                 }
1037                 
1038                 if ($recordToUpdate !== null) {
1039                     if (Tinebase_Core::isLogLevel(Zend_Log::TRACE) && $clientRecord) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
1040                         . ' Merged record: ' . print_r($record->toArray(), TRUE));
1041                     
1042                     $record = call_user_func(array($this->_controller, $this->_options['updateMethod']), $recordToUpdate, FALSE);
1043                 }
1044                 
1045                 break;
1046             case 'keep':
1047                 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
1048                     . ' Record to import (keep both / no duplicate check): ' . print_r($record->toArray(), TRUE));
1049                 
1050                 $record = call_user_func(array($this->_controller, $this->_options['createMethod']), $record, FALSE);
1051                 break;
1052             default:
1053                 if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
1054                     . ' Record to import: ' . print_r($record->toArray(), TRUE));
1055                 
1056                 $record = call_user_func(array($this->_controller, $this->_options['createMethod']), $record);
1057         }
1058         
1059         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
1060             . ' record: ' . print_r($record->toArray(), TRUE));
1061         
1062         return $record;
1063     }
1064     
1065     /**
1066      * merge record / skip if no diff
1067      * 
1068      * @param Tinebase_Record_Abstract $updateRecord
1069      * @param Tinebase_Record_Abstract $mergeRecord
1070      * @return Tinebase_Record_Abstract
1071      */
1072     protected function _mergeRecord($updateRecord, $mergeRecord)
1073     {
1074         $omitFields = array(
1075             'creation_time',
1076             'created_by',
1077             'last_modified_time',
1078             'last_modified_by',
1079             'seq',
1080             'id'
1081         );
1082         
1083         $diff = $updateRecord->diff($mergeRecord, $omitFields);
1084         if (! $diff || $diff->isEmpty()) {
1085             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
1086                 . ' Records are identical, no need to update');
1087             return null;
1088         } else {
1089             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
1090                 . ' Got diff: ' . print_r($diff->diff, TRUE));
1091         }
1092         
1093         return $updateRecord->merge($mergeRecord, $diff);
1094     }
1095     
1096     /**
1097      * handle import exceptions
1098      * 
1099      * @param Exception $e
1100      * @param integer $recordIndex
1101      * @param Tinebase_Record_Abstract|array $record
1102      * @param boolean $allowToResolveDuplicates
1103      * 
1104      * @todo use json converter for client record
1105      */
1106     protected function _handleImportException(Exception $e, $recordIndex, $record = null, $allowToResolveDuplicates = true)
1107     {
1108         Tinebase_Exception::log($e);
1109         
1110         if ($e instanceof Tinebase_Exception_Duplicate) {
1111             $exception = $this->_handleDuplicateExceptions($e, $recordIndex, $record, $allowToResolveDuplicates);
1112         } else {
1113             $this->_importResult['failcount']++;
1114             $exception = array(
1115                 'code'         => $e->getCode(),
1116                 'message'      => $e->getMessage(),
1117                 'clientRecord' => ($record !== NULL && $record instanceof Tinebase_Record_Abstract) ? $record->toArray() 
1118                     : (is_array($record) ? $record : array()),
1119             );
1120         }
1121         
1122         if ($exception) {
1123             $this->_importResult['exceptions']->addRecord(new Tinebase_Model_ImportException(array(
1124                 'code'          => $e->getCode(),
1125                 'message'       => $e->getMessage(),
1126                 'exception'     => $exception,
1127                 'index'         => $recordIndex,
1128             )));
1129         }
1130     }
1131     
1132     /**
1133      * handle duplicate exceptions
1134      * 
1135      * @param Tinebase_Exception_Duplicate $ted
1136      * @param integer $recordIndex
1137      * @param Tinebase_Record_Abstract|array $record
1138      * @param boolean $allowToResolveDuplicates
1139      * @return array|null exception
1140      */
1141     protected function _handleDuplicateExceptions(Tinebase_Exception_Duplicate $ted, $recordIndex, $record = null, $allowToResolveDuplicates = true)
1142     {
1143         $firstDuplicateRecord = $ted->getData()->getFirstRecord();
1144         $resolveStrategy = isset($this->_options['duplicateResolveStrategy']) ? $this->_options['duplicateResolveStrategy'] : null;
1145
1146         // switch to keep strategy for records of current import run
1147         if (in_array($firstDuplicateRecord->getId(), $this->_importResult['results']->getArrayOfIds())) {
1148             $allowToResolveDuplicates = true;
1149             $resolveStrategy = 'keep';
1150         }
1151
1152         if ($resolveStrategy && $allowToResolveDuplicates) {
1153             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
1154                 . ' Trying to resolve with configured strategy: ' . $resolveStrategy);
1155             
1156             try {
1157                 if ($resolveStrategy === 'keep') {
1158                     $updatedRecord = $this->_importAndResolveConflict($ted->getClientRecord(), $resolveStrategy);
1159                     $this->_importResult['totalcount']++;
1160                 } else {
1161                     $updatedRecord = $this->_importAndResolveConflict($firstDuplicateRecord, $resolveStrategy, $ted->getClientRecord());
1162                     $this->_importResult['updatecount']++;
1163                 }
1164                 $this->_importResult['results']->addRecord($updatedRecord);
1165             } catch (Exception $newException) {
1166                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
1167                     . " Resolving failed. Don't try to resolve duplicates this time");
1168                 
1169                 $this->_handleImportException($newException, $recordIndex, $record, false);
1170             }
1171             $result = null;
1172         } else {
1173             $this->_importResult['duplicatecount']++;
1174             $result = $ted->toArray();
1175         }
1176         
1177         return $result;
1178     }
1179     
1180     /**
1181      * log import result
1182      */
1183     protected function _logImportResult()
1184     {
1185         if (Tinebase_Core::isLogLevel(Zend_Log::INFO)) Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ 
1186             . ' Import finished. (total: ' . $this->_importResult['totalcount'] 
1187             . ' fail: ' . $this->_importResult['failcount'] 
1188             . ' duplicates: ' . $this->_importResult['duplicatecount'] 
1189             . ' updates: ' . $this->_importResult['updatecount'] 
1190             . ')');
1191     }
1192     
1193     /**
1194      * returns config from definition
1195      * 
1196      * @param Tinebase_Model_ImportExportDefinition $_definition
1197      * @param array                                 $_options
1198      * @return array
1199      */
1200     public static function getOptionsArrayFromDefinition($_definition, $_options)
1201     {
1202         $options = Tinebase_ImportExportDefinition::getOptionsAsZendConfigXml($_definition, $_options);
1203         $optionsArray = $options->toArray();
1204         if (! isset($optionsArray['model'])) {
1205             $optionsArray['model'] = $_definition->model;
1206         }
1207         
1208         return $optionsArray;
1209     }
1210     
1211     /**
1212      * set controller
1213      */
1214     protected function _setController()
1215     {
1216         $this->_controller = Tinebase_Core::getApplicationInstance($this->_options['model']);
1217     }
1218 }