MDL-62950 behat: Correct path to P&P
[moodle.git] / tag / classes / tag.php
blob52dfd34b1ecb6492f37cda8025508e8ed402fff4
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Contains class core_tag_tag
20 * @package core_tag
21 * @copyright 2015 Marina Glancy
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 defined('MOODLE_INTERNAL') || die();
27 /**
28 * Represents one tag and also contains lots of useful tag-related methods as static functions.
30 * Tags can be added to any database records.
31 * $itemtype refers to the DB table name
32 * $itemid refers to id field in this DB table
33 * $component is the component that is responsible for the tag instance
34 * $context is the affected context
36 * BASIC INSTRUCTIONS :
37 * - to "tag a blog post" (for example):
38 * core_tag_tag::set_item_tags('post', 'core', $blogpost->id, $context, $arrayoftags);
40 * - to "remove all the tags on a blog post":
41 * core_tag_tag::remove_all_item_tags('post', 'core', $blogpost->id);
43 * set_item_tags() will create tags that do not exist yet.
45 * @property-read int $id
46 * @property-read string $name
47 * @property-read string $rawname
48 * @property-read int $tagcollid
49 * @property-read int $userid
50 * @property-read int $isstandard
51 * @property-read string $description
52 * @property-read int $descriptionformat
53 * @property-read int $flag 0 if not flagged or positive integer if flagged
54 * @property-read int $timemodified
56 * @package core_tag
57 * @copyright 2015 Marina Glancy
58 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
60 class core_tag_tag {
62 /** @var stdClass data about the tag */
63 protected $record = null;
65 /** @var int indicates that both standard and not standard tags can be used (or should be returned) */
66 const BOTH_STANDARD_AND_NOT = 0;
68 /** @var int indicates that only standard tags can be used (or must be returned) */
69 const STANDARD_ONLY = 1;
71 /** @var int indicates that only non-standard tags should be returned - this does not really have use cases, left for BC */
72 const NOT_STANDARD_ONLY = -1;
74 /** @var int option to hide standard tags when editing item tags */
75 const HIDE_STANDARD = 2;
77 /**
78 * Constructor. Use functions get(), get_by_name(), etc.
80 * @param stdClass $record
82 protected function __construct($record) {
83 if (empty($record->id)) {
84 throw new coding_exeption("Record must contain at least field 'id'");
86 $this->record = $record;
89 /**
90 * Magic getter
92 * @param string $name
93 * @return mixed
95 public function __get($name) {
96 return $this->record->$name;
99 /**
100 * Magic isset method
102 * @param string $name
103 * @return bool
105 public function __isset($name) {
106 return isset($this->record->$name);
110 * Converts to object
112 * @return stdClass
114 public function to_object() {
115 return fullclone($this->record);
119 * Returns tag name ready to be displayed
121 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
122 * @return string
124 public function get_display_name($ashtml = true) {
125 return static::make_display_name($this->record, $ashtml);
129 * Prepares tag name ready to be displayed
131 * @param stdClass|core_tag_tag $tag record from db table tag, must contain properties name and rawname
132 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
133 * @return string
135 public static function make_display_name($tag, $ashtml = true) {
136 global $CFG;
138 if (empty($CFG->keeptagnamecase)) {
139 // This is the normalized tag name.
140 $tagname = core_text::strtotitle($tag->name);
141 } else {
142 // Original casing of the tag name.
143 $tagname = $tag->rawname;
146 // Clean up a bit just in case the rules change again.
147 $tagname = clean_param($tagname, PARAM_TAG);
149 return $ashtml ? htmlspecialchars($tagname) : $tagname;
153 * Adds one or more tag in the database. This function should not be called directly : you should
154 * use tag_set.
156 * @param int $tagcollid
157 * @param string|array $tags one tag, or an array of tags, to be created
158 * @param bool $isstandard type of tag to be created. A standard tag is kept even if there are no records tagged with it.
159 * @return array tag objects indexed by their lowercase normalized names. Any boolean false in the array
160 * indicates an error while adding the tag.
162 protected static function add($tagcollid, $tags, $isstandard = false) {
163 global $USER, $DB;
165 $tagobject = new stdClass();
166 $tagobject->isstandard = $isstandard ? 1 : 0;
167 $tagobject->userid = $USER->id;
168 $tagobject->timemodified = time();
169 $tagobject->tagcollid = $tagcollid;
171 $rv = array();
172 foreach ($tags as $veryrawname) {
173 $rawname = clean_param($veryrawname, PARAM_TAG);
174 if (!$rawname) {
175 $rv[$rawname] = false;
176 } else {
177 $obj = (object)(array)$tagobject;
178 $obj->rawname = $rawname;
179 $obj->name = core_text::strtolower($rawname);
180 $obj->id = $DB->insert_record('tag', $obj);
181 $rv[$obj->name] = new static($obj);
183 \core\event\tag_created::create_from_tag($rv[$obj->name])->trigger();
187 return $rv;
191 * Simple function to just return a single tag object by its id
193 * @param int $id
194 * @param string $returnfields which fields do we want returned from table {tag}.
195 * Default value is 'id,name,rawname,tagcollid',
196 * specify '*' to include all fields.
197 * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
198 * IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
199 * MUST_EXIST means throw exception if no record or multiple records found
200 * @return core_tag_tag|false tag object
202 public static function get($id, $returnfields = 'id, name, rawname, tagcollid', $strictness = IGNORE_MISSING) {
203 global $DB;
204 $record = $DB->get_record('tag', array('id' => $id), $returnfields, $strictness);
205 if ($record) {
206 return new static($record);
208 return false;
212 * Simple function to just return an array of tag objects by their ids
214 * @param int[] $ids
215 * @param string $returnfields which fields do we want returned from table {tag}.
216 * Default value is 'id,name,rawname,tagcollid',
217 * specify '*' to include all fields.
218 * @return core_tag_tag[] array of retrieved tags
220 public static function get_bulk($ids, $returnfields = 'id, name, rawname, tagcollid') {
221 global $DB;
222 $result = array();
223 if (empty($ids)) {
224 return $result;
226 list($sql, $params) = $DB->get_in_or_equal($ids);
227 $records = $DB->get_records_select('tag', 'id '.$sql, $params, '', $returnfields);
228 foreach ($records as $record) {
229 $result[$record->id] = new static($record);
231 return $result;
235 * Simple function to just return a single tag object by tagcollid and name
237 * @param int $tagcollid tag collection to use,
238 * if 0 is given we will try to guess the tag collection and return the first match
239 * @param string $name tag name
240 * @param string $returnfields which fields do we want returned. This is a comma separated string
241 * containing any combination of 'id', 'name', 'rawname', 'tagcollid' or '*' to include all fields.
242 * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
243 * IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
244 * MUST_EXIST means throw exception if no record or multiple records found
245 * @return core_tag_tag|false tag object
247 public static function get_by_name($tagcollid, $name, $returnfields='id, name, rawname, tagcollid',
248 $strictness = IGNORE_MISSING) {
249 global $DB;
250 if ($tagcollid == 0) {
251 $tags = static::guess_by_name($name, $returnfields);
252 if ($tags) {
253 $tag = reset($tags);
254 return $tag;
255 } else if ($strictness == MUST_EXIST) {
256 throw new dml_missing_record_exception('tag', 'name=?', array($name));
258 return false;
260 $name = core_text::strtolower($name); // To cope with input that might just be wrong case.
261 $params = array('name' => $name, 'tagcollid' => $tagcollid);
262 $record = $DB->get_record('tag', $params, $returnfields, $strictness);
263 if ($record) {
264 return new static($record);
266 return false;
270 * Looking in all tag collections for the tag with the given name
272 * @param string $name tag name
273 * @param string $returnfields
274 * @return array array of core_tag_tag instances
276 public static function guess_by_name($name, $returnfields='id, name, rawname, tagcollid') {
277 global $DB;
278 if (empty($name)) {
279 return array();
281 $tagcolls = core_tag_collection::get_collections();
282 list($sql, $params) = $DB->get_in_or_equal(array_keys($tagcolls), SQL_PARAMS_NAMED);
283 $params['name'] = core_text::strtolower($name);
284 $tags = $DB->get_records_select('tag', 'name = :name AND tagcollid ' . $sql, $params, '', $returnfields);
285 if (count($tags) > 1) {
286 // Sort in the same order as tag collections.
287 $tagcolls = core_tag_collection::get_collections();
288 uasort($tags, function($a, $b) use ($tagcolls) {
289 return $tagcolls[$a->tagcollid]->sortorder < $tagcolls[$b->tagcollid]->sortorder ? -1 : 1;
292 $rv = array();
293 foreach ($tags as $id => $tag) {
294 $rv[$id] = new static($tag);
296 return $rv;
300 * Returns the list of tag objects by tag collection id and the list of tag names
302 * @param int $tagcollid
303 * @param array $tags array of tags to look for
304 * @param string $returnfields list of DB fields to return, must contain 'id', 'name' and 'rawname'
305 * @return array tag-indexed array of objects. No value for a key means the tag wasn't found.
307 public static function get_by_name_bulk($tagcollid, $tags, $returnfields = 'id, name, rawname, tagcollid') {
308 global $DB;
310 if (empty($tags)) {
311 return array();
314 $cleantags = self::normalize(self::normalize($tags, false)); // Format: rawname => normalised name.
316 list($namesql, $params) = $DB->get_in_or_equal(array_values($cleantags));
317 array_unshift($params, $tagcollid);
319 $recordset = $DB->get_recordset_sql("SELECT $returnfields FROM {tag} WHERE tagcollid = ? AND name $namesql", $params);
321 $result = array_fill_keys($cleantags, null);
322 foreach ($recordset as $record) {
323 $result[$record->name] = new static($record);
325 $recordset->close();
326 return $result;
331 * Function that normalizes a list of tag names.
333 * @param array $rawtags array of tags
334 * @param bool $tolowercase convert to lower case?
335 * @return array lowercased normalized tags, indexed by the normalized tag, in the same order as the original array.
336 * (Eg: 'Banana' => 'banana').
338 public static function normalize($rawtags, $tolowercase = true) {
339 $result = array();
340 foreach ($rawtags as $rawtag) {
341 $rawtag = trim($rawtag);
342 if (strval($rawtag) !== '') {
343 $clean = clean_param($rawtag, PARAM_TAG);
344 if ($tolowercase) {
345 $result[$rawtag] = core_text::strtolower($clean);
346 } else {
347 $result[$rawtag] = $clean;
351 return $result;
355 * Retrieves tags and/or creates them if do not exist yet
357 * @param int $tagcollid
358 * @param array $tags array of raw tag names, do not have to be normalised
359 * @param bool $isstandard create as standard tag (default false)
360 * @return core_tag_tag[] array of tag objects indexed with lowercase normalised tag name
362 public static function create_if_missing($tagcollid, $tags, $isstandard = false) {
363 $cleantags = self::normalize(array_filter(self::normalize($tags, false))); // Array rawname => normalised name .
365 $result = static::get_by_name_bulk($tagcollid, $tags, '*');
366 $existing = array_filter($result);
367 $missing = array_diff_key(array_flip($cleantags), $existing); // Array normalised name => rawname.
368 if ($missing) {
369 $newtags = static::add($tagcollid, array_values($missing), $isstandard);
370 foreach ($newtags as $tag) {
371 $result[$tag->name] = $tag;
374 return $result;
378 * Creates a URL to view a tag
380 * @param int $tagcollid
381 * @param string $name
382 * @param int $exclusivemode
383 * @param int $fromctx context id where this tag cloud is displayed
384 * @param int $ctx context id for tag view link
385 * @param int $rec recursive argument for tag view link
386 * @return \moodle_url
388 public static function make_url($tagcollid, $name, $exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
389 $coll = core_tag_collection::get_by_id($tagcollid);
390 if (!empty($coll->customurl)) {
391 $url = '/' . ltrim(trim($coll->customurl), '/');
392 } else {
393 $url = '/tag/index.php';
395 $params = array('tc' => $tagcollid, 'tag' => $name);
396 if ($exclusivemode) {
397 $params['excl'] = 1;
399 if ($fromctx) {
400 $params['from'] = $fromctx;
402 if ($ctx) {
403 $params['ctx'] = $ctx;
405 if (!$rec) {
406 $params['rec'] = 0;
408 return new moodle_url($url, $params);
412 * Returns URL to view the tag
414 * @param int $exclusivemode
415 * @param int $fromctx context id where this tag cloud is displayed
416 * @param int $ctx context id for tag view link
417 * @param int $rec recursive argument for tag view link
418 * @return \moodle_url
420 public function get_view_url($exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
421 return static::make_url($this->record->tagcollid, $this->record->rawname,
422 $exclusivemode, $fromctx, $ctx, $rec);
426 * Validates that the required fields were retrieved and retrieves them if missing
428 * @param array $list array of the fields that need to be validated
429 * @param string $caller name of the function that requested it, for the debugging message
431 protected function ensure_fields_exist($list, $caller) {
432 global $DB;
433 $missing = array_diff($list, array_keys((array)$this->record));
434 if ($missing) {
435 debugging('core_tag_tag::' . $caller . '() must be called on fully retrieved tag object. Missing fields: '.
436 join(', ', $missing), DEBUG_DEVELOPER);
437 $this->record = $DB->get_record('tag', array('id' => $this->record->id), '*', MUST_EXIST);
442 * Deletes the tag instance given the record from tag_instance DB table
444 * @param stdClass $taginstance
445 * @param bool $fullobject whether $taginstance contains all fields from DB table tag_instance
446 * (in this case it is safe to add a record snapshot to the event)
447 * @return bool
449 protected function delete_instance_as_record($taginstance, $fullobject = false) {
450 global $DB;
452 $this->ensure_fields_exist(array('name', 'rawname', 'isstandard'), 'delete_instance_as_record');
454 $DB->delete_records('tag_instance', array('id' => $taginstance->id));
456 // We can not fire an event with 'null' as the contextid.
457 if (is_null($taginstance->contextid)) {
458 $taginstance->contextid = context_system::instance()->id;
461 // Trigger tag removed event.
462 $taginstance->tagid = $this->id;
463 \core\event\tag_removed::create_from_tag_instance($taginstance, $this->name, $this->rawname, $fullobject)->trigger();
465 // If there are no other instances of the tag then consider deleting the tag as well.
466 if (!$this->isstandard) {
467 if (!$DB->record_exists('tag_instance', array('tagid' => $this->id))) {
468 self::delete_tags($this->id);
472 return true;
476 * Delete one instance of a tag. If the last instance was deleted, it will also delete the tag, unless it is standard.
478 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
479 * query will be slow because DB index will not be used.
480 * @param string $itemtype the type of the record for which to remove the instance
481 * @param int $itemid the id of the record for which to remove the instance
482 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
484 protected function delete_instance($component, $itemtype, $itemid, $tiuserid = 0) {
485 global $DB;
486 $params = array('tagid' => $this->id,
487 'itemtype' => $itemtype, 'itemid' => $itemid);
488 if ($tiuserid) {
489 $params['tiuserid'] = $tiuserid;
491 if ($component) {
492 $params['component'] = $component;
495 $taginstance = $DB->get_record('tag_instance', $params);
496 if (!$taginstance) {
497 return;
499 $this->delete_instance_as_record($taginstance, true);
503 * Bulk delete all tag instances.
505 * @param stdClass[] $taginstances A list of tag_instance records to delete. Each
506 * record must also contain the name and rawname
507 * columns from the related tag record.
509 public static function delete_instances_as_record(array $taginstances) {
510 global $DB;
512 if (empty($taginstances)) {
513 return;
516 $taginstanceids = array_map(function($taginstance) {
517 return $taginstance->id;
518 }, $taginstances);
519 // Now remove all the tag instances.
520 $DB->delete_records_list('tag_instance', 'id', $taginstanceids);
521 // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
522 $syscontextid = context_system::instance()->id;
523 // Loop through the tag instances and fire an 'tag_removed' event.
524 foreach ($taginstances as $taginstance) {
525 // We can not fire an event with 'null' as the contextid.
526 if (is_null($taginstance->contextid)) {
527 $taginstance->contextid = $syscontextid;
530 // Trigger tag removed event.
531 \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name,
532 $taginstance->rawname, true)->trigger();
537 * Bulk delete all tag instances by tag id.
539 * @param int[] $taginstanceids List of tag instance ids to be deleted.
541 public static function delete_instances_by_id(array $taginstanceids) {
542 global $DB;
544 if (empty($taginstanceids)) {
545 return;
548 list($idsql, $params) = $DB->get_in_or_equal($taginstanceids);
549 $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
550 FROM {tag_instance} ti
551 JOIN {tag} t
552 ON ti.tagid = t.id
553 WHERE ti.id {$idsql}";
555 if ($taginstances = $DB->get_records_sql($sql, $params)) {
556 static::delete_instances_as_record($taginstances);
561 * Bulk delete all tag instances for a component or tag area
563 * @param string $component
564 * @param string $itemtype (optional)
565 * @param int $contextid (optional)
567 public static function delete_instances($component, $itemtype = null, $contextid = null) {
568 global $DB;
570 $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
571 FROM {tag_instance} ti
572 JOIN {tag} t
573 ON ti.tagid = t.id
574 WHERE ti.component = :component";
575 $params = array('component' => $component);
576 if (!is_null($contextid)) {
577 $sql .= " AND ti.contextid = :contextid";
578 $params['contextid'] = $contextid;
580 if (!is_null($itemtype)) {
581 $sql .= " AND ti.itemtype = :itemtype";
582 $params['itemtype'] = $itemtype;
585 if ($taginstances = $DB->get_records_sql($sql, $params)) {
586 static::delete_instances_as_record($taginstances);
591 * Adds a tag instance
593 * @param string $component
594 * @param string $itemtype
595 * @param string $itemid
596 * @param context $context
597 * @param int $ordering
598 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
599 * @return int id of tag_instance
601 protected function add_instance($component, $itemtype, $itemid, context $context, $ordering, $tiuserid = 0) {
602 global $DB;
603 $this->ensure_fields_exist(array('name', 'rawname'), 'add_instance');
605 $taginstance = new stdClass;
606 $taginstance->tagid = $this->id;
607 $taginstance->component = $component ? $component : '';
608 $taginstance->itemid = $itemid;
609 $taginstance->itemtype = $itemtype;
610 $taginstance->contextid = $context->id;
611 $taginstance->ordering = $ordering;
612 $taginstance->timecreated = time();
613 $taginstance->timemodified = $taginstance->timecreated;
614 $taginstance->tiuserid = $tiuserid;
616 $taginstance->id = $DB->insert_record('tag_instance', $taginstance);
618 // Trigger tag added event.
619 \core\event\tag_added::create_from_tag_instance($taginstance, $this->name, $this->rawname, true)->trigger();
621 return $taginstance->id;
625 * Updates the ordering on tag instance
627 * @param int $instanceid
628 * @param int $ordering
630 protected function update_instance_ordering($instanceid, $ordering) {
631 global $DB;
632 $data = new stdClass();
633 $data->id = $instanceid;
634 $data->ordering = $ordering;
635 $data->timemodified = time();
637 $DB->update_record('tag_instance', $data);
641 * Get the array of core_tag_tag objects associated with a list of items.
643 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
645 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
646 * query will be slow because DB index will not be used.
647 * @param string $itemtype type of the tagged item
648 * @param int[] $itemids
649 * @param int $standardonly wether to return only standard tags or any
650 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
651 * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
653 public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT,
654 $tiuserid = 0) {
655 global $DB;
657 if (static::is_enabled($component, $itemtype) === false) {
658 // Tagging area is properly defined but not enabled - return empty array.
659 return array();
662 if (empty($itemids)) {
663 return array();
666 $standardonly = (int)$standardonly; // In case somebody passed bool.
668 list($idsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
669 // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags().
670 $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
671 tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
672 FROM {tag_instance} ti
673 JOIN {tag} tg ON tg.id = ti.tagid
674 WHERE ti.itemtype = :itemtype AND ti.itemid $idsql ".
675 ($component ? "AND ti.component = :component " : "").
676 ($tiuserid ? "AND ti.tiuserid = :tiuserid " : "").
677 (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : "").
678 (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : "").
679 "ORDER BY ti.ordering ASC, ti.id";
681 $params['itemtype'] = $itemtype;
682 $params['component'] = $component;
683 $params['tiuserid'] = $tiuserid;
685 $records = $DB->get_records_sql($sql, $params);
686 $result = array();
687 foreach ($itemids as $itemid) {
688 $result[$itemid] = [];
690 foreach ($records as $id => $record) {
691 $result[$record->itemid][$id] = new static($record);
693 return $result;
697 * Get the array of core_tag_tag objects associated with an item (instances).
699 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
701 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
702 * query will be slow because DB index will not be used.
703 * @param string $itemtype type of the tagged item
704 * @param int $itemid
705 * @param int $standardonly wether to return only standard tags or any
706 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
707 * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
709 public static function get_item_tags($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
710 $tiuserid = 0) {
711 $tagobjects = static::get_items_tags($component, $itemtype, [$itemid], $standardonly, $tiuserid);
712 return empty($tagobjects) ? [] : $tagobjects[$itemid];
716 * Returns the list of display names of the tags that are associated with an item
718 * This method is usually used to prefill the form data for the 'tags' form element
720 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
721 * query will be slow because DB index will not be used.
722 * @param string $itemtype type of the tagged item
723 * @param int $itemid
724 * @param int $standardonly wether to return only standard tags or any
725 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
726 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded tag names
727 * @return string[] array of tags display names
729 public static function get_item_tags_array($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
730 $tiuserid = 0, $ashtml = true) {
731 $tags = array();
732 foreach (static::get_item_tags($component, $itemtype, $itemid, $standardonly, $tiuserid) as $tag) {
733 $tags[$tag->id] = $tag->get_display_name($ashtml);
735 return $tags;
739 * Sets the list of tag instances for one item (table record).
741 * Extra exsisting instances are removed, new ones are added. New tags are created if needed.
743 * This method can not be used for setting tags relations, please use set_related_tags()
745 * @param string $component component responsible for tagging
746 * @param string $itemtype type of the tagged item
747 * @param int $itemid
748 * @param context $context
749 * @param array $tagnames
750 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
752 public static function set_item_tags($component, $itemtype, $itemid, context $context, $tagnames, $tiuserid = 0) {
753 if ($itemtype === 'tag') {
754 if ($tiuserid) {
755 throw new coding_exeption('Related tags can not have tag instance userid');
757 debugging('You can not use set_item_tags() for tagging a tag, please use set_related_tags()', DEBUG_DEVELOPER);
758 static::get($itemid, '*', MUST_EXIST)->set_related_tags($tagnames);
759 return;
762 if ($tagnames !== null && static::is_enabled($component, $itemtype) === false) {
763 // Tagging area is properly defined but not enabled - do nothing.
764 // Unless we are deleting the item tags ($tagnames === null), in which case proceed with deleting.
765 return;
768 // Apply clean_param() to all tags.
769 if ($tagnames) {
770 $tagcollid = core_tag_area::get_collection($component, $itemtype);
771 $tagobjects = static::create_if_missing($tagcollid, $tagnames);
772 } else {
773 $tagobjects = array();
776 $allowmultiplecontexts = core_tag_area::allows_tagging_in_multiple_contexts($component, $itemtype);
777 $currenttags = static::get_item_tags($component, $itemtype, $itemid, self::BOTH_STANDARD_AND_NOT, $tiuserid);
778 $taginstanceidstomovecontext = [];
780 // For data coherence reasons, it's better to remove deleted tags
781 // before adding new data: ordering could be duplicated.
782 foreach ($currenttags as $currenttag) {
783 $hasbeenrequested = array_key_exists($currenttag->name, $tagobjects);
784 $issamecontext = $currenttag->taginstancecontextid == $context->id;
786 if ($allowmultiplecontexts) {
787 // If the tag area allows multiple contexts then we should only be
788 // managing tags in the given $context. All other tags can be ignored.
789 $shoulddelete = $issamecontext && !$hasbeenrequested;
790 } else {
791 // If the tag area only allows tag instances in a single context then
792 // all tags that aren't in the requested tags should be deleted, regardless
793 // of their context, if they are not part of the new set of tags.
794 $shoulddelete = !$hasbeenrequested;
795 // If the tag instance isn't in the correct context (legacy data)
796 // then we should take this opportunity to update it with the correct
797 // context id.
798 if (!$shoulddelete && !$issamecontext) {
799 $currenttag->taginstancecontextid = $context->id;
800 $taginstanceidstomovecontext[] = $currenttag->taginstanceid;
804 if ($shoulddelete) {
805 $taginstance = (object)array('id' => $currenttag->taginstanceid,
806 'itemtype' => $itemtype, 'itemid' => $itemid,
807 'contextid' => $currenttag->taginstancecontextid, 'tiuserid' => $tiuserid);
808 $currenttag->delete_instance_as_record($taginstance, false);
812 if (!empty($taginstanceidstomovecontext)) {
813 static::change_instances_context($taginstanceidstomovecontext, $context);
816 $ordering = -1;
817 foreach ($tagobjects as $name => $tag) {
818 $ordering++;
819 foreach ($currenttags as $currenttag) {
820 $namesmatch = strval($currenttag->name) === strval($name);
822 if ($allowmultiplecontexts) {
823 // If the tag area allows multiple contexts then we should only
824 // skip adding a new instance if the existing one is in the correct
825 // context.
826 $contextsmatch = $currenttag->taginstancecontextid == $context->id;
827 $shouldskipinstance = $namesmatch && $contextsmatch;
828 } else {
829 // The existing behaviour for single context tag areas is to
830 // skip adding a new instance regardless of whether the existing
831 // instance is in the same context as the provided $context.
832 $shouldskipinstance = $namesmatch;
835 if ($shouldskipinstance) {
836 if ($currenttag->ordering != $ordering) {
837 $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering);
839 continue 2;
842 $tag->add_instance($component, $itemtype, $itemid, $context, $ordering, $tiuserid);
847 * Removes all tags from an item.
849 * All tags will be removed even if tagging is disabled in this area. This is
850 * usually called when the item itself has been deleted.
852 * @param string $component component responsible for tagging
853 * @param string $itemtype type of the tagged item
854 * @param int $itemid
855 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
857 public static function remove_all_item_tags($component, $itemtype, $itemid, $tiuserid = 0) {
858 $context = context_system::instance(); // Context will not be used.
859 static::set_item_tags($component, $itemtype, $itemid, $context, null, $tiuserid);
863 * Adds a tag to an item, without overwriting the current tags.
865 * If the tag has already been added to the record, no changes are made.
867 * @param string $component the component that was tagged
868 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
869 * @param int $itemid the id of the record to tag
870 * @param context $context the context of where this tag was assigned
871 * @param string $tagname the tag to add
872 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
873 * @return int id of tag_instance that was either created or already existed or null if tagging is not enabled
875 public static function add_item_tag($component, $itemtype, $itemid, context $context, $tagname, $tiuserid = 0) {
876 global $DB;
878 if (static::is_enabled($component, $itemtype) === false) {
879 // Tagging area is properly defined but not enabled - do nothing.
880 return null;
883 $rawname = clean_param($tagname, PARAM_TAG);
884 $normalisedname = core_text::strtolower($rawname);
885 $tagcollid = core_tag_area::get_collection($component, $itemtype);
887 $usersql = $tiuserid ? " AND ti.tiuserid = :tiuserid " : "";
888 $sql = 'SELECT t.*, ti.id AS taginstanceid
889 FROM {tag} t
890 LEFT JOIN {tag_instance} ti ON ti.tagid = t.id AND ti.itemtype = :itemtype '.
891 $usersql .
892 'AND ti.itemid = :itemid AND ti.component = :component
893 WHERE t.name = :name AND t.tagcollid = :tagcollid';
894 $params = array('name' => $normalisedname, 'tagcollid' => $tagcollid, 'itemtype' => $itemtype,
895 'itemid' => $itemid, 'component' => $component, 'tiuserid' => $tiuserid);
896 $record = $DB->get_record_sql($sql, $params);
897 if ($record) {
898 if ($record->taginstanceid) {
899 // Tag was already added to the item, nothing to do here.
900 return $record->taginstanceid;
902 $tag = new static($record);
903 } else {
904 // The tag does not exist yet, create it.
905 $tags = static::add($tagcollid, array($tagname));
906 $tag = reset($tags);
909 $ordering = $DB->get_field_sql('SELECT MAX(ordering) FROM {tag_instance} ti
910 WHERE ti.itemtype = :itemtype AND ti.itemid = itemid AND
911 ti.component = :component' . $usersql, $params);
913 return $tag->add_instance($component, $itemtype, $itemid, $context, $ordering + 1, $tiuserid);
917 * Removes the tag from an item without changing the other tags
919 * @param string $component the component that was tagged
920 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
921 * @param int $itemid the id of the record to tag
922 * @param string $tagname the tag to remove
923 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
925 public static function remove_item_tag($component, $itemtype, $itemid, $tagname, $tiuserid = 0) {
926 global $DB;
928 if (static::is_enabled($component, $itemtype) === false) {
929 // Tagging area is properly defined but not enabled - do nothing.
930 return array();
933 $rawname = clean_param($tagname, PARAM_TAG);
934 $normalisedname = core_text::strtolower($rawname);
936 $usersql = $tiuserid ? " AND tiuserid = :tiuserid " : "";
937 $componentsql = $component ? " AND ti.component = :component " : "";
938 $sql = 'SELECT t.*, ti.id AS taginstanceid, ti.contextid AS taginstancecontextid, ti.ordering
939 FROM {tag} t JOIN {tag_instance} ti ON ti.tagid = t.id ' . $usersql . '
940 WHERE t.name = :name AND ti.itemtype = :itemtype
941 AND ti.itemid = :itemid ' . $componentsql;
942 $params = array('name' => $normalisedname,
943 'itemtype' => $itemtype, 'itemid' => $itemid, 'component' => $component,
944 'tiuserid' => $tiuserid);
945 if ($record = $DB->get_record_sql($sql, $params)) {
946 $taginstance = (object)array('id' => $record->taginstanceid,
947 'itemtype' => $itemtype, 'itemid' => $itemid,
948 'contextid' => $record->taginstancecontextid, 'tiuserid' => $tiuserid);
949 $tag = new static($record);
950 $tag->delete_instance_as_record($taginstance, false);
951 $componentsql = $component ? " AND component = :component " : "";
952 $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
953 WHERE itemtype = :itemtype
954 AND itemid = :itemid $componentsql $usersql
955 AND ordering > :ordering";
956 $params['ordering'] = $record->ordering;
957 $DB->execute($sql, $params);
962 * Allows to move all tag instances from one context to another
964 * @param string $component the component that was tagged
965 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
966 * @param context $oldcontext
967 * @param context $newcontext
969 public static function move_context($component, $itemtype, $oldcontext, $newcontext) {
970 global $DB;
971 if ($oldcontext instanceof context) {
972 $oldcontext = $oldcontext->id;
974 if ($newcontext instanceof context) {
975 $newcontext = $newcontext->id;
977 $DB->set_field('tag_instance', 'contextid', $newcontext,
978 array('component' => $component, 'itemtype' => $itemtype, 'contextid' => $oldcontext));
982 * Moves all tags of the specified items to the new context
984 * @param string $component the component that was tagged
985 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
986 * @param array $itemids
987 * @param context|int $newcontext target context to move tags to
989 public static function change_items_context($component, $itemtype, $itemids, $newcontext) {
990 global $DB;
991 if (empty($itemids)) {
992 return;
994 if (!is_array($itemids)) {
995 $itemids = array($itemids);
997 list($sql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
998 $params['component'] = $component;
999 $params['itemtype'] = $itemtype;
1000 if ($newcontext instanceof context) {
1001 $newcontext = $newcontext->id;
1004 $DB->set_field_select('tag_instance', 'contextid', $newcontext,
1005 'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params);
1009 * Moves all of the specified tag instances into a new context.
1011 * @param array $taginstanceids The list of tag instance ids that should be moved
1012 * @param context $newcontext The context to move the tag instances into
1014 public static function change_instances_context(array $taginstanceids, context $newcontext) {
1015 global $DB;
1017 if (empty($taginstanceids)) {
1018 return;
1021 list($sql, $params) = $DB->get_in_or_equal($taginstanceids);
1022 $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params);
1026 * Updates the information about the tag
1028 * @param array|stdClass $data data to update, may contain: isstandard, description, descriptionformat, rawname
1029 * @return bool whether the tag was updated. False may be returned if: all new values match the existing,
1030 * or it was attempted to rename the tag to the name that is already used.
1032 public function update($data) {
1033 global $DB, $COURSE;
1035 $allowedfields = array('isstandard', 'description', 'descriptionformat', 'rawname');
1037 $data = (array)$data;
1038 if ($extrafields = array_diff(array_keys($data), $allowedfields)) {
1039 debugging('The field(s) '.join(', ', $extrafields).' will be ignored when updating the tag',
1040 DEBUG_DEVELOPER);
1042 $data = array_intersect_key($data, array_fill_keys($allowedfields, 1));
1043 $this->ensure_fields_exist(array_merge(array('tagcollid', 'userid', 'name', 'rawname'), array_keys($data)), 'update');
1045 // Validate the tag name.
1046 if (array_key_exists('rawname', $data)) {
1047 $data['rawname'] = clean_param($data['rawname'], PARAM_TAG);
1048 $name = core_text::strtolower($data['rawname']);
1050 if (!$name || $data['rawname'] === $this->rawname) {
1051 unset($data['rawname']);
1052 } else if ($existing = static::get_by_name($this->tagcollid, $name, 'id')) {
1053 // Prevent the rename if a tag with that name already exists.
1054 if ($existing->id != $this->id) {
1055 throw new moodle_exception('namesalreadybeeingused', 'core_tag');
1058 if (isset($data['rawname'])) {
1059 $data['name'] = $name;
1063 // Validate the tag type.
1064 if (array_key_exists('isstandard', $data)) {
1065 $data['isstandard'] = $data['isstandard'] ? 1 : 0;
1068 // Find only the attributes that need to be changed.
1069 $originalname = $this->name;
1070 foreach ($data as $key => $value) {
1071 if ($this->record->$key !== $value) {
1072 $this->record->$key = $value;
1073 } else {
1074 unset($data[$key]);
1077 if (empty($data)) {
1078 return false;
1081 $data['id'] = $this->id;
1082 $data['timemodified'] = time();
1083 $DB->update_record('tag', $data);
1085 $event = \core\event\tag_updated::create(array(
1086 'objectid' => $this->id,
1087 'relateduserid' => $this->userid,
1088 'context' => context_system::instance(),
1089 'other' => array(
1090 'name' => $this->name,
1091 'rawname' => $this->rawname
1094 if (isset($data['rawname'])) {
1095 $event->set_legacy_logdata(array($COURSE->id, 'tag', 'update', 'index.php?id='. $this->id,
1096 $originalname . '->'. $this->name));
1098 $event->trigger();
1099 return true;
1103 * Flag a tag as inappropriate
1105 public function flag() {
1106 global $DB;
1108 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1110 // Update all the tags to flagged.
1111 $this->timemodified = time();
1112 $this->flag++;
1113 $DB->update_record('tag', array('timemodified' => $this->timemodified,
1114 'flag' => $this->flag, 'id' => $this->id));
1116 $event = \core\event\tag_flagged::create(array(
1117 'objectid' => $this->id,
1118 'relateduserid' => $this->userid,
1119 'context' => context_system::instance(),
1120 'other' => array(
1121 'name' => $this->name,
1122 'rawname' => $this->rawname
1126 $event->trigger();
1130 * Remove the inappropriate flag on a tag.
1132 public function reset_flag() {
1133 global $DB;
1135 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1137 if (!$this->flag) {
1138 // Nothing to do.
1139 return false;
1142 $this->timemodified = time();
1143 $this->flag = 0;
1144 $DB->update_record('tag', array('timemodified' => $this->timemodified,
1145 'flag' => 0, 'id' => $this->id));
1147 $event = \core\event\tag_unflagged::create(array(
1148 'objectid' => $this->id,
1149 'relateduserid' => $this->userid,
1150 'context' => context_system::instance(),
1151 'other' => array(
1152 'name' => $this->name,
1153 'rawname' => $this->rawname
1156 $event->trigger();
1160 * Sets the list of tags related to this one.
1162 * Tag relations are recorded by two instances linking two tags to each other.
1163 * For tag relations ordering is not used and may be random.
1165 * @param array $tagnames
1167 public function set_related_tags($tagnames) {
1168 $context = context_system::instance();
1169 $tagobjects = $tagnames ? static::create_if_missing($this->tagcollid, $tagnames) : array();
1170 unset($tagobjects[$this->name]); // Never link to itself.
1172 $currenttags = static::get_item_tags('core', 'tag', $this->id);
1174 // For data coherence reasons, it's better to remove deleted tags
1175 // before adding new data: ordering could be duplicated.
1176 foreach ($currenttags as $currenttag) {
1177 if (!array_key_exists($currenttag->name, $tagobjects)) {
1178 $taginstance = (object)array('id' => $currenttag->taginstanceid,
1179 'itemtype' => 'tag', 'itemid' => $this->id,
1180 'contextid' => $context->id);
1181 $currenttag->delete_instance_as_record($taginstance, false);
1182 $this->delete_instance('core', 'tag', $currenttag->id);
1186 foreach ($tagobjects as $name => $tag) {
1187 foreach ($currenttags as $currenttag) {
1188 if ($currenttag->name === $name) {
1189 continue 2;
1192 $this->add_instance('core', 'tag', $tag->id, $context, 0);
1193 $tag->add_instance('core', 'tag', $this->id, $context, 0);
1194 $currenttags[] = $tag;
1199 * Adds to the list of related tags without removing existing
1201 * Tag relations are recorded by two instances linking two tags to each other.
1202 * For tag relations ordering is not used and may be random.
1204 * @param array $tagnames
1206 public function add_related_tags($tagnames) {
1207 $context = context_system::instance();
1208 $tagobjects = static::create_if_missing($this->tagcollid, $tagnames);
1210 $currenttags = static::get_item_tags('core', 'tag', $this->id);
1212 foreach ($tagobjects as $name => $tag) {
1213 foreach ($currenttags as $currenttag) {
1214 if ($currenttag->name === $name) {
1215 continue 2;
1218 $this->add_instance('core', 'tag', $tag->id, $context, 0);
1219 $tag->add_instance('core', 'tag', $this->id, $context, 0);
1220 $currenttags[] = $tag;
1225 * Returns the correlated tags of a tag, retrieved from the tag_correlation table.
1227 * Correlated tags are calculated in cron based on existing tag instances.
1229 * @param bool $keepduplicates if true, will return one record for each existing
1230 * tag instance which may result in duplicates of the actual tags
1231 * @return core_tag_tag[] an array of tag objects
1233 public function get_correlated_tags($keepduplicates = false) {
1234 global $DB;
1236 $correlated = $DB->get_field('tag_correlation', 'correlatedtags', array('tagid' => $this->id));
1238 if (!$correlated) {
1239 return array();
1241 $correlated = preg_split('/\s*,\s*/', trim($correlated), -1, PREG_SPLIT_NO_EMPTY);
1242 list($query, $params) = $DB->get_in_or_equal($correlated);
1244 // This is (and has to) return the same fields as the query in core_tag_tag::get_item_tags().
1245 $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
1246 tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
1247 FROM {tag} tg
1248 INNER JOIN {tag_instance} ti ON tg.id = ti.tagid
1249 WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ?
1250 ORDER BY ti.ordering ASC, ti.id";
1251 $params[] = $this->id;
1252 $params[] = $this->tagcollid;
1253 $records = $DB->get_records_sql($sql, $params);
1254 $seen = array();
1255 $result = array();
1256 foreach ($records as $id => $record) {
1257 if (!$keepduplicates && !empty($seen[$record->id])) {
1258 continue;
1260 $result[$id] = new static($record);
1261 $seen[$record->id] = true;
1263 return $result;
1267 * Returns tags that this tag was manually set as related to
1269 * @return core_tag_tag[]
1271 public function get_manual_related_tags() {
1272 return self::get_item_tags('core', 'tag', $this->id);
1276 * Returns tags related to a tag
1278 * Related tags of a tag come from two sources:
1279 * - manually added related tags, which are tag_instance entries for that tag
1280 * - correlated tags, which are calculated
1282 * @return core_tag_tag[] an array of tag objects
1284 public function get_related_tags() {
1285 $manual = $this->get_manual_related_tags();
1286 $automatic = $this->get_correlated_tags();
1287 $relatedtags = array_merge($manual, $automatic);
1289 // Remove duplicated tags (multiple instances of the same tag).
1290 $seen = array();
1291 foreach ($relatedtags as $instance => $tag) {
1292 if (isset($seen[$tag->id])) {
1293 unset($relatedtags[$instance]);
1294 } else {
1295 $seen[$tag->id] = 1;
1299 return $relatedtags;
1303 * Find all items tagged with a tag of a given type ('post', 'user', etc.)
1305 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1306 * query will be slow because DB index will not be used.
1307 * @param string $itemtype type to restrict search to
1308 * @param int $limitfrom (optional, required if $limitnum is set) return a subset of records, starting at this point.
1309 * @param int $limitnum (optional, required if $limitfrom is set) return a subset comprising this many records.
1310 * @param string $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1311 * @param array $params additional parameters for the DB query
1312 * @return array of matching objects, indexed by record id, from the table containing the type requested
1314 public function get_tagged_items($component, $itemtype, $limitfrom = '', $limitnum = '', $subquery = '', $params = array()) {
1315 global $DB;
1317 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1318 return array();
1320 $params = $params ? $params : array();
1322 $query = "SELECT it.*
1323 FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1324 WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1325 $params['itemtype'] = $itemtype;
1326 $params['tagid'] = $this->id;
1327 if ($component) {
1328 $query .= ' AND tt.component = :component';
1329 $params['component'] = $component;
1331 if ($subquery) {
1332 $query .= ' AND ' . $subquery;
1334 $query .= ' ORDER BY it.id';
1336 return $DB->get_records_sql($query, $params, $limitfrom, $limitnum);
1340 * Count how many items are tagged with a specific tag.
1342 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1343 * query will be slow because DB index will not be used.
1344 * @param string $itemtype type to restrict search to
1345 * @param string $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1346 * @param array $params additional parameters for the DB query
1347 * @return int number of mathing tags.
1349 public function count_tagged_items($component, $itemtype, $subquery = '', $params = array()) {
1350 global $DB;
1352 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1353 return 0;
1355 $params = $params ? $params : array();
1357 $query = "SELECT COUNT(it.id)
1358 FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1359 WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1360 $params['itemtype'] = $itemtype;
1361 $params['tagid'] = $this->id;
1362 if ($component) {
1363 $query .= ' AND tt.component = :component';
1364 $params['component'] = $component;
1366 if ($subquery) {
1367 $query .= ' AND ' . $subquery;
1370 return $DB->get_field_sql($query, $params);
1374 * Determine if an item is tagged with a specific tag
1376 * Note that this is a static method and not a method of core_tag object because the tag might not exist yet,
1377 * for example user searches for "php" and we offer him to add "php" to his interests.
1379 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1380 * query will be slow because DB index will not be used.
1381 * @param string $itemtype the record type to look for
1382 * @param int $itemid the record id to look for
1383 * @param string $tagname a tag name
1384 * @return int 1 if it is tagged, 0 otherwise
1386 public static function is_item_tagged_with($component, $itemtype, $itemid, $tagname) {
1387 global $DB;
1388 $tagcollid = core_tag_area::get_collection($component, $itemtype);
1389 $query = 'SELECT 1 FROM {tag} t
1390 JOIN {tag_instance} ti ON ti.tagid = t.id
1391 WHERE t.name = ? AND t.tagcollid = ? AND ti.itemtype = ? AND ti.itemid = ?';
1392 $cleanname = core_text::strtolower(clean_param($tagname, PARAM_TAG));
1393 $params = array($cleanname, $tagcollid, $itemtype, $itemid);
1394 if ($component) {
1395 $query .= ' AND ti.component = ?';
1396 $params[] = $component;
1398 return $DB->record_exists_sql($query, $params) ? 1 : 0;
1402 * Returns whether the tag area is enabled
1404 * @param string $component component responsible for tagging
1405 * @param string $itemtype what is being tagged, for example, 'post', 'course', 'user', etc.
1406 * @return bool|null
1408 public static function is_enabled($component, $itemtype) {
1409 return core_tag_area::is_enabled($component, $itemtype);
1413 * Retrieves contents of tag area for the tag/index.php page
1415 * @param stdClass $tagarea
1416 * @param bool $exclusivemode if set to true it means that no other entities tagged with this tag
1417 * are displayed on the page and the per-page limit may be bigger
1418 * @param int $fromctx context id where the link was displayed, may be used by callbacks
1419 * to display items in the same context first
1420 * @param int $ctx context id where to search for records
1421 * @param bool $rec search in subcontexts as well
1422 * @param int $page 0-based number of page being displayed
1423 * @return \core_tag\output\tagindex
1425 public function get_tag_index($tagarea, $exclusivemode, $fromctx, $ctx, $rec, $page = 0) {
1426 global $CFG;
1427 if (!empty($tagarea->callback)) {
1428 if (!empty($tagarea->callbackfile)) {
1429 require_once($CFG->dirroot . '/' . ltrim($tagarea->callbackfile, '/'));
1431 $callback = $tagarea->callback;
1432 return call_user_func_array($callback, [$this, $exclusivemode, $fromctx, $ctx, $rec, $page]);
1434 return null;
1438 * Returns formatted description of the tag
1440 * @param array $options
1441 * @return string
1443 public function get_formatted_description($options = array()) {
1444 $options = empty($options) ? array() : (array)$options;
1445 $options += array('para' => false, 'overflowdiv' => true);
1446 $description = file_rewrite_pluginfile_urls($this->description, 'pluginfile.php',
1447 context_system::instance()->id, 'tag', 'description', $this->id);
1448 return format_text($description, $this->descriptionformat, $options);
1452 * Returns the list of tag links available for the current user (edit, flag, etc.)
1454 * @return array
1456 public function get_links() {
1457 global $USER;
1458 $links = array();
1460 if (!isloggedin() || isguestuser()) {
1461 return $links;
1464 $tagname = $this->get_display_name();
1465 $systemcontext = context_system::instance();
1467 // Add a link for users to add/remove this from their interests.
1468 if (static::is_enabled('core', 'user') && core_tag_area::get_collection('core', 'user') == $this->tagcollid) {
1469 if (static::is_item_tagged_with('core', 'user', $USER->id, $this->name)) {
1470 $url = new moodle_url('/tag/user.php', array('action' => 'removeinterest',
1471 'sesskey' => sesskey(), 'tag' => $this->rawname));
1472 $links[] = html_writer::link($url, get_string('removetagfrommyinterests', 'tag', $tagname),
1473 array('class' => 'removefrommyinterests'));
1474 } else {
1475 $url = new moodle_url('/tag/user.php', array('action' => 'addinterest',
1476 'sesskey' => sesskey(), 'tag' => $this->rawname));
1477 $links[] = html_writer::link($url, get_string('addtagtomyinterests', 'tag', $tagname),
1478 array('class' => 'addtomyinterests'));
1482 // Flag as inappropriate link. Only people with moodle/tag:flag capability.
1483 if (has_capability('moodle/tag:flag', $systemcontext)) {
1484 $url = new moodle_url('/tag/user.php', array('action' => 'flaginappropriate',
1485 'sesskey' => sesskey(), 'id' => $this->id));
1486 $links[] = html_writer::link($url, get_string('flagasinappropriate', 'tag', $tagname),
1487 array('class' => 'flagasinappropriate'));
1490 // Edit tag: Only people with moodle/tag:edit capability who either have it as an interest or can manage tags.
1491 if (has_capability('moodle/tag:edit', $systemcontext) ||
1492 has_capability('moodle/tag:manage', $systemcontext)) {
1493 $url = new moodle_url('/tag/edit.php', array('id' => $this->id));
1494 $links[] = html_writer::link($url, get_string('edittag', 'tag'),
1495 array('class' => 'edittag'));
1498 return $links;
1502 * Delete one or more tag, and all their instances if there are any left.
1504 * @param int|array $tagids one tagid (int), or one array of tagids to delete
1505 * @return bool true on success, false otherwise
1507 public static function delete_tags($tagids) {
1508 global $DB;
1510 if (!is_array($tagids)) {
1511 $tagids = array($tagids);
1513 if (empty($tagids)) {
1514 return;
1517 // Use the tagids to create a select statement to be used later.
1518 list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids);
1520 // Store the tags and tag instances we are going to delete.
1521 $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
1522 $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
1524 // Delete all the tag instances.
1525 $select = 'WHERE tagid ' . $tagsql;
1526 $sql = "DELETE FROM {tag_instance} $select";
1527 $DB->execute($sql, $tagparams);
1529 // Delete all the tag correlations.
1530 $sql = "DELETE FROM {tag_correlation} $select";
1531 $DB->execute($sql, $tagparams);
1533 // Delete all the tags.
1534 $select = 'WHERE id ' . $tagsql;
1535 $sql = "DELETE FROM {tag} $select";
1536 $DB->execute($sql, $tagparams);
1538 // Fire an event that these items were untagged.
1539 if ($taginstances) {
1540 // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
1541 $syscontextid = context_system::instance()->id;
1542 // Loop through the tag instances and fire a 'tag_removed'' event.
1543 foreach ($taginstances as $taginstance) {
1544 // We can not fire an event with 'null' as the contextid.
1545 if (is_null($taginstance->contextid)) {
1546 $taginstance->contextid = $syscontextid;
1549 // Trigger tag removed event.
1550 \core\event\tag_removed::create_from_tag_instance($taginstance,
1551 $tags[$taginstance->tagid]->name, $tags[$taginstance->tagid]->rawname,
1552 true)->trigger();
1556 // Fire an event that these tags were deleted.
1557 if ($tags) {
1558 $context = context_system::instance();
1559 foreach ($tags as $tag) {
1560 // Delete all files associated with this tag.
1561 $fs = get_file_storage();
1562 $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
1563 foreach ($files as $file) {
1564 $file->delete();
1567 // Trigger an event for deleting this tag.
1568 $event = \core\event\tag_deleted::create(array(
1569 'objectid' => $tag->id,
1570 'relateduserid' => $tag->userid,
1571 'context' => $context,
1572 'other' => array(
1573 'name' => $tag->name,
1574 'rawname' => $tag->rawname
1577 $event->add_record_snapshot('tag', $tag);
1578 $event->trigger();
1582 return true;
1586 * Combine together correlated tags of several tags
1588 * This is a help method for method combine_tags()
1590 * @param core_tag_tag[] $tags
1592 protected function combine_correlated_tags($tags) {
1593 global $DB;
1594 $ids = array_map(function($t) {
1595 return $t->id;
1596 }, $tags);
1598 // Retrieve the correlated tags of this tag and correlated tags of all tags to be merged in one query
1599 // but store them separately. Calculate the list of correlated tags that need to be added to the current.
1600 list($sql, $params) = $DB->get_in_or_equal($ids);
1601 $params[] = $this->id;
1602 $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql.' OR tagid = ?',
1603 $params, '', 'tagid, id, correlatedtags');
1604 $correlated = array();
1605 $mycorrelated = array();
1606 foreach ($records as $record) {
1607 $taglist = preg_split('/\s*,\s*/', trim($record->correlatedtags), -1, PREG_SPLIT_NO_EMPTY);
1608 if ($record->tagid == $this->id) {
1609 $mycorrelated = $taglist;
1610 } else {
1611 $correlated = array_merge($correlated, $taglist);
1614 array_unique($correlated);
1615 // Strip out from $correlated the ids of the tags that are already in $mycorrelated
1616 // or are one of the tags that are going to be combined.
1617 $correlated = array_diff($correlated, [$this->id], $ids, $mycorrelated);
1619 if (empty($correlated)) {
1620 // Nothing to do, ignore situation when current tag is correlated to one of the merged tags - they will
1621 // be deleted later and get_tag_correlation() will not return them. Next cron will clean everything up.
1622 return;
1625 // Update correlated tags of this tag.
1626 $newcorrelatedlist = join(',', array_merge($mycorrelated, $correlated));
1627 if (isset($records[$this->id])) {
1628 $DB->update_record('tag_correlation', array('id' => $records[$this->id]->id, 'correlatedtags' => $newcorrelatedlist));
1629 } else {
1630 $DB->insert_record('tag_correlation', array('tagid' => $this->id, 'correlatedtags' => $newcorrelatedlist));
1633 // Add this tag to the list of correlated tags of each tag in $correlated.
1634 list($sql, $params) = $DB->get_in_or_equal($correlated);
1635 $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql, $params, '', 'tagid, id, correlatedtags');
1636 foreach ($correlated as $tagid) {
1637 if (isset($records[$tagid])) {
1638 $newcorrelatedlist = $records[$tagid]->correlatedtags . ',' . $this->id;
1639 $DB->update_record('tag_correlation', array('id' => $records[$tagid]->id, 'correlatedtags' => $newcorrelatedlist));
1640 } else {
1641 $DB->insert_record('tag_correlation', array('tagid' => $tagid, 'correlatedtags' => '' . $this->id));
1647 * Combines several other tags into this one
1649 * Combining rules:
1650 * - current tag becomes the "main" one, all instances
1651 * pointing to other tags are changed to point to it.
1652 * - if any of the tags is standard, the "main" tag becomes standard too
1653 * - all tags except for the current ("main") are deleted, even when they are standard
1655 * @param core_tag_tag[] $tags tags to combine into this one
1657 public function combine_tags($tags) {
1658 global $DB;
1660 $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags');
1662 // Retrieve all tag objects, find if there are any standard tags in the set.
1663 $isstandard = false;
1664 $tagstocombine = array();
1665 $ids = array();
1666 $relatedtags = $this->get_manual_related_tags();
1667 foreach ($tags as $tag) {
1668 $tag->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'tagcollid', 'name', 'rawname'), 'combine_tags');
1669 if ($tag && $tag->id != $this->id && $tag->tagcollid == $this->tagcollid) {
1670 $isstandard = $isstandard || $tag->isstandard;
1671 $tagstocombine[$tag->name] = $tag;
1672 $ids[] = $tag->id;
1673 $relatedtags = array_merge($relatedtags, $tag->get_manual_related_tags());
1677 if (empty($tagstocombine)) {
1678 // Nothing to do.
1679 return;
1682 // Combine all manually set related tags, exclude itself all the tags it is about to be combined with.
1683 if ($relatedtags) {
1684 $relatedtags = array_map(function($t) {
1685 return $t->name;
1686 }, $relatedtags);
1687 array_unique($relatedtags);
1688 $relatedtags = array_diff($relatedtags, [$this->name], array_keys($tagstocombine));
1690 $this->set_related_tags($relatedtags);
1692 // Combine all correlated tags, exclude itself all the tags it is about to be combined with.
1693 $this->combine_correlated_tags($tagstocombine);
1695 // If any of the duplicate tags are standard, mark this one as standard too.
1696 if ($isstandard && !$this->isstandard) {
1697 $this->update(array('isstandard' => 1));
1700 // Go through all instances of each tag that needs to be combined and make them point to this tag instead.
1701 // We go though the list one by one because otherwise looking-for-duplicates logic would be too complicated.
1702 foreach ($tagstocombine as $tag) {
1703 $params = array('tagid' => $tag->id, 'mainid' => $this->id);
1704 $mainsql = 'SELECT ti.*, t.name, t.rawname, tim.id AS alreadyhasmaintag '
1705 . 'FROM {tag_instance} ti '
1706 . 'LEFT JOIN {tag} t ON t.id = ti.tagid '
1707 . 'LEFT JOIN {tag_instance} tim ON ti.component = tim.component AND '
1708 . ' ti.itemtype = tim.itemtype AND ti.itemid = tim.itemid AND '
1709 . ' ti.tiuserid = tim.tiuserid AND tim.tagid = :mainid '
1710 . 'WHERE ti.tagid = :tagid';
1712 $records = $DB->get_records_sql($mainsql, $params);
1713 foreach ($records as $record) {
1714 if ($record->alreadyhasmaintag) {
1715 // Item is tagged with both main tag and the duplicate tag.
1716 // Remove instance pointing to the duplicate tag.
1717 $tag->delete_instance_as_record($record, false);
1718 $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
1719 WHERE itemtype = :itemtype
1720 AND itemid = :itemid AND component = :component AND tiuserid = :tiuserid
1721 AND ordering > :ordering";
1722 $DB->execute($sql, (array)$record);
1723 } else {
1724 // Item is tagged only with duplicate tag but not the main tag.
1725 // Replace tagid in the instance pointing to the duplicate tag with this tag.
1726 $DB->update_record('tag_instance', array('id' => $record->id, 'tagid' => $this->id));
1727 \core\event\tag_removed::create_from_tag_instance($record, $record->name, $record->rawname)->trigger();
1728 $record->tagid = $this->id;
1729 \core\event\tag_added::create_from_tag_instance($record, $this->name, $this->rawname)->trigger();
1734 // Finally delete all tags that we combined into the current one.
1735 self::delete_tags($ids);
1739 * Retrieve a list of tags that have been used to tag the given $component
1740 * and $itemtype in the provided $contexts.
1742 * @param string $component The tag instance component
1743 * @param string $itemtype The tag instance item type
1744 * @param context[] $contexts The list of contexts to look for tag instances in
1745 * @return core_tag_tag[]
1747 public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) {
1748 global $DB;
1750 $params = [$component, $itemtype];
1751 $contextids = array_map(function($context) {
1752 return $context->id;
1753 }, $contexts);
1754 list($contextsql, $contextsqlparams) = $DB->get_in_or_equal($contextids);
1755 $params = array_merge($params, $contextsqlparams);
1757 $subsql = "SELECT DISTINCT t.id
1758 FROM {tag} t
1759 JOIN {tag_instance} ti ON t.id = ti.tagid
1760 WHERE component = ?
1761 AND itemtype = ?
1762 AND contextid {$contextsql}";
1764 $sql = "SELECT tt.*
1765 FROM ($subsql) tv
1766 JOIN {tag} tt ON tt.id = tv.id";
1768 return array_map(function($record) {
1769 return new core_tag_tag($record);
1770 }, $DB->get_records_sql($sql, $params));