1: <?php
2: /*****************************************************************************************
3: * X2Engine Open Source Edition is a customer relationship management program developed by
4: * X2Engine, Inc. Copyright (C) 2011-2016 X2Engine Inc.
5: *
6: * This program is free software; you can redistribute it and/or modify it under
7: * the terms of the GNU Affero General Public License version 3 as published by the
8: * Free Software Foundation with the addition of the following permission added
9: * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
10: * IN WHICH THE COPYRIGHT IS OWNED BY X2ENGINE, X2ENGINE DISCLAIMS THE WARRANTY
11: * OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
12: *
13: * This program is distributed in the hope that it will be useful, but WITHOUT
14: * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15: * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
16: * details.
17: *
18: * You should have received a copy of the GNU Affero General Public License along with
19: * this program; if not, see http://www.gnu.org/licenses or write to the Free
20: * Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
21: * 02110-1301 USA.
22: *
23: * You can contact X2Engine, Inc. P.O. Box 66752, Scotts Valley,
24: * California 95067, USA. or at email address contact@x2engine.com.
25: *
26: * The interactive user interfaces in modified source and object code versions
27: * of this program must display Appropriate Legal Notices, as required under
28: * Section 5 of the GNU Affero General Public License version 3.
29: *
30: * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
31: * these Appropriate Legal Notices must retain the display of the "Powered by
32: * X2Engine" logo. If the display of the logo is not reasonably feasible for
33: * technical reasons, the Appropriate Legal Notices must display the words
34: * "Powered by X2Engine".
35: *****************************************************************************************/
36:
37: /**
38: * TagBehavior class file.
39: *
40: * @package application.components
41: * TagBehavior adds and removes tags from x2_tags when a record is created, updated or deleted
42: */
43: class TagBehavior extends X2ActiveRecordBehavior {
44:
45: /**
46: * @var bool $disableTagScanning
47: */
48: public $disableTagScanning = false;
49:
50: /**
51: * @var a cache of all tags associated with the owner model
52: */
53: protected $_tags = null;
54:
55: private $flowTriggersEnabled = true;
56:
57: public function rules () {
58: return array (
59: array ('tags', 'safe', 'on' => 'search'),
60: );
61: }
62:
63: public function enableTagTriggers () {
64: $this->flowTriggersEnabled = true;
65: }
66:
67: public function disableTagTriggers () {
68: $this->flowTriggersEnabled = false;
69: }
70:
71: /**
72: * Responds to {@link CModel::onAfterSave} event.
73: *
74: * Matches tags provided they:
75: * - start with a #
76: * - consist of these characters: UTF-8 letters, numbers, _ and - (but only in the middle
77: * of the tag)
78: * - come after a space or . or are at the beginning
79: * - are not in quotes
80: *
81: * Looks up any current tag records, and saves a tag record for each new tag.
82: * Note: does not delete tags when they are removed from text fields (this would screw with
83: * manual tagging)
84: *
85: * @param CModelEvent $event event parameter
86: */
87: public function afterSave($event) {
88: // look up current tags
89: $oldTags = $this->getTags();
90: $newTags = array();
91:
92: foreach ($this->scanForTags() as $tag) {
93: if (!$this->hasTag ($tag, $oldTags)) { // don't add duplicates if there are already tags
94: $tagModel = new Tags;
95: $tagModel->tag = $tag;
96: $tagModel->type = get_class($this->getOwner());
97: $tagModel->itemId = $this->getOwner()->id;
98: $tagModel->itemName = $this->getOwner()->name;
99: $tagModel->taggedBy = Yii::app()->getSuName();
100: $tagModel->timestamp = time();
101: if ($tagModel->save())
102: $newTags[] = $tag;
103: }
104: }
105: $this->_tags = $newTags + $oldTags; // update tag cache
106:
107: if (!empty($newTags) && $this->flowTriggersEnabled) {
108: X2Flow::trigger('RecordTagAddTrigger', array(
109: 'model' => $this->getOwner(),
110: 'tags' => $newTags,
111: ));
112: }
113: }
114:
115: /**
116: * Responds to {@link CActiveRecord::onAfterDelete} event.
117: * Deletes all the tags for this model
118: *
119: * @param CModelEvent $event event parameter
120: */
121: public function afterDelete($event) {
122: $this->clearTags();
123: }
124:
125: /**
126: * Scans through every 'varchar' and 'text' field in the owner model for tags.
127: *
128: * @return array an array of tags
129: */
130: public function scanForTags() {
131: if ($this->disableTagScanning) return array ();
132: $tags = array();
133:
134: if (Yii::app()->settings->disableAutomaticRecordTagging) {
135: return array();
136: }
137:
138: // Type of fields to search in
139: $fieldTypes = array (
140: 'text'
141: );
142:
143: foreach ($this->getOwner()->getFields(true) as $fieldName => $field) {
144: if (!in_array($field->type, $fieldTypes)) {
145: continue;
146: }
147:
148: $text = $this->owner->$fieldName;
149:
150: $matches = $this->matchTags ($text);
151: $tags = array_merge($matches, $tags);
152: }
153: $tags = array_unique($tags);
154: return $tags;
155: }
156:
157: /**
158: * Finds all tag matches in text
159: * @param string $text
160: * @return array
161: */
162: public function matchTags($text) {
163:
164: // Array of excludes such as style tags, href attributes, etc
165: $excludes = array(
166: '/<style[^<]*<\/style>/',
167: '/style="[^"]*"/',
168: '/style=\'[^\']*\'/',
169: );
170:
171: foreach ($excludes as $exp) {
172: $text = preg_replace($exp, '', $text);
173: }
174:
175: // Primary expression to filter out tags
176: $exp = '/(?:|\s)(#(?:\w+|\w[-\w]+\w))(?:$|\s)/u';
177:
178: $matches = array();
179: preg_match_all($exp, $text, $matches);
180:
181: return $matches[1];
182: }
183:
184: /**
185: * @param string $tag
186: * @param array|null $oldTags
187: * @return true if record has tag already, false otherwise
188: */
189: public function hasTag ($tag, array $oldTags=null, $refresh=false) {
190: $oldTags = $oldTags === null ? $this->getTags ($refresh) : $oldTags;
191: return in_array (strtolower (Tags::normalizeTag ($tag)), array_map (function ($tag) {
192: return strtolower ($tag);
193: }, $oldTags));
194: }
195:
196: /**
197: * Tests whether the owner model has any (OR mode) or all (AND mode) of the provided tags
198: *
199: * @param mixed $tags sring or array of strings containing tags
200: * @param array $mode logic mode (either "AND" or "OR") for the test
201: * @return boolean the test result
202: */
203: public function hasTags($tags, $mode = 'OR') {
204: $matches = array_intersect($this->getTags(), Tags::normalizeTags((array) $tags));
205:
206: if ($mode === 'AND')
207: return count($matches) === count((array) $tags); // all tags must be present
208: else
209: return count($matches) > 0; // at least one tag must be present
210: }
211:
212: /**
213: * Looks up the tags associated with the owner model.
214: * Uses {@link $tags} as a cache to prevent repeated queries.
215: *
216: * @return array an array of tags
217: */
218: public function getTags($refreshCache = false) {
219: if ($this->_tags === null || $refreshCache) {
220: $this->_tags = Yii::app()->db->createCommand()
221: ->select('tag')
222: ->from(CActiveRecord::model('Tags')->tableName())
223: ->where(
224: 'type=:type AND itemId=:itemId',
225: array(
226: ':type' => get_class($this->getOwner()),
227: ':itemId' => $this->getOwner()->id))
228: ->queryColumn();
229: }
230: return $this->_tags;
231: }
232:
233: public function setTags ($tags, $rawInput=false) {
234: if (!$rawInput)
235: $tags = is_string ($tags) ? array_map (function ($tag) {
236: return trim ($tag);
237: }, explode (Tags::DELIM, $tags)) : $tags;
238: $this->_tags = $tags;
239: }
240:
241: public function compareTags (CDbCriteria $criteria) {
242: $tags = $this->tags;
243: $inQuery = array ();
244: $params = array (
245: ':type' => get_class ($this->owner),
246: );
247: for ($i = 0; $i < count ($tags); $i++) {
248: if ($tags[$i] === '') {
249: unset ($tags[$i]);
250: $i--;
251: continue;
252: } else {
253: $inQuery[] = 'b.tag LIKE :'.$i;
254: $params[':'.$i] = '%'.$tags[$i].'%';
255: }
256: }
257: $tagConditions = implode (' OR ',$inQuery);
258:
259: if ($tagConditions) {
260: $criteria->distinct = true;
261: $criteria->join .= ' JOIN x2_tags b ON (b.itemId=t.id AND b.type=:type '.
262: 'AND ('.$tagConditions.'))';
263: $criteria->params = $params;
264: }
265:
266: return $criteria;
267: }
268:
269: public function renderTagInput () {
270: $clone = clone $this->owner;
271: $clone->setTags (implode (', ', $this->tags), true);
272:
273: return CHtml::activeTextField ($clone, 'tags');
274: }
275:
276: /**
277: * Adds the specified tag(s) to the owner model, but not
278: * if the tag has already been added.
279: * @param mixed $tags a string or array of strings containing tags
280: * @return boolean whether or not at least one tag was added successfully
281: */
282: public function addTags($tags) {
283: $result = false;
284: $addedTags = array();
285:
286: foreach ((array) $tags as $tagName) {
287: if (empty($tagName))
288: continue;
289: if (!$this->hasTag ($tagName)) { // check for duplicate tag
290: $tag = new Tags;
291: $tag->tag = Tags::normalizeTag ($tagName);
292: $tag->itemId = $this->getOwner()->id;
293: $tag->type = get_class($this->getOwner());
294: $tag->taggedBy = Yii::app()->getSuName();
295: $tag->timestamp = time();
296: $tag->itemName = $this->getOwner()->name;
297:
298: if ($tag->save()) {
299: $this->_tags[] = $tag->tag; // update tag cache
300: $addedTags[] = $tagName;
301: $result = true;
302: } else {
303: throw new CHttpException(
304: 422, 'Failed saving tag due to errors: ' . json_encode($tag->errors));
305: }
306: }
307: }
308: if ($this->flowTriggersEnabled)
309: X2Flow::trigger('RecordTagAddTrigger', array(
310: 'model' => $this->getOwner(),
311: 'tags' => $addedTags,
312: ));
313:
314: return $result;
315: }
316:
317: /**
318: * Removes the specified tag(s) from the owner model
319: * @param mixed $tags a string or array of strings containing tags
320: * @return boolean whether or not at least one tag was deleted successfully
321: */
322: public function removeTags($tags) {
323: $result = false;
324: $removedTags = array();
325: $tags = Tags::normalizeTags((array) $tags);
326:
327: foreach ((array) $tags as $tag) {
328: if (empty($tag))
329: continue;
330:
331: $attributes = array(
332: 'type' => get_class($this->getOwner()),
333: 'itemId' => $this->getOwner()->id,
334: 'tag' => $tag
335: );
336: if ($this->hasTag ($tag) &&
337: CActiveRecord::model('Tags')->deleteAllByAttributes($attributes) > 0) {
338:
339: if (false !== $offset = array_search($tag, $this->_tags))
340: unset($this->_tags[$offset]); // update tag cache
341:
342: $removedTags[] = $tag;
343: $result = true;
344: }
345: }
346: if ($this->flowTriggersEnabled)
347: X2Flow::trigger('RecordTagRemoveTrigger', array(
348: 'model' => $this->getOwner(),
349: 'tags' => $removedTags,
350: ));
351:
352: return $result;
353: }
354:
355: /**
356: * Deletes all tags associated with the owner model
357: */
358: public function clearTags() {
359: $this->_tags = array(); // clear tag cache
360:
361: return (bool) CActiveRecord::model('Tags')->deleteAllByAttributes(array(
362: 'type' => get_class($this->getOwner()),
363: 'itemId' => $this->getOwner()->id)
364: );
365: }
366:
367: }
368: