0011996: add fallback app icon
[tine20] / tine20 / Tinebase / Tags.php
1 <?php
2 /**
3  * Tine 2.0
4  *
5  * @package     Tinebase
6  * @subpackage  Tags
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @copyright   Copyright (c) 2008-2014 Metaways Infosystems GmbH (http://www.metaways.de)
9  * @author      Cornelius Weiss <c.weiss@metaways.de>
10  *
11  * @todo        this should implement Tinebase_Backend_Sql_Interface or use standard sql backend + refactor this
12  */
13
14 /**
15  * Class for handling tags and tagging.
16  *
17  * NOTE: Functions in the 'tagging' chain check acl of the actions,
18  *       tag housekeeper functions do their acl in the admin controller
19  *       
20  * @package     Tinebase
21  * @subpackage  Tags
22  */
23 class Tinebase_Tags
24 {
25     /**
26      * @var Zend_Db_Adapter_Pdo_Mysql
27      */
28     protected $_db;
29     
30     /**
31      * @var Tinebase_Backend_Sql_Command_Interface
32      */
33     protected $_dbCommand;
34     
35     /**
36      * don't clone. Use the singleton.
37      */
38     private function __clone()
39     {
40
41     }
42
43     /**
44      * holds the instance of the singleton
45      *
46      * @var Tinebase_Tags
47      */
48     private static $_instance = NULL;
49
50     /**
51      * the singleton pattern
52      *
53      * @return Tinebase_Tags
54      */
55     public static function getInstance()
56     {
57         if (self::$_instance === NULL) {
58             self::$_instance = new Tinebase_Tags;
59         }
60
61         return self::$_instance;
62     }
63
64     /**
65      * the constructor
66      *
67      */
68     private function __construct()
69     {
70         $this->_db        = Tinebase_Core::getDb();
71         $this->_dbCommand = Tinebase_Backend_Sql_Command::factory($this->_db);
72     }
73
74     /**
75      * Searches tags according to filter and paging
76      * The Current user needs to have the given right, unless $_ignoreAcl is true
77      * 
78      * @param  Tinebase_Model_TagFilter $_filter
79      * @param  Tinebase_Model_Pagination  $_paging
80      * @return Tinebase_Record_RecordSet  Set of Tinebase_Model_Tag
81      */
82     public function searchTags($_filter, $_paging = NULL)
83     {
84         $select = $_filter->getSelect();
85         
86         Tinebase_Model_TagRight::applyAclSql($select, $_filter->grant);
87         
88         if (isset($_filter->application)) {
89             $app = Tinebase_Application::getInstance()->getApplicationByName($_filter->application);
90             $this->_filterSharedOnly($select, $app->getId());
91         }
92         
93         if ($_paging !== NULL) {
94             $_paging->appendPaginationSql($select);
95         }
96         
97         Tinebase_Backend_Sql_Abstract::traitGroup($select);
98         
99         $tags = new Tinebase_Record_RecordSet('Tinebase_Model_Tag', $this->_db->fetchAssoc($select));
100         
101         return $tags;
102     }
103
104     /**
105     * Searches tags according to foreign filter
106     * -> returns the count of tag occurrences in the result set
107     *
108     * @param  Tinebase_Model_Filter_FilterGroup $_filter
109     * @return Tinebase_Record_RecordSet  Set of Tinebase_Model_Tag
110     */
111     public function searchTagsByForeignFilter($_filter)
112     {
113         $controller = Tinebase_Core::getApplicationInstance($_filter->getApplicationName(), $_filter->getModelName());
114         $recordIds = $controller->search($_filter, NULL, FALSE, TRUE);
115         
116         if (! empty($recordIds)) {
117             $app = Tinebase_Application::getInstance()->getApplicationByName($_filter->getApplicationName());
118             
119             $select = $this->_getSelect($recordIds, $app->getId());
120             Tinebase_Model_TagRight::applyAclSql($select);
121             
122             Tinebase_Backend_Sql_Abstract::traitGroup($select);
123             
124             $tags = $this->_db->fetchAll($select);
125             $tagData = $this->_getDistinctTagsAndComputeOccurrence($tags);
126         } else {
127             $tagData = array();
128         }
129         
130         return new Tinebase_Record_RecordSet('Tinebase_Model_Tag', $tagData);
131     }
132     
133     /**
134      * get distinct tags from result array and compute occurrence of tag in selection
135      * 
136      * @param array $_tags
137      * @return array
138      */
139     protected function _getDistinctTagsAndComputeOccurrence(array $_tags)
140     {
141         $tagData = array();
142         
143         foreach ($_tags as $tag) {
144             if ((isset($tagData[$tag['id']]) || array_key_exists($tag['id'], $tagData))) {
145                 $tagData[$tag['id']]['selection_occurrence']++;
146             } else {
147                 $tag['selection_occurrence'] = 1;
148                 $tagData[$tag['id']] = $tag;
149             }
150         }
151         
152         return $tagData;
153     }
154     
155     /**
156      * Returns tags count of a tag search
157      * @todo automate the count query if paging is active!
158      *
159      * @param  Tinebase_Model_TagFilter $_filter
160      * @return int
161      */
162     public function getSearchTagsCount($_filter)
163     {
164         $tags = $this->searchTags($_filter);
165         return count($tags);
166     }
167
168     /**
169      * Return a single record
170      *
171      * @param string|Tinebase_Model_Tag $_id
172      * @param $_getDeleted boolean get deleted records
173      * @return Tinebase_Model_FullTag
174      *
175      * @todo support $_getDeleted
176      */
177     public function get($_id, $_getDeleted = FALSE)
178     {
179         $fullTag = $this->getFullTagById($_id);
180         return $fullTag;
181     }
182     
183     /**
184      * get full tag by id
185      * 
186      * @param string|Tinebase_Model_Tag $id
187      * @param string $ignoreAcl
188      * @throws Tinebase_Exception_NotFound
189      * @return Tinebase_Model_FullTag
190      */
191     public function getFullTagById($id, $ignoreAcl = false)
192     {
193         $tagId = ($id instanceof Tinebase_Model_Tag) ? $id->getId() : $id;
194         
195         $tags = $this->getTagsById($tagId, Tinebase_Model_TagRight::VIEW_RIGHT, $ignoreAcl);
196         
197         if (count($tags) == 0) {
198             throw new Tinebase_Exception_NotFound("Tag $id not found or insufficient rights.");
199         }
200         
201         return new Tinebase_Model_FullTag($tags[0]->toArray(), true);
202     }
203     
204     /**
205      * Returns (bare) tags identified by its id(s)
206      *
207      * @param   string|array|Tinebase_Record_RecordSet  $_id
208      * @param   string                                  $_right the required right current user must have on the tags
209      * @param   bool                                    $_ignoreAcl
210      * @return  Tinebase_Record_RecordSet               Set of Tinebase_Model_Tag
211      * @throws  Tinebase_Exception_InvalidArgument
212      *
213      * @todo    check context
214      */
215     public function getTagsById($_id, $_right = Tinebase_Model_TagRight::VIEW_RIGHT, $_ignoreAcl = false)
216     {
217         $tags = new Tinebase_Record_RecordSet('Tinebase_Model_Tag');
218         
219         if (is_string($_id)) {
220             $ids = array($_id);
221         } else if ($_id instanceof Tinebase_Record_RecordSet) {
222             $ids = $_id->getArrayOfIds();
223         } else if (is_array($_id)) {
224             $ids = $_id;
225         } else {
226             throw new Tinebase_Exception_InvalidArgument('Expected string|array|Tinebase_Record_RecordSet of tags');
227         }
228         
229         if (! empty($ids)) {
230             $select = $this->_db->select()
231                 ->from(array('tags' => SQL_TABLE_PREFIX . 'tags'))
232                 ->where($this->_db->quoteIdentifier('is_deleted') . ' = 0')
233                 ->where($this->_db->quoteInto($this->_db->quoteIdentifier('id') . ' IN (?)', $ids));
234             if ($_ignoreAcl !== true) {
235                 Tinebase_Model_TagRight::applyAclSql($select, $_right);
236             }
237
238             Tinebase_Backend_Sql_Abstract::traitGroup($select);
239             
240             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' ' . $select->__toString());
241
242             foreach ($this->_db->fetchAssoc($select) as $tagArray){
243                 $tags->addRecord(new Tinebase_Model_Tag($tagArray, true));
244             }
245             if (count($tags) !== count($ids)) {
246                 $missingIds = array_diff($ids, $tags->getArrayOfIds());
247                 Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' Tag(s) not found or insufficient rights: ' . print_r($missingIds, true));
248             }
249         }
250         return $tags;
251     }
252
253     /**
254      * Returns tags identified by its names
255      *
256      * @param   string  $_name name of the tag to search for
257      * @param   string  $_right the required right current user must have on the tags
258      * @param   string  $_application the required right current user must have on the tags
259      * @param   bool    $_ignoreAcl
260      * @return  Tinebase_Model_Tag
261      * @throws  Tinebase_Exception_NotFound
262      *
263      * @todo    check context
264      */
265     public function getTagByName($_name, $_right = Tinebase_Model_TagRight::VIEW_RIGHT, $_application = NULL, $_ignoreAcl = false)
266     {
267         $select = $this->_db->select()
268             ->from(array('tags' => SQL_TABLE_PREFIX . 'tags'))
269             ->where($this->_db->quoteIdentifier('is_deleted') . ' = 0')
270             ->where($this->_db->quoteInto($this->_db->quoteIdentifier('name') . ' = (?)', $_name));
271         
272         if ($_ignoreAcl !== true) {
273             Tinebase_Model_TagRight::applyAclSql($select, $_right);
274         }
275
276         Tinebase_Backend_Sql_Abstract::traitGroup($select);
277         
278         $stmt = $this->_db->query($select);
279         $queryResult = $stmt->fetch();
280         $stmt->closeCursor();
281
282         if (!$queryResult) {
283             throw new Tinebase_Exception_NotFound("Tag with name $_name not found!");
284         }
285
286         $result = new Tinebase_Model_Tag($queryResult);
287
288         return $result;
289     }
290
291     /**
292      * Creates a single tag
293      *
294      * @param   Tinebase_Model_Tag
295      * @param   boolean $_ignoreACL
296      * @return  Tinebase_Model_Tag
297      * @throws  Tinebase_Exception_AccessDenied
298      * @throws  Tinebase_Exception_UnexpectedValue
299      */
300     public function createTag(Tinebase_Model_Tag $_tag, $_ignoreACL = FALSE)
301     {
302         if ($_tag instanceof Tinebase_Model_FullTag) {
303             $_tag = new Tinebase_Model_Tag($_tag->toArray(), TRUE);
304         }
305
306         $currentAccountId = Tinebase_Core::getUser()->getId();
307
308         $newId = $_tag->generateUID();
309         $_tag->setId($newId);
310         $_tag->occurrence = 0;
311         $_tag->created_by = Tinebase_Core::getUser()->getId();
312         $_tag->creation_time = Tinebase_DateTime::now()->get(Tinebase_Record_Abstract::ISO8601LONG);
313
314         switch ($_tag->type) {
315             case Tinebase_Model_Tag::TYPE_PERSONAL:
316                 $_tag->owner = $currentAccountId;
317                 $this->_db->insert(SQL_TABLE_PREFIX . 'tags', $_tag->toArray());
318                 // for personal tags we set rights and scope temporary here,
319                 // this needs to be moved into Tinebase Controller later
320                 $right = new Tinebase_Model_TagRight(array(
321                     'tag_id'        => $newId,
322                     'account_type'  => Tinebase_Acl_Rights::ACCOUNT_TYPE_USER,
323                     'account_id'    => $currentAccountId,
324                     'view_right'    => true,
325                     'use_right'     => true,
326                 ));
327                 $this->setRights($right);
328                 $this->_db->insert(SQL_TABLE_PREFIX . 'tags_context', array(
329                     'tag_id'         => $newId,
330                     'application_id' => 0
331                 ));
332                 break;
333             case Tinebase_Model_Tag::TYPE_SHARED:
334                 if (! $_ignoreACL && ! Tinebase_Core::getUser()->hasRight('Admin', Admin_Acl_Rights::MANAGE_SHARED_TAGS) ) {
335                     throw new Tinebase_Exception_AccessDenied('Your are not allowed to create this tag');
336                 }
337                 $_tag->owner = 0;
338                 $this->_db->insert(SQL_TABLE_PREFIX . 'tags', $_tag->toArray());
339                 break;
340             default:
341                 throw new Tinebase_Exception_UnexpectedValue('No such tag type.');
342                 break;
343         }
344         
345         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
346             . ' Created new tag ' . $_tag->name);
347
348         // any context temporary
349
350         $tags = $this->getTagsById($newId, NULL, true);
351         return $tags[0];
352     }
353
354     /**
355      * Creates new entry
356      *
357      * @param   Tinebase_Record_Interface $_record
358      * @return  Tinebase_Record_Interface
359      */
360     public function create(Tinebase_Record_Interface $_record)
361     {
362         return $this->createTag($_record);
363     }
364
365     /**
366      * updates a single tag
367      *
368      * @param   Tinebase_Model_Tag
369      * @return  Tinebase_Model_Tag
370      * @throws  Tinebase_Exception_AccessDenied
371      */
372     public function updateTag(Tinebase_Model_Tag $_tag)
373     {
374         if ($_tag instanceof Tinebase_Model_FullTag) {
375             $_tag = new Tinebase_Model_Tag($_tag->toArray(), TRUE);
376         }
377
378         $currentAccountId = Tinebase_Core::getUser()->getId();
379         $manageSharedTagsRight = Tinebase_Acl_Roles::getInstance()
380         ->hasRight('Admin', $currentAccountId, Admin_Acl_Rights::MANAGE_SHARED_TAGS);
381
382         if ( ($_tag->type == Tinebase_Model_Tag::TYPE_PERSONAL && $_tag->owner == $currentAccountId) ||
383         ($_tag->type == Tinebase_Model_Tag::TYPE_SHARED && $manageSharedTagsRight) ) {
384
385             $tagId = $_tag->getId();
386             if (strlen($tagId) != 40) {
387                 throw new Tinebase_Exception_AccessDenied('Could not update non-existing tag.');
388             }
389
390             $this->_db->update(SQL_TABLE_PREFIX . 'tags', array(
391                 'type'               => $_tag->type,
392                 'owner'              => $_tag->owner,
393                 'name'               => $_tag->name,
394                 'description'        => $_tag->description,
395                 'color'              => $_tag->color,
396                 'last_modified_by'   => $currentAccountId,
397                 'last_modified_time' => Tinebase_DateTime::now()->get(Tinebase_Record_Abstract::ISO8601LONG)
398             ), $this->_db->quoteInto($this->_db->quoteIdentifier('id').'= ?', $tagId));
399
400             $tags = $this->getTagsById($tagId);
401             return $tags[0];
402         } else {
403             throw new Tinebase_Exception_AccessDenied('Your are not allowed to update this tag.');
404         }
405     }
406
407     /**
408      * Updates existing entry
409      *
410      * @param Tinebase_Record_Interface $_record
411      * @throws Tinebase_Exception_Record_Validation|Tinebase_Exception_InvalidArgument
412      * @return Tinebase_Record_Interface Record|NULL
413      */
414     public function update(Tinebase_Record_Interface $_record)
415     {
416         return $this->updateTag($_record);
417     }
418
419     /**
420      * Deletes (set state "deleted") tags identified by their ids
421      *
422      * @param  string|array $ids to delete
423      * @param  boolean $ignoreAcl
424      * @throws  Tinebase_Exception_AccessDenied
425      */
426     public function deleteTags($ids, $ignoreAcl = FALSE)
427     {
428         $tags = $this->getTagsById($ids, Tinebase_Model_TagRight::VIEW_RIGHT, $ignoreAcl);
429         if (count($tags) != count((array)$ids)) {
430             throw new Tinebase_Exception_AccessDenied('You are not allowed to delete the tag(s).');
431         }
432
433         $currentAccountId = (is_object(Tinebase_Core::getUser())) ? Tinebase_Core::getUser()->getId() : 'setupuser';
434         
435         if (! $ignoreAcl) {
436             $manageSharedTagsRight = Tinebase_Acl_Roles::getInstance()->hasRight('Admin', $currentAccountId, Admin_Acl_Rights::MANAGE_SHARED_TAGS);
437             foreach ($tags as $tag) {
438                 if ( ($tag->type == Tinebase_Model_Tag::TYPE_PERSONAL && $tag->owner == $currentAccountId) ||
439                 ($tag->type == Tinebase_Model_Tag::TYPE_SHARED && $manageSharedTagsRight) ) {
440                     continue;
441                 } else {
442                     throw new Tinebase_Exception_AccessDenied('You are not allowed to delete this tags');
443                 }
444             }
445         }
446         
447         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
448             . ' Deleting ' . count($tags) . ' tags.');
449         
450         if (count($tags) > 0) {
451             $this->_db->update(SQL_TABLE_PREFIX . 'tags', array(
452                 'is_deleted'   => true,
453                 'deleted_by'   => $currentAccountId,
454                 'deleted_time' => Tinebase_DateTime::now()->get(Tinebase_Record_Abstract::ISO8601LONG)
455             ), $this->_db->quoteInto($this->_db->quoteIdentifier('id').' IN (?)', $tags->getArrayOfIds()));
456         }
457     }
458
459     /**
460      * Gets tags of a given record where user has the required right to
461      * The tags are stored in the records $_tagsProperty.
462      *
463      * @param Tinebase_Record_Abstract  $_record        the record object
464      * @param string                    $_tagsProperty  the property in the record where the tags are in (defaults: 'tags')
465      * @param string                    $_right         the required right current user must have on the tags
466      * @return Tinebase_Record_RecordSet tags of record
467      */
468     public function getTagsOfRecord($_record, $_tagsProperty='tags', $_right=Tinebase_Model_TagRight::VIEW_RIGHT)
469     {
470         $recordId = $_record->getId();
471         $tags = new Tinebase_Record_RecordSet('Tinebase_Model_Tag');
472         if (!empty($recordId)) {
473             $select = $this->_getSelect($recordId, Tinebase_Application::getInstance()->getApplicationByName($_record->getApplication())->getId());
474             Tinebase_Model_TagRight::applyAclSql($select, $_right, $this->_db->quoteIdentifier('tagging.tag_id'));
475             
476             Tinebase_Backend_Sql_Abstract::traitGroup($select);
477             
478             foreach ($this->_db->fetchAssoc($select) as $tagArray){
479                 $tags->addRecord(new Tinebase_Model_Tag($tagArray, true));
480             }
481         }
482
483         $_record[$_tagsProperty] = $tags;
484         return $tags;
485     }
486
487     /**
488      * Gets tags of a given records where user has the required right to
489      * The tags are stored in the records $_tagsProperty.
490      *
491      * @param Tinebase_Record_RecordSet  $_records       the recordSet
492      * @param string                     $_tagsProperty  the property in the record where the tags are in (defaults: 'tags')
493      * @param string                     $_right         the required right current user must have on the tags
494      * @return Tinebase_Record_RecordSet tags of record
495      */
496     public function getMultipleTagsOfRecords($_records, $_tagsProperty='tags', $_right=Tinebase_Model_TagRight::VIEW_RIGHT)
497     {
498         if (count($_records) == 0) {
499             // do nothing
500             return;
501         }
502
503         $recordIds = $_records->getArrayOfIds();
504         if (count($recordIds) == 0) {
505             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
506                 . ' Can\'t get tags for records without ids');
507             // do nothing
508             return;
509         }
510
511         // get first record to determine application
512         $first = $_records->getFirstRecord();
513         $appId = Tinebase_Application::getInstance()->getApplicationByName($first->getApplication())->getId();
514
515         $select = $this->_getSelect($recordIds, $appId);
516         $select->group(array('tagging.tag_id', 'tagging.record_id'));
517         Tinebase_Model_TagRight::applyAclSql($select, $_right, $this->_db->quoteIdentifier('tagging.tag_id'));
518
519         Tinebase_Backend_Sql_Abstract::traitGroup($select);
520
521         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
522             . ' ' . $select);
523
524         $queryResult = $this->_db->fetchAll($select);
525
526         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
527             . ' ' . print_r($queryResult, TRUE));
528
529         // build array with tags (record_id => array of Tinebase_Model_Tag)
530         $tagsOfRecords = array();
531         foreach ($queryResult as $result) {
532             $tagsOfRecords[$result['record_id']][] = new Tinebase_Model_Tag($result, true);
533         }
534
535         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
536             . ' Getting ' . count($tagsOfRecords) . ' tags for ' . count($_records) . ' records.');
537
538         foreach ($_records as $record) {
539             $record->{$_tagsProperty} = new Tinebase_Record_RecordSet(
540                 'Tinebase_Model_Tag', 
541                 (isset($tagsOfRecords[$record->getId()])) ? $tagsOfRecords[$record->getId()] : array()
542             );
543         }
544     }
545
546     /**
547      * sets (attaches and detaches) tags of a record
548      * NOTE: Only touches tags the user has use right for
549      * NOTE: Non existing personal tags will be created on the fly
550      *
551      * @param Tinebase_Record_Abstract  $_record        the record object
552      * @param string                    $_tagsProperty  the property in the record where the tags are in (defaults: 'tags')
553      */
554     public function setTagsOfRecord($_record, $_tagsProperty = 'tags')
555     {
556         $tagsToSet = $this->_createTagsOnTheFly($_record[$_tagsProperty]);
557         $currentTags = $this->getTagsOfRecord($_record, 'tags', Tinebase_Model_TagRight::USE_RIGHT);
558         
559         $appId = Tinebase_Application::getInstance()->getApplicationByName($_record->getApplication())->getId();
560         if (! $this->_userHasPersonalTagRight($appId)) {
561             $tagsToSet = $tagsToSet->filter('type', Tinebase_Model_Tag::TYPE_SHARED);
562             $currentTags = $currentTags->filter('type', Tinebase_Model_Tag::TYPE_SHARED);
563         }
564
565         $tagIdsToSet = $tagsToSet->getArrayOfIds();
566         $currentTagIds = $currentTags->getArrayOfIds();
567
568         $toAttach = array_diff($tagIdsToSet, $currentTagIds);
569         $toDetach = array_diff($currentTagIds, $tagIdsToSet);
570
571         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
572             . ' Attaching tags: ' . print_r($toAttach, true));
573         
574         $recordId = $_record->getId();
575         foreach ($toAttach as $tagId) {
576             $this->_db->insert(SQL_TABLE_PREFIX . 'tagging', array(
577                 'tag_id'         => $tagId,
578                 'application_id' => $appId,
579                 'record_id'      => $recordId,
580             // backend property not supported by record yet
581                 'record_backend_id' => ' '
582             ));
583             $this->_addOccurrence($tagId, 1);
584         }
585         foreach ($toDetach as $tagId) {
586             $this->_db->delete(SQL_TABLE_PREFIX . 'tagging', array(
587                 $this->_db->quoteInto($this->_db->quoteIdentifier('tag_id'). ' = ?',         $tagId), 
588                 $this->_db->quoteInto($this->_db->quoteIdentifier('application_id'). ' = ?', $appId), 
589                 $this->_db->quoteInto($this->_db->quoteIdentifier('record_id'). ' = ?',      $recordId), 
590             ));
591             $this->_deleteOccurrence($tagId, 1);
592         }
593     }
594
595     /**
596      * attach tag to multiple records identified by a filter
597      *
598      * @param Tinebase_Model_Filter_FilterGroup $_filter
599      * @param mixed                             $_tag       string|array|Tinebase_Model_Tag with existing and non-existing tag
600      * @return Tinebase_Model_Tag|null
601      * @throws Tinebase_Exception_AccessDenied
602      * @throws Exception
603      * 
604      * @todo maybe this could be done in a more generic way (in Tinebase_Controller_Record_Abstract)
605      */
606     public function attachTagToMultipleRecords($_filter, $_tag)
607     {
608         // check/create tag on the fly
609         $tags = $this->_createTagsOnTheFly(array($_tag));
610         if (empty($tags) || count($tags) == 0) {
611             Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' No tags created.');
612             return null;
613         }
614         $tag = $tags->getFirstRecord();
615         $tagId = $tag->getId();
616
617         list($appName, $i, $modelName) = explode('_', $_filter->getModelName());
618         $appId = Tinebase_Application::getInstance()->getApplicationByName($appName)->getId();
619         $controller = Tinebase_Core::getApplicationInstance($appName, $modelName);
620
621         // only get records user has update rights to
622         $controller->checkFilterACL($_filter, 'update');
623         $recordIds = $controller->search($_filter, NULL, FALSE, TRUE);
624
625         if (empty($recordIds)) {
626             Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' There are no records we could attach the tag to');
627             return null;
628         }
629         
630         if ($tag->type === Tinebase_Model_Tag::TYPE_PERSONAL && ! $this->_userHasPersonalTagRight($appId)) {
631             throw new Tinebase_Exception_AccessDenied('You are not allowed to attach personal tags');
632         }
633         
634         // fetch ids of records already having the tag
635         $alreadyAttachedIds = array();
636         $select = $this->_db->select()
637             ->from(array('tagging' => SQL_TABLE_PREFIX . 'tagging'), 'record_id')
638             ->where($this->_db->quoteIdentifier('application_id') . ' = ?', $appId)
639             ->where($this->_db->quoteIdentifier('tag_id') . ' = ? ', $tagId);
640
641         Tinebase_Backend_Sql_Abstract::traitGroup($select);
642         
643         foreach ($this->_db->fetchAssoc($select) as $tagArray) {
644             $alreadyAttachedIds[] = $tagArray['record_id'];
645         }
646
647         $toAttachIds = array_diff($recordIds, $alreadyAttachedIds);
648         
649         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Attaching 1 Tag to ' . count($toAttachIds) . ' records.');
650         
651         try {
652             $transactionId = Tinebase_TransactionManager::getInstance()->startTransaction($this->_db);
653             
654             foreach ($toAttachIds as $recordId) {
655                 $this->_db->insert(SQL_TABLE_PREFIX . 'tagging', array(
656                     'tag_id'         => $tagId,
657                     'application_id' => $appId,
658                     'record_id'      => $recordId,
659                 // backend property not supported by record yet
660                     'record_backend_id' => ''
661                     )
662                 );
663             }
664             
665             $controller->concurrencyManagementAndModlogMultiple(
666                 $toAttachIds, 
667                 array('tags' => array()), 
668                 array('tags' => array($tag->toArray()))
669             );
670             
671             $this->_addOccurrence($tagId, count($toAttachIds));
672             
673             Tinebase_TransactionManager::getInstance()->commitTransaction($transactionId);
674         } catch (Exception $e) {
675             Tinebase_TransactionManager::getInstance()->rollBack();
676             Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' ' . print_r($e->getMessage(), true));
677             throw $e;
678         }
679         
680         return $this->get($tagId);
681     }
682
683     /**
684      * detach tag from multiple records identified by a filter
685      *
686      * @param Tinebase_Model_Filter_FilterGroup $_filter
687      * @param mixed                             $_tag       string|array|Tinebase_Model_Tag with existing and non-existing tag
688      * @return void
689      * 
690      * @todo maybe this could be done in a more generic way (in Tinebase_Controller_Record_Abstract)
691      */
692     public function detachTagsFromMultipleRecords($_filter, $_tag)
693     {
694         list($appName, $i, $modelName) = explode('_', $_filter->getModelName());
695         $appId = Tinebase_Application::getInstance()->getApplicationByName($appName)->getId();
696         $controller = Tinebase_Core::getApplicationInstance($appName, $modelName);
697         
698         // only get records user has update rights to
699         $controller->checkFilterACL($_filter, 'update');
700         $recordIds = $controller->search($_filter, NULL, FALSE, TRUE);
701         
702         foreach ((array) $_tag as $dirtyTagId) {
703             try {
704                 $transactionId = Tinebase_TransactionManager::getInstance()->startTransaction($this->_db);
705                 $this->_detachSingleTag($recordIds, $dirtyTagId, $appId, $controller);
706                 Tinebase_TransactionManager::getInstance()->commitTransaction($transactionId);
707             } catch (Exception $e) {
708                 Tinebase_TransactionManager::getInstance()->rollBack();
709                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' ' . print_r($e->getMessage(), true));
710                 throw $e;
711             }
712         }
713     }
714     
715     /**
716      * detach a single tag from records
717      * 
718      * @param array $recordIds
719      * @param string $dirtyTagId
720      * @param string $appId
721      * @param Tinebase_Controller_Record_Abstract $controller
722      */
723     protected function _detachSingleTag($recordIds, $dirtyTagId, $appId, $controller)
724     {
725         $tag = $this->getTagsById($dirtyTagId, Tinebase_Model_TagRight::USE_RIGHT)->getFirstRecord();
726         
727         if (empty($tag)) {
728             Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' No use right for tag, detaching not possible.');
729             return;
730         }
731         $tagId = $tag->getId();
732         
733         $attachedIds = array();
734         $select = $this->_db->select()
735             ->from(array('tagging' => SQL_TABLE_PREFIX . 'tagging'), 'record_id')
736             ->where($this->_db->quoteIdentifier('application_id') . ' = ?', $appId)
737             ->where($this->_db->quoteIdentifier('tag_id') . ' = ? ', $tagId)
738             ->where($this->_db->quoteInto($this->_db->quoteIdentifier('record_id').' IN (?)', $recordIds));
739
740         Tinebase_Backend_Sql_Abstract::traitGroup($select);
741         
742         foreach ($this->_db->fetchAssoc($select) as $tagArray){
743             $attachedIds[] = $tagArray['record_id'];
744         }
745         
746         if (empty($attachedIds)) {
747             Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ . ' There are no records we could detach the tag(s) from');
748             return;
749         }
750         
751         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Detaching 1 Tag from ' . count($attachedIds) . ' records.');
752         foreach ($attachedIds as $recordId) {
753             $this->_db->delete(SQL_TABLE_PREFIX . 'tagging', array(
754                 $this->_db->quoteIdentifier('tag_id') . ' = ?'         => $tagId,
755                 $this->_db->quoteIdentifier('record_id') . ' = ?'      => $recordId,
756                 $this->_db->quoteIdentifier('application_id') . ' = ?' => $appId
757             ));
758         }
759         
760         $controller->concurrencyManagementAndModlogMultiple(
761             $attachedIds,
762             array('tags' => array($tag->toArray())),
763             array('tags' => array())
764         );
765         
766         $this->_deleteOccurrence($tagId, count($attachedIds));
767     }
768     
769     /**
770      * Creates missing tags on the fly and returns complete list of tags the current
771      * user has use rights for.
772      * Always respects the current acl of the current user!
773      *
774      * @param   array|Tinebase_Record_RecordSet set of string|array|Tinebase_Model_Tag with existing and non-existing tags
775      * @return  Tinebase_Record_RecordSet       set of all tags
776      * @throws  Tinebase_Exception_UnexpectedValue
777      */
778     protected function _createTagsOnTheFly($_mixedTags)
779     {
780         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
781             . ' Creating tags on the fly: ' . print_r(($_mixedTags instanceof Tinebase_Record_RecordSet ? $_mixedTags->toArray() : $_mixedTags), TRUE));
782         
783         $tagIds = array();
784         foreach ($_mixedTags as $tag) {
785             if (is_string($tag)) {
786                 $tagIds[] = $tag;
787                 continue;
788             } else {
789                 if (is_array($tag)) {
790                     if (! isset($tag['name']) || empty($tag['name'])) {
791                         if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__ 
792                             . ' Do not create tag without a name.');
793                         continue;
794                     }
795                     $tag = new Tinebase_Model_Tag($tag);
796                 } elseif (! $tag instanceof Tinebase_Model_Tag) {
797                     throw new Tinebase_Exception_UnexpectedValue('Tag could not be identified.');
798                 }
799                 if (!$tag->getId()) {
800                     $tag->type = Tinebase_Model_Tag::TYPE_PERSONAL;
801                     $tag = $this->createTag($tag);
802                 }
803                 $tagIds[] = $tag->getId();
804             }
805         }
806         return $this->getTagsById($tagIds, Tinebase_Model_TagRight::USE_RIGHT);
807     }
808
809     /**
810      * adds given number to the persistent occurrence property of a given tag
811      *
812      * @param  Tinebase_Model_Tag|string $_tag
813      * @param  int                             $_toAdd
814      * @return void
815      */
816     protected function _addOccurrence($_tag, $_toAdd)
817     {
818         $this->_updateOccurrence($_tag, $_toAdd);
819     }
820     
821     /**
822      * update tag occurrrence
823      * 
824      * @param Tinebase_Model_Tag|string $tag
825      * @param integer $toAddOrRemove
826      */
827     protected function _updateOccurrence($tag, $toAddOrRemove)
828     {
829         if ($toAddOrRemove == 0) {
830             return;
831         }
832         
833         $tagId = $tag instanceof Tinebase_Model_Tag ? $tag->getId() : $tag;
834
835         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " de/increasing tag occurrence of $tagId by $toAddOrRemove");
836
837         $quotedIdentifier = $this->_db->quoteIdentifier('occurrence');
838         
839         if ($toAddOrRemove > 0) {
840             $toAdd = (int) $toAddOrRemove;
841             $data = array(
842                 'occurrence' => new Zend_Db_Expr($quotedIdentifier . ' + ' . $toAdd)
843             );
844         } else {
845             $toRemove = abs((int) $toAddOrRemove);
846             $data = array(
847                 'occurrence' => new Zend_Db_Expr('(CASE WHEN (' . $quotedIdentifier . ' - ' . $toRemove . ') > 0 THEN ' . $quotedIdentifier . ' - ' . $toRemove . ' ELSE 0 END)')
848             );
849         }
850         
851         $this->_db->update(SQL_TABLE_PREFIX . 'tags', $data, $this->_db->quoteInto($this->_db->quoteIdentifier('id') . ' = ?', $tagId));
852     }
853
854     /**
855      * deletes given number from the persistent occurrence property of a given tag
856      *
857      * @param  Tinebase_Model_Tag|string $_tag
858      * @param  int                             $_toDel
859      * @return void
860      */
861     protected function _deleteOccurrence($_tag, $_toDel)
862     {
863         $this->_updateOccurrence($_tag, - $_toDel);
864     }
865
866     /**
867      * get all rights of a given tag
868      *
869      * @param  string                    $_tagId
870      * @return Tinebase_Record_RecordSet Set of Tinebase_Model_TagRight
871      */
872     public function getRights($_tagId)
873     {
874         $select = $this->_db->select()
875             ->from(array('tags_acl' => SQL_TABLE_PREFIX . 'tags_acl'), 
876                    array('tag_id', 'account_type', 'account_id', 'account_right' => $this->_dbCommand->getAggregate('account_right'))
877             )
878             ->where($this->_db->quoteInto($this->_db->quoteIdentifier('tag_id') . ' = ?', $_tagId))
879             ->group(array('tag_id', 'account_type', 'account_id'));
880         
881         Tinebase_Backend_Sql_Abstract::traitGroup($select);
882         
883         $stmt = $this->_db->query($select);
884         $rows = $stmt->fetchAll(Zend_Db::FETCH_ASSOC);
885
886         $rights = new Tinebase_Record_RecordSet('Tinebase_Model_TagRight', $rows, true);
887
888         return $rights;
889     }
890
891     /**
892      * purges (removes from tabel) all rights of a given tag
893      *
894      * @param  string $_tagId
895      * @return void
896      */
897     public function purgeRights($_tagId)
898     {
899         $this->_db->delete(SQL_TABLE_PREFIX . 'tags_acl', array(
900             $this->_db->quoteIdentifier('tag_id') . ' = ?' => $_tagId
901         ));
902     }
903
904     /**
905      * Sets all given tag rights
906      *
907      * @param Tinebase_Record_RecordSet|Tinebase_Model_TagRight
908      * @return void
909      * @throws Tinebase_Exception_Record_Validation
910      */
911     public function setRights($_rights)
912     {
913         $rights = $_rights instanceof Tinebase_Model_TagRight ? array($_rights) : $_rights;
914
915         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Setting ' . count($rights) . ' tag right(s).');
916
917         foreach ($rights as $right) {
918             if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' ' . print_r($right->toArray(), TRUE));
919             
920             if (! ($right instanceof Tinebase_Model_TagRight && $right->isValid())) {
921                 throw new Tinebase_Exception_Record_Validation('The given right is not valid!');
922             }
923             $this->_db->delete(SQL_TABLE_PREFIX . 'tags_acl', array(
924                 $this->_db->quoteInto($this->_db->quoteIdentifier('tag_id') . ' = ?', $right->tag_id),
925                 $this->_db->quoteInto($this->_db->quoteIdentifier('account_type') . ' = ?', $right->account_type),
926                 $this->_db->quoteInto($this->_db->quoteIdentifier('account_id') . ' = ?', (string) $right->account_id)
927             ));
928             foreach (array('view', 'use' ) as $availableRight) {
929                 $rightField = $availableRight . '_right';
930                 if ($right->$rightField === true) {
931                     $this->_db->insert(SQL_TABLE_PREFIX . 'tags_acl', array(
932                         'tag_id'        => $right->tag_id,
933                         'account_type'  => $right->account_type,
934                         'account_id'    => $right->account_id,
935                         'account_right' => $availableRight
936                     ));
937                 }
938             }
939         }
940     }
941
942     /**
943      * returns all contexts of a given tag
944      *
945      * @param  string $_tagId
946      * @return array  array of application ids
947      */
948     public function getContexts($_tagId)
949     {
950         $select = $this->_db->select()
951             ->from(array('tags_context' => SQL_TABLE_PREFIX . 'tags_context'), array('application_id' => $this->_dbCommand->getAggregate('application_id')))
952             ->where($this->_db->quoteInto($this->_db->quoteIdentifier('tag_id') . ' = ?', $_tagId))
953             ->group('tag_id');
954         
955         Tinebase_Backend_Sql_Abstract::traitGroup($select);
956         
957         $apps = $this->_db->fetchOne($select);
958
959         if ($apps === '0'){
960             $apps = 'any';
961         }
962
963         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' got tag contexts: ' .$apps);
964         return explode(',', $apps);
965     }
966
967     /**
968      * purges (removes from tabel) all contexts of a given tag
969      *
970      * @param  string $_tagId
971      * @return void
972      */
973     public function purgeContexts($_tagId)
974     {
975         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' removing contexts for tag ' . $_tagId);
976
977         $this->_db->delete(SQL_TABLE_PREFIX . 'tags_context', array(
978             $this->_db->quoteIdentifier('tag_id') . ' = ?' => $_tagId
979         ));
980     }
981
982     /**
983      * sets all given contexts for a given tag
984      *
985      * @param   array  $_contexts array of application ids (0 or 'any' for all apps)
986      * @param   string $_tagId
987      * @throws  Tinebase_Exception_InvalidArgument
988      */
989     public function setContexts(array $_contexts, $_tagId)
990     {
991         if (!$_tagId) {
992             throw new Tinebase_Exception_InvalidArgument('A $_tagId is mandentory.');
993         }
994
995         if (in_array('any', $_contexts, true) || in_array(0, $_contexts, true)) {
996             $_contexts = array(0);
997         }
998
999         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) 
1000             Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Setting tag contexts: ' . print_r($_contexts, true));
1001
1002         foreach ($_contexts as $context) {
1003             $this->_db->insert(SQL_TABLE_PREFIX . 'tags_context', array(
1004                 'tag_id'         => $_tagId instanceof Tinebase_Model_Tag ? $_tagId->getId() : $_tagId,
1005                 'application_id' => $context
1006             ));
1007         }
1008     }
1009
1010     /**
1011      * get db adapter
1012      *
1013      * @return Zend_Db_Adapter_Abstract
1014      */
1015     public function getAdapter()
1016     {
1017         return $this->_db;
1018     }
1019
1020     /**
1021      * get backend type
1022      *
1023      * @return string
1024      */
1025     public function getType()
1026     {
1027         return 'Sql';
1028     }
1029
1030     /**
1031      * get select for tags query
1032      *
1033      * @param string|array $_recordId
1034      * @param string $_applicationId
1035      * @param mixed $_cols
1036      * @return Zend_Db_Select
1037      */
1038     protected function _getSelect($_recordId, $_applicationId, $_cols = '*')
1039     {
1040         $recordIds = (array) $_recordId;
1041         // stringify record ids (we might have a mix of uuids and old integer ids)
1042         foreach ($recordIds as $key => $value) {
1043             $recordIds[$key] = (string) $value;
1044         }
1045
1046         $select = $this->_db->select()
1047             ->from(array('tagging' => SQL_TABLE_PREFIX . 'tagging'), $_cols)
1048             ->join(array('tags'    => SQL_TABLE_PREFIX . 'tags'), $this->_db->quoteIdentifier('tagging.tag_id') . ' = ' . $this->_db->quoteIdentifier('tags.id'))
1049             ->where($this->_db->quoteIdentifier('application_id') . ' = ?', $_applicationId)
1050             ->where($this->_db->quoteIdentifier('record_id') . ' IN (?) ', $recordIds)
1051             ->where($this->_db->quoteIdentifier('is_deleted') . ' = 0');
1052         
1053         $this->_filterSharedOnly($select, $_applicationId);
1054         
1055         return $select;
1056     }
1057     
1058     /**
1059      * apply filter for type shared only
1060      * 
1061      * @param Zend_Db_Select $select
1062      * @param string $applicationId
1063      */
1064     protected function _filterSharedOnly($select, $applicationId)
1065     {
1066         if (! $this->_userHasPersonalTagRight($applicationId)) {
1067             $select->where($this->_db->quoteIdentifier('type') . ' = ?', Tinebase_Model_Tag::TYPE_SHARED);
1068         }
1069     }
1070     
1071     /**
1072      * checks if user is allowed to use personal tags in application
1073      * 
1074      * @param string $applicationId
1075      */
1076     protected function _userHasPersonalTagRight($applicationId)
1077     {
1078         return ! is_object(Tinebase_Core::getUser()) || Tinebase_Core::getUser()->hasRight($applicationId, Tinebase_Acl_Rights_Abstract::USE_PERSONAL_TAGS);
1079     }
1080
1081     /**
1082      * merge duplicate shared tags
1083      * 
1084      * @param string $model record model for which tags should be merged
1085      * @param boolean $deleteObsoleteTags
1086      * @param boolean $ignoreAcl
1087      * 
1088      * @see 0007354: function for merging duplicate tags
1089      */
1090     public function mergeDuplicateSharedTags($model, $deleteObsoleteTags = TRUE, $ignoreAcl = FALSE)
1091     {
1092         $select = $this->_db->select()
1093             ->from(array('tags'    => SQL_TABLE_PREFIX . 'tags'), 'name')
1094             ->where($this->_db->quoteIdentifier('type') . ' = ?', Tinebase_Model_Tag::TYPE_SHARED)
1095             ->where($this->_db->quoteIdentifier('is_deleted') . ' = 0')
1096             ->group('name')
1097             ->having('COUNT(' . $this->_db->quoteIdentifier('name') . ') > 1');
1098         $queryResult = $this->_db->fetchAll($select);
1099         
1100         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
1101             ' Found ' . count($queryResult) . ' duplicate tag names.');
1102         
1103         $controller = Tinebase_Core::getApplicationInstance($model);
1104         if ($ignoreAcl) {
1105             $containerChecks = $controller->doContainerACLChecks(FALSE);
1106         }
1107         $recordFilterModel = $model . 'Filter';
1108         
1109         foreach ($queryResult as $duplicateTag) {
1110             $filter = new Tinebase_Model_TagFilter(array(
1111                 'name' => $duplicateTag['name'],
1112                 'type' => Tinebase_Model_Tag::TYPE_SHARED,
1113             ));
1114             $paging = new Tinebase_Model_Pagination(array('sort' => 'creation_time'));
1115             $tagsWithSameName = $this->searchTags($filter, $paging);
1116             $targetTag = $tagsWithSameName->getFirstRecord();
1117             
1118             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
1119                 ' Merging tag ' . $duplicateTag['name'] . '. Found ' . count($tagsWithSameName) . ' tags with this name.');
1120             
1121             foreach ($tagsWithSameName as $tag) {
1122                 if ($tag->getId() === $targetTag->getId()) {
1123                     // skip target (oldest) tag
1124                     continue;
1125                 }
1126
1127                 $recordFilter = new $recordFilterModel(array(
1128                     array('field' => 'tag', 'operator' => 'in', 'value' => array($tag->getId()))
1129                 ));
1130                 
1131                 $recordIdsWithTagToMerge = $controller->search($recordFilter, NULL, FALSE, TRUE);
1132                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
1133                     ' Found ' . count($recordIdsWithTagToMerge) . ' ' . $model . '(s) with tags to be merged.');
1134                 
1135                 if (!empty($recordIdsWithTagToMerge)) {
1136                     $recordFilter = new $recordFilterModel(array(
1137                         array('field' => 'id', 'operator' => 'in', 'value' => $recordIdsWithTagToMerge)
1138                     ));
1139                     
1140                     $this->attachTagToMultipleRecords($recordFilter, $targetTag);
1141                     $this->detachTagsFromMultipleRecords($recordFilter, $tag->getId());
1142                 }
1143                 
1144                 // check occurrence of the merged tag and remove it if obsolete
1145                 $tag = $this->get($tag);
1146                 if ($deleteObsoleteTags && $tag->occurrence == 0) {
1147                     $this->deleteTags($tag->getId(), $ignoreAcl);
1148                 }
1149             }
1150         }
1151         
1152         if ($ignoreAcl) {
1153             /** @noinspection PhpUndefinedVariableInspection */
1154             $controller->doContainerACLChecks($containerChecks);
1155         }
1156     }
1157 }