weekly release 4.5dev
[moodle.git] / tag / classes / tag.php
blobc1d56bfc41e082589f1c995bbc9331a484b63696
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 /** @var int|null tag context ID. */
78 public $taginstancecontextid;
80 /** @var int|null time modification. */
81 public $timemodified;
83 /** @var int|null 0 if not flagged or positive integer if flagged. */
84 public $flag;
86 /**
87 * Constructor. Use functions get(), get_by_name(), etc.
89 * @param stdClass $record
91 protected function __construct($record) {
92 if (empty($record->id)) {
93 throw new coding_exception("Record must contain at least field 'id'");
95 // The following three variables must be added because the database ($record) does not contain them.
96 $this->taginstancecontextid = $record->taginstancecontextid ?? null;
97 $this->flag = $record->flag ?? null;
98 $this->record = $record;
102 * Magic getter
104 * @param string $name
105 * @return mixed
107 public function __get($name) {
108 return $this->record->$name;
112 * Magic isset method
114 * @param string $name
115 * @return bool
117 public function __isset($name) {
118 return isset($this->record->$name);
122 * Converts to object
124 * @return stdClass
126 public function to_object() {
127 return fullclone($this->record);
131 * Returns tag name ready to be displayed
133 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
134 * @return string
136 public function get_display_name($ashtml = true) {
137 return static::make_display_name($this->record, $ashtml);
141 * Prepares tag name ready to be displayed
143 * @param stdClass|core_tag_tag $tag record from db table tag, must contain properties name and rawname
144 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
145 * @return string
147 public static function make_display_name($tag, $ashtml = true) {
148 global $CFG;
150 if (empty($CFG->keeptagnamecase)) {
151 // This is the normalized tag name.
152 $tagname = core_text::strtotitle($tag->name);
153 } else {
154 // Original casing of the tag name.
155 $tagname = $tag->rawname;
158 // Clean up a bit just in case the rules change again.
159 $tagname = clean_param($tagname, PARAM_TAG);
161 return $ashtml ? htmlspecialchars($tagname, ENT_COMPAT) : $tagname;
165 * Adds one or more tag in the database. This function should not be called directly : you should
166 * use tag_set.
168 * @param int $tagcollid
169 * @param string|array $tags one tag, or an array of tags, to be created
170 * @param bool $isstandard type of tag to be created. A standard tag is kept even if there are no records tagged with it.
171 * @return array tag objects indexed by their lowercase normalized names. Any boolean false in the array
172 * indicates an error while adding the tag.
174 protected static function add($tagcollid, $tags, $isstandard = false) {
175 global $USER, $DB;
177 $tagobject = new stdClass();
178 $tagobject->isstandard = $isstandard ? 1 : 0;
179 $tagobject->userid = $USER->id;
180 $tagobject->timemodified = time();
181 $tagobject->tagcollid = $tagcollid;
183 $rv = array();
184 foreach ($tags as $veryrawname) {
185 $rawname = clean_param($veryrawname, PARAM_TAG);
186 if (!$rawname) {
187 $rv[$rawname] = false;
188 } else {
189 $obj = (object)(array)$tagobject;
190 $obj->rawname = $rawname;
191 $obj->name = core_text::strtolower($rawname);
192 $obj->id = $DB->insert_record('tag', $obj);
193 $rv[$obj->name] = new static($obj);
195 \core\event\tag_created::create_from_tag($rv[$obj->name])->trigger();
199 return $rv;
203 * Simple function to just return a single tag object by its id
205 * @param int $id
206 * @param string $returnfields which fields do we want returned from table {tag}.
207 * Default value is 'id,name,rawname,tagcollid',
208 * specify '*' to include all fields.
209 * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
210 * IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
211 * MUST_EXIST means throw exception if no record or multiple records found
212 * @return core_tag_tag|false tag object
214 public static function get($id, $returnfields = 'id, name, rawname, tagcollid', $strictness = IGNORE_MISSING) {
215 global $DB;
216 $record = $DB->get_record('tag', array('id' => $id), $returnfields, $strictness);
217 if ($record) {
218 return new static($record);
220 return false;
224 * Simple function to just return an array of tag objects by their ids
226 * @param int[] $ids
227 * @param string $returnfields which fields do we want returned from table {tag}.
228 * Default value is 'id,name,rawname,tagcollid',
229 * specify '*' to include all fields.
230 * @return core_tag_tag[] array of retrieved tags
232 public static function get_bulk($ids, $returnfields = 'id, name, rawname, tagcollid') {
233 global $DB;
234 $result = array();
235 if (empty($ids)) {
236 return $result;
238 list($sql, $params) = $DB->get_in_or_equal($ids);
239 $records = $DB->get_records_select('tag', 'id '.$sql, $params, '', $returnfields);
240 foreach ($records as $record) {
241 $result[$record->id] = new static($record);
243 return $result;
247 * Simple function to just return a single tag object by tagcollid and name
249 * @param int $tagcollid tag collection to use,
250 * if 0 is given we will try to guess the tag collection and return the first match
251 * @param string $name tag name
252 * @param string $returnfields which fields do we want returned. This is a comma separated string
253 * containing any combination of 'id', 'name', 'rawname', 'tagcollid' or '*' to include all fields.
254 * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
255 * IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
256 * MUST_EXIST means throw exception if no record or multiple records found
257 * @return core_tag_tag|false tag object
259 public static function get_by_name($tagcollid, $name, $returnfields='id, name, rawname, tagcollid',
260 $strictness = IGNORE_MISSING) {
261 global $DB;
262 if ($tagcollid == 0) {
263 $tags = static::guess_by_name($name, $returnfields);
264 if ($tags) {
265 $tag = reset($tags);
266 return $tag;
267 } else if ($strictness == MUST_EXIST) {
268 throw new dml_missing_record_exception('tag', 'name=?', array($name));
270 return false;
272 $name = core_text::strtolower($name); // To cope with input that might just be wrong case.
273 $params = array('name' => $name, 'tagcollid' => $tagcollid);
274 $record = $DB->get_record('tag', $params, $returnfields, $strictness);
275 if ($record) {
276 return new static($record);
278 return false;
282 * Looking in all tag collections for the tag with the given name
284 * @param string $name tag name
285 * @param string $returnfields
286 * @return array array of core_tag_tag instances
288 public static function guess_by_name($name, $returnfields='id, name, rawname, tagcollid') {
289 global $DB;
290 if (empty($name)) {
291 return array();
293 $tagcolls = core_tag_collection::get_collections();
294 list($sql, $params) = $DB->get_in_or_equal(array_keys($tagcolls), SQL_PARAMS_NAMED);
295 $params['name'] = core_text::strtolower($name);
296 $tags = $DB->get_records_select('tag', 'name = :name AND tagcollid ' . $sql, $params, '', $returnfields);
297 if (count($tags) > 1) {
298 // Sort in the same order as tag collections.
299 $tagcolls = core_tag_collection::get_collections();
300 uasort($tags, function($a, $b) use ($tagcolls) {
301 return $tagcolls[$a->tagcollid]->sortorder < $tagcolls[$b->tagcollid]->sortorder ? -1 : 1;
304 $rv = array();
305 foreach ($tags as $id => $tag) {
306 $rv[$id] = new static($tag);
308 return $rv;
312 * Returns the list of tag objects by tag collection id and the list of tag names
314 * @param int $tagcollid
315 * @param array $tags array of tags to look for
316 * @param string $returnfields list of DB fields to return, must contain 'id', 'name' and 'rawname'
317 * @return array tag-indexed array of objects. No value for a key means the tag wasn't found.
319 public static function get_by_name_bulk($tagcollid, $tags, $returnfields = 'id, name, rawname, tagcollid') {
320 global $DB;
322 if (empty($tags)) {
323 return array();
326 $cleantags = self::normalize(self::normalize($tags, false)); // Format: rawname => normalised name.
328 list($namesql, $params) = $DB->get_in_or_equal(array_values($cleantags));
329 array_unshift($params, $tagcollid);
331 $recordset = $DB->get_recordset_sql("SELECT $returnfields FROM {tag} WHERE tagcollid = ? AND name $namesql", $params);
333 $result = array_fill_keys($cleantags, null);
334 foreach ($recordset as $record) {
335 $result[$record->name] = new static($record);
337 $recordset->close();
338 return $result;
343 * Function that normalizes a list of tag names.
345 * @param array $rawtags array of tags
346 * @param bool $tolowercase convert to lower case?
347 * @return array lowercased normalized tags, indexed by the normalized tag, in the same order as the original array.
348 * (Eg: 'Banana' => 'banana').
350 public static function normalize($rawtags, $tolowercase = true) {
351 $result = array();
352 foreach ($rawtags as $rawtag) {
353 $rawtag = trim($rawtag);
354 if (strval($rawtag) !== '') {
355 $clean = clean_param($rawtag, PARAM_TAG);
356 if ($tolowercase) {
357 $result[$rawtag] = core_text::strtolower($clean);
358 } else {
359 $result[$rawtag] = $clean;
363 return $result;
367 * Retrieves tags and/or creates them if do not exist yet
369 * @param int $tagcollid
370 * @param array $tags array of raw tag names, do not have to be normalised
371 * @param bool $isstandard create as standard tag (default false)
372 * @return core_tag_tag[] array of tag objects indexed with lowercase normalised tag name
374 public static function create_if_missing($tagcollid, $tags, $isstandard = false) {
375 $cleantags = self::normalize(array_filter(self::normalize($tags, false))); // Array rawname => normalised name .
377 $result = static::get_by_name_bulk($tagcollid, $tags, '*');
378 $existing = array_filter($result);
379 $missing = array_diff_key(array_flip($cleantags), $existing); // Array normalised name => rawname.
380 if ($missing) {
381 $newtags = static::add($tagcollid, array_values($missing), $isstandard);
382 foreach ($newtags as $tag) {
383 $result[$tag->name] = $tag;
386 return $result;
390 * Creates a URL to view a tag
392 * @param int $tagcollid
393 * @param string $name
394 * @param int $exclusivemode
395 * @param int $fromctx context id where this tag cloud is displayed
396 * @param int $ctx context id for tag view link
397 * @param int $rec recursive argument for tag view link
398 * @return \moodle_url
400 public static function make_url($tagcollid, $name, $exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
401 $coll = core_tag_collection::get_by_id($tagcollid);
402 if (!empty($coll->customurl)) {
403 $url = '/' . ltrim(trim($coll->customurl), '/');
404 } else {
405 $url = '/tag/index.php';
407 $params = array('tc' => $tagcollid, 'tag' => $name);
408 if ($exclusivemode) {
409 $params['excl'] = 1;
411 if ($fromctx) {
412 $params['from'] = $fromctx;
414 if ($ctx) {
415 $params['ctx'] = $ctx;
417 if (!$rec) {
418 $params['rec'] = 0;
420 return new moodle_url($url, $params);
424 * Returns URL to view the tag
426 * @param int $exclusivemode
427 * @param int $fromctx context id where this tag cloud is displayed
428 * @param int $ctx context id for tag view link
429 * @param int $rec recursive argument for tag view link
430 * @return \moodle_url
432 public function get_view_url($exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
433 return static::make_url($this->record->tagcollid, $this->record->rawname,
434 $exclusivemode, $fromctx, $ctx, $rec);
438 * Validates that the required fields were retrieved and retrieves them if missing
440 * @param array $list array of the fields that need to be validated
441 * @param string $caller name of the function that requested it, for the debugging message
443 protected function ensure_fields_exist($list, $caller) {
444 global $DB;
445 $missing = array_diff($list, array_keys((array)$this->record));
446 if ($missing) {
447 debugging('core_tag_tag::' . $caller . '() must be called on fully retrieved tag object. Missing fields: '.
448 join(', ', $missing), DEBUG_DEVELOPER);
449 $this->record = $DB->get_record('tag', array('id' => $this->record->id), '*', MUST_EXIST);
454 * Deletes the tag instance given the record from tag_instance DB table
456 * @param stdClass $taginstance
457 * @param bool $fullobject whether $taginstance contains all fields from DB table tag_instance
458 * (in this case it is safe to add a record snapshot to the event)
459 * @return bool
461 protected function delete_instance_as_record($taginstance, $fullobject = false) {
462 global $DB;
464 $this->ensure_fields_exist(array('name', 'rawname', 'isstandard'), 'delete_instance_as_record');
466 $DB->delete_records('tag_instance', array('id' => $taginstance->id));
468 // We can not fire an event with 'null' as the contextid.
469 if (is_null($taginstance->contextid)) {
470 $taginstance->contextid = context_system::instance()->id;
473 // Trigger tag removed event.
474 $taginstance->tagid = $this->id;
475 \core\event\tag_removed::create_from_tag_instance($taginstance, $this->name, $this->rawname, $fullobject)->trigger();
477 // If there are no other instances of the tag then consider deleting the tag as well.
478 if (!$this->isstandard) {
479 if (!$DB->record_exists('tag_instance', array('tagid' => $this->id))) {
480 self::delete_tags($this->id);
484 return true;
488 * Delete one instance of a tag. If the last instance was deleted, it will also delete the tag, unless it is standard.
490 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
491 * query will be slow because DB index will not be used.
492 * @param string $itemtype the type of the record for which to remove the instance
493 * @param int $itemid the id of the record for which to remove the instance
494 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
496 protected function delete_instance($component, $itemtype, $itemid, $tiuserid = 0) {
497 global $DB;
498 $params = array('tagid' => $this->id,
499 'itemtype' => $itemtype, 'itemid' => $itemid);
500 if ($tiuserid) {
501 $params['tiuserid'] = $tiuserid;
503 if ($component) {
504 $params['component'] = $component;
507 $taginstance = $DB->get_record('tag_instance', $params);
508 if (!$taginstance) {
509 return;
511 $this->delete_instance_as_record($taginstance, true);
515 * Bulk delete all tag instances.
517 * @param stdClass[] $taginstances A list of tag_instance records to delete. Each
518 * record must also contain the name and rawname
519 * columns from the related tag record.
521 public static function delete_instances_as_record(array $taginstances) {
522 global $DB;
524 if (empty($taginstances)) {
525 return;
528 $taginstanceids = array_map(function($taginstance) {
529 return $taginstance->id;
530 }, $taginstances);
531 // Now remove all the tag instances.
532 $DB->delete_records_list('tag_instance', 'id', $taginstanceids);
533 // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
534 $syscontextid = context_system::instance()->id;
535 // Loop through the tag instances and fire an 'tag_removed' event.
536 foreach ($taginstances as $taginstance) {
537 // We can not fire an event with 'null' as the contextid.
538 if (is_null($taginstance->contextid)) {
539 $taginstance->contextid = $syscontextid;
542 // Trigger tag removed event.
543 \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name,
544 $taginstance->rawname, true)->trigger();
549 * Bulk delete all tag instances by tag id.
551 * @param int[] $taginstanceids List of tag instance ids to be deleted.
553 public static function delete_instances_by_id(array $taginstanceids) {
554 global $DB;
556 if (empty($taginstanceids)) {
557 return;
560 list($idsql, $params) = $DB->get_in_or_equal($taginstanceids);
561 $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
562 FROM {tag_instance} ti
563 JOIN {tag} t
564 ON ti.tagid = t.id
565 WHERE ti.id {$idsql}";
567 if ($taginstances = $DB->get_records_sql($sql, $params)) {
568 static::delete_instances_as_record($taginstances);
573 * Bulk delete all tag instances for a component or tag area
575 * @param string $component
576 * @param string $itemtype (optional)
577 * @param int $contextid (optional)
579 public static function delete_instances($component, $itemtype = null, $contextid = null) {
580 global $DB;
582 $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
583 FROM {tag_instance} ti
584 JOIN {tag} t
585 ON ti.tagid = t.id
586 WHERE ti.component = :component";
587 $params = array('component' => $component);
588 if (!is_null($contextid)) {
589 $sql .= " AND ti.contextid = :contextid";
590 $params['contextid'] = $contextid;
592 if (!is_null($itemtype)) {
593 $sql .= " AND ti.itemtype = :itemtype";
594 $params['itemtype'] = $itemtype;
597 if ($taginstances = $DB->get_records_sql($sql, $params)) {
598 static::delete_instances_as_record($taginstances);
603 * Adds a tag instance
605 * @param string $component
606 * @param string $itemtype
607 * @param string $itemid
608 * @param context $context
609 * @param int $ordering
610 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
611 * @return int id of tag_instance
613 protected function add_instance($component, $itemtype, $itemid, context $context, $ordering, $tiuserid = 0) {
614 global $DB;
615 $this->ensure_fields_exist(array('name', 'rawname'), 'add_instance');
617 $taginstance = new stdClass;
618 $taginstance->tagid = $this->id;
619 $taginstance->component = $component ? $component : '';
620 $taginstance->itemid = $itemid;
621 $taginstance->itemtype = $itemtype;
622 $taginstance->contextid = $context->id;
623 $taginstance->ordering = $ordering;
624 $taginstance->timecreated = time();
625 $taginstance->timemodified = $taginstance->timecreated;
626 $taginstance->tiuserid = $tiuserid;
628 $taginstance->id = $DB->insert_record('tag_instance', $taginstance);
630 // Trigger tag added event.
631 \core\event\tag_added::create_from_tag_instance($taginstance, $this->name, $this->rawname, true)->trigger();
633 return $taginstance->id;
637 * Updates the ordering on tag instance
639 * @param int $instanceid
640 * @param int $ordering
642 protected function update_instance_ordering($instanceid, $ordering) {
643 global $DB;
644 $data = new stdClass();
645 $data->id = $instanceid;
646 $data->ordering = $ordering;
647 $data->timemodified = time();
649 $DB->update_record('tag_instance', $data);
653 * Get the array of core_tag_tag objects associated with a list of items.
655 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
657 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
658 * query will be slow because DB index will not be used.
659 * @param string $itemtype type of the tagged item
660 * @param int[] $itemids
661 * @param int $standardonly wether to return only standard tags or any
662 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
663 * @return core_tag_tag[][] first array key is itemid. For each itemid,
664 * an array tagid => tag object with additional fields taginstanceid, taginstancecontextid and ordering
666 public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT,
667 $tiuserid = 0) {
668 global $DB;
670 if (static::is_enabled($component, $itemtype) === false) {
671 // Tagging area is properly defined but not enabled - return empty array.
672 return array();
675 if (empty($itemids)) {
676 return array();
679 $standardonly = (int)$standardonly; // In case somebody passed bool.
681 list($idsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
682 // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags().
683 $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
684 tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
685 FROM {tag_instance} ti
686 JOIN {tag} tg ON tg.id = ti.tagid
687 WHERE ti.itemtype = :itemtype AND ti.itemid $idsql ".
688 ($component ? "AND ti.component = :component " : "").
689 ($tiuserid ? "AND ti.tiuserid = :tiuserid " : "").
690 (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : "").
691 (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : "").
692 "ORDER BY ti.ordering ASC, ti.id";
694 $params['itemtype'] = $itemtype;
695 $params['component'] = $component;
696 $params['tiuserid'] = $tiuserid;
698 $records = $DB->get_records_sql($sql, $params);
699 $result = array();
700 foreach ($itemids as $itemid) {
701 $result[$itemid] = [];
703 foreach ($records as $id => $record) {
704 $result[$record->itemid][$id] = new static($record);
706 return $result;
710 * Get the array of core_tag_tag objects associated with an item (instances).
712 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
714 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
715 * query will be slow because DB index will not be used.
716 * @param string $itemtype type of the tagged item
717 * @param int $itemid
718 * @param int $standardonly wether to return only standard tags or any
719 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
720 * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
722 public static function get_item_tags($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
723 $tiuserid = 0) {
724 $tagobjects = static::get_items_tags($component, $itemtype, [$itemid], $standardonly, $tiuserid);
725 return empty($tagobjects) ? [] : $tagobjects[$itemid];
729 * Returns the list of display names of the tags that are associated with an item
731 * This method is usually used to prefill the form data for the 'tags' form element
733 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
734 * query will be slow because DB index will not be used.
735 * @param string $itemtype type of the tagged item
736 * @param int $itemid
737 * @param int $standardonly wether to return only standard tags or any
738 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
739 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded tag names
740 * @return string[] array of tags display names
742 public static function get_item_tags_array($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
743 $tiuserid = 0, $ashtml = true) {
744 $tags = array();
745 foreach (static::get_item_tags($component, $itemtype, $itemid, $standardonly, $tiuserid) as $tag) {
746 $tags[$tag->id] = $tag->get_display_name($ashtml);
748 return $tags;
752 * Sets the list of tag instances for one item (table record).
754 * Extra exsisting instances are removed, new ones are added. New tags are created if needed.
756 * This method can not be used for setting tags relations, please use set_related_tags()
758 * @param string $component component responsible for tagging
759 * @param string $itemtype type of the tagged item
760 * @param int $itemid
761 * @param context $context
762 * @param array $tagnames
763 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
765 public static function set_item_tags($component, $itemtype, $itemid, context $context, $tagnames, $tiuserid = 0) {
766 if ($itemtype === 'tag') {
767 if ($tiuserid) {
768 throw new coding_exception('Related tags can not have tag instance userid');
770 debugging('You can not use set_item_tags() for tagging a tag, please use set_related_tags()', DEBUG_DEVELOPER);
771 static::get($itemid, '*', MUST_EXIST)->set_related_tags($tagnames);
772 return;
775 if ($tagnames !== null && static::is_enabled($component, $itemtype) === false) {
776 // Tagging area is properly defined but not enabled - do nothing.
777 // Unless we are deleting the item tags ($tagnames === null), in which case proceed with deleting.
778 return;
781 // Apply clean_param() to all tags.
782 if ($tagnames) {
783 $tagcollid = core_tag_area::get_collection($component, $itemtype);
784 $tagobjects = static::create_if_missing($tagcollid, $tagnames);
785 } else {
786 $tagobjects = array();
789 $allowmultiplecontexts = core_tag_area::allows_tagging_in_multiple_contexts($component, $itemtype);
790 $currenttags = static::get_item_tags($component, $itemtype, $itemid, self::BOTH_STANDARD_AND_NOT, $tiuserid);
791 $taginstanceidstomovecontext = [];
793 // For data coherence reasons, it's better to remove deleted tags
794 // before adding new data: ordering could be duplicated.
795 foreach ($currenttags as $currenttag) {
796 $hasbeenrequested = array_key_exists($currenttag->name, $tagobjects);
797 $issamecontext = $currenttag->taginstancecontextid == $context->id;
799 if ($allowmultiplecontexts) {
800 // If the tag area allows multiple contexts then we should only be
801 // managing tags in the given $context. All other tags can be ignored.
802 $shoulddelete = $issamecontext && !$hasbeenrequested;
803 } else {
804 // If the tag area only allows tag instances in a single context then
805 // all tags that aren't in the requested tags should be deleted, regardless
806 // of their context, if they are not part of the new set of tags.
807 $shoulddelete = !$hasbeenrequested;
808 // If the tag instance isn't in the correct context (legacy data)
809 // then we should take this opportunity to update it with the correct
810 // context id.
811 if (!$shoulddelete && !$issamecontext) {
812 $currenttag->taginstancecontextid = $context->id;
813 $taginstanceidstomovecontext[] = $currenttag->taginstanceid;
817 if ($shoulddelete) {
818 $taginstance = (object)array('id' => $currenttag->taginstanceid,
819 'itemtype' => $itemtype, 'itemid' => $itemid,
820 'contextid' => $currenttag->taginstancecontextid, 'tiuserid' => $tiuserid);
821 $currenttag->delete_instance_as_record($taginstance, false);
825 if (!empty($taginstanceidstomovecontext)) {
826 static::change_instances_context($taginstanceidstomovecontext, $context);
829 $ordering = -1;
830 foreach ($tagobjects as $name => $tag) {
831 $ordering++;
832 foreach ($currenttags as $currenttag) {
833 $namesmatch = strval($currenttag->name) === strval($name);
835 if ($allowmultiplecontexts) {
836 // If the tag area allows multiple contexts then we should only
837 // skip adding a new instance if the existing one is in the correct
838 // context.
839 $contextsmatch = $currenttag->taginstancecontextid == $context->id;
840 $shouldskipinstance = $namesmatch && $contextsmatch;
841 } else {
842 // The existing behaviour for single context tag areas is to
843 // skip adding a new instance regardless of whether the existing
844 // instance is in the same context as the provided $context.
845 $shouldskipinstance = $namesmatch;
848 if ($shouldskipinstance) {
849 if ($currenttag->ordering != $ordering) {
850 $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering);
852 continue 2;
855 $tag->add_instance($component, $itemtype, $itemid, $context, $ordering, $tiuserid);
860 * Removes all tags from an item.
862 * All tags will be removed even if tagging is disabled in this area. This is
863 * usually called when the item itself has been deleted.
865 * @param string $component component responsible for tagging
866 * @param string $itemtype type of the tagged item
867 * @param int $itemid
868 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
870 public static function remove_all_item_tags($component, $itemtype, $itemid, $tiuserid = 0) {
871 $context = context_system::instance(); // Context will not be used.
872 static::set_item_tags($component, $itemtype, $itemid, $context, null, $tiuserid);
876 * Adds a tag to an item, without overwriting the current tags.
878 * If the tag has already been added to the record, no changes are made.
880 * @param string $component the component that was tagged
881 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
882 * @param int $itemid the id of the record to tag
883 * @param context $context the context of where this tag was assigned
884 * @param string $tagname the tag to add
885 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
886 * @return int id of tag_instance that was either created or already existed or null if tagging is not enabled
888 public static function add_item_tag($component, $itemtype, $itemid, context $context, $tagname, $tiuserid = 0) {
889 global $DB;
891 if (static::is_enabled($component, $itemtype) === false) {
892 // Tagging area is properly defined but not enabled - do nothing.
893 return null;
896 $rawname = clean_param($tagname, PARAM_TAG);
897 $normalisedname = core_text::strtolower($rawname);
898 $tagcollid = core_tag_area::get_collection($component, $itemtype);
900 $usersql = $tiuserid ? " AND ti.tiuserid = :tiuserid " : "";
901 $sql = 'SELECT t.*, ti.id AS taginstanceid
902 FROM {tag} t
903 LEFT JOIN {tag_instance} ti ON ti.tagid = t.id AND ti.itemtype = :itemtype '.
904 $usersql .
905 'AND ti.itemid = :itemid AND ti.component = :component
906 WHERE t.name = :name AND t.tagcollid = :tagcollid';
907 $params = array('name' => $normalisedname, 'tagcollid' => $tagcollid, 'itemtype' => $itemtype,
908 'itemid' => $itemid, 'component' => $component, 'tiuserid' => $tiuserid);
909 $record = $DB->get_record_sql($sql, $params);
910 if ($record) {
911 if ($record->taginstanceid) {
912 // Tag was already added to the item, nothing to do here.
913 return $record->taginstanceid;
915 $tag = new static($record);
916 } else {
917 // The tag does not exist yet, create it.
918 $tags = static::add($tagcollid, array($tagname));
919 $tag = reset($tags);
922 $ordering = $DB->get_field_sql('SELECT MAX(ordering) FROM {tag_instance} ti
923 WHERE ti.itemtype = :itemtype AND ti.itemid = :itemid AND
924 ti.component = :component' . $usersql, $params);
926 return $tag->add_instance($component, $itemtype, $itemid, $context, $ordering + 1, $tiuserid);
930 * Removes the tag from an item without changing the other tags
932 * @param string $component the component that was tagged
933 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
934 * @param int $itemid the id of the record to tag
935 * @param string $tagname the tag to remove
936 * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
938 public static function remove_item_tag($component, $itemtype, $itemid, $tagname, $tiuserid = 0) {
939 global $DB;
941 if (static::is_enabled($component, $itemtype) === false) {
942 // Tagging area is properly defined but not enabled - do nothing.
943 return array();
946 $rawname = clean_param($tagname, PARAM_TAG);
947 $normalisedname = core_text::strtolower($rawname);
949 $usersql = $tiuserid ? " AND tiuserid = :tiuserid " : "";
950 $componentsql = $component ? " AND ti.component = :component " : "";
951 $sql = 'SELECT t.*, ti.id AS taginstanceid, ti.contextid AS taginstancecontextid, ti.ordering
952 FROM {tag} t JOIN {tag_instance} ti ON ti.tagid = t.id ' . $usersql . '
953 WHERE t.name = :name AND ti.itemtype = :itemtype
954 AND ti.itemid = :itemid ' . $componentsql;
955 $params = array('name' => $normalisedname,
956 'itemtype' => $itemtype, 'itemid' => $itemid, 'component' => $component,
957 'tiuserid' => $tiuserid);
958 if ($record = $DB->get_record_sql($sql, $params)) {
959 $taginstance = (object)array('id' => $record->taginstanceid,
960 'itemtype' => $itemtype, 'itemid' => $itemid,
961 'contextid' => $record->taginstancecontextid, 'tiuserid' => $tiuserid);
962 $tag = new static($record);
963 $tag->delete_instance_as_record($taginstance, false);
964 $componentsql = $component ? " AND component = :component " : "";
965 $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
966 WHERE itemtype = :itemtype
967 AND itemid = :itemid $componentsql $usersql
968 AND ordering > :ordering";
969 $params['ordering'] = $record->ordering;
970 $DB->execute($sql, $params);
975 * Allows to move all tag instances from one context to another
977 * @param string $component the component that was tagged
978 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
979 * @param context $oldcontext
980 * @param context $newcontext
982 public static function move_context($component, $itemtype, $oldcontext, $newcontext) {
983 global $DB;
984 if ($oldcontext instanceof context) {
985 $oldcontext = $oldcontext->id;
987 if ($newcontext instanceof context) {
988 $newcontext = $newcontext->id;
990 $DB->set_field('tag_instance', 'contextid', $newcontext,
991 array('component' => $component, 'itemtype' => $itemtype, 'contextid' => $oldcontext));
995 * Moves all tags of the specified items to the new context
997 * @param string $component the component that was tagged
998 * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
999 * @param array $itemids
1000 * @param context|int $newcontext target context to move tags to
1002 public static function change_items_context($component, $itemtype, $itemids, $newcontext) {
1003 global $DB;
1004 if (empty($itemids)) {
1005 return;
1007 if (!is_array($itemids)) {
1008 $itemids = array($itemids);
1010 list($sql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
1011 $params['component'] = $component;
1012 $params['itemtype'] = $itemtype;
1013 if ($newcontext instanceof context) {
1014 $newcontext = $newcontext->id;
1017 $DB->set_field_select('tag_instance', 'contextid', $newcontext,
1018 'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params);
1022 * Moves all of the specified tag instances into a new context.
1024 * @param array $taginstanceids The list of tag instance ids that should be moved
1025 * @param context $newcontext The context to move the tag instances into
1027 public static function change_instances_context(array $taginstanceids, context $newcontext) {
1028 global $DB;
1030 if (empty($taginstanceids)) {
1031 return;
1034 list($sql, $params) = $DB->get_in_or_equal($taginstanceids);
1035 $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params);
1039 * Updates the information about the tag
1041 * @param array|stdClass $data data to update, may contain: isstandard, description, descriptionformat, rawname
1042 * @return bool whether the tag was updated. False may be returned if: all new values match the existing,
1043 * or it was attempted to rename the tag to the name that is already used.
1045 public function update($data) {
1046 global $DB, $COURSE;
1048 $allowedfields = array('isstandard', 'description', 'descriptionformat', 'rawname');
1050 $data = (array)$data;
1051 if ($extrafields = array_diff(array_keys($data), $allowedfields)) {
1052 debugging('The field(s) '.join(', ', $extrafields).' will be ignored when updating the tag',
1053 DEBUG_DEVELOPER);
1055 $data = array_intersect_key($data, array_fill_keys($allowedfields, 1));
1056 $this->ensure_fields_exist(array_merge(array('tagcollid', 'userid', 'name', 'rawname'), array_keys($data)), 'update');
1058 // Validate the tag name.
1059 if (array_key_exists('rawname', $data)) {
1060 $data['rawname'] = clean_param($data['rawname'], PARAM_TAG);
1061 $name = core_text::strtolower($data['rawname']);
1063 if (!$name || $data['rawname'] === $this->rawname) {
1064 unset($data['rawname']);
1065 } else if ($existing = static::get_by_name($this->tagcollid, $name, 'id')) {
1066 // Prevent the rename if a tag with that name already exists.
1067 if ($existing->id != $this->id) {
1068 throw new moodle_exception('namesalreadybeeingused', 'core_tag');
1071 if (isset($data['rawname'])) {
1072 $data['name'] = $name;
1076 // Validate the tag type.
1077 if (array_key_exists('isstandard', $data)) {
1078 $data['isstandard'] = $data['isstandard'] ? 1 : 0;
1081 // Find only the attributes that need to be changed.
1082 $originalname = $this->name;
1083 foreach ($data as $key => $value) {
1084 if ($this->record->$key !== $value) {
1085 $this->record->$key = $value;
1086 } else {
1087 unset($data[$key]);
1090 if (empty($data)) {
1091 return false;
1094 $data['id'] = $this->id;
1095 $data['timemodified'] = time();
1096 $DB->update_record('tag', $data);
1098 $event = \core\event\tag_updated::create(array(
1099 'objectid' => $this->id,
1100 'relateduserid' => $this->userid,
1101 'context' => context_system::instance(),
1102 'other' => array(
1103 'name' => $this->name,
1104 'rawname' => $this->rawname
1107 $event->trigger();
1108 return true;
1112 * Flag a tag as inappropriate
1114 public function flag() {
1115 global $DB;
1117 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1119 // Update all the tags to flagged.
1120 $this->timemodified = time();
1121 $this->flag++;
1122 $DB->update_record('tag', array('timemodified' => $this->timemodified,
1123 'flag' => $this->flag, 'id' => $this->id));
1125 $event = \core\event\tag_flagged::create(array(
1126 'objectid' => $this->id,
1127 'relateduserid' => $this->userid,
1128 'context' => context_system::instance(),
1129 'other' => array(
1130 'name' => $this->name,
1131 'rawname' => $this->rawname
1135 $event->trigger();
1139 * Remove the inappropriate flag on a tag.
1141 public function reset_flag() {
1142 global $DB;
1144 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1146 if (!$this->flag) {
1147 // Nothing to do.
1148 return false;
1151 $this->timemodified = time();
1152 $this->flag = 0;
1153 $DB->update_record('tag', array('timemodified' => $this->timemodified,
1154 'flag' => 0, 'id' => $this->id));
1156 $event = \core\event\tag_unflagged::create(array(
1157 'objectid' => $this->id,
1158 'relateduserid' => $this->userid,
1159 'context' => context_system::instance(),
1160 'other' => array(
1161 'name' => $this->name,
1162 'rawname' => $this->rawname
1165 $event->trigger();
1169 * Sets the list of tags related to this one.
1171 * Tag relations are recorded by two instances linking two tags to each other.
1172 * For tag relations ordering is not used and may be random.
1174 * @param array $tagnames
1176 public function set_related_tags($tagnames) {
1177 $context = context_system::instance();
1178 $tagobjects = $tagnames ? static::create_if_missing($this->tagcollid, $tagnames) : array();
1179 unset($tagobjects[$this->name]); // Never link to itself.
1181 $currenttags = static::get_item_tags('core', 'tag', $this->id);
1183 // For data coherence reasons, it's better to remove deleted tags
1184 // before adding new data: ordering could be duplicated.
1185 foreach ($currenttags as $currenttag) {
1186 if (!array_key_exists($currenttag->name, $tagobjects)) {
1187 $taginstance = (object)array('id' => $currenttag->taginstanceid,
1188 'itemtype' => 'tag', 'itemid' => $this->id,
1189 'contextid' => $context->id);
1190 $currenttag->delete_instance_as_record($taginstance, false);
1191 $this->delete_instance('core', 'tag', $currenttag->id);
1195 foreach ($tagobjects as $name => $tag) {
1196 foreach ($currenttags as $currenttag) {
1197 if ($currenttag->name === $name) {
1198 continue 2;
1201 $this->add_instance('core', 'tag', $tag->id, $context, 0);
1202 $tag->add_instance('core', 'tag', $this->id, $context, 0);
1203 $currenttags[] = $tag;
1208 * Adds to the list of related tags without removing existing
1210 * Tag relations are recorded by two instances linking two tags to each other.
1211 * For tag relations ordering is not used and may be random.
1213 * @param array $tagnames
1215 public function add_related_tags($tagnames) {
1216 $context = context_system::instance();
1217 $tagobjects = static::create_if_missing($this->tagcollid, $tagnames);
1219 $currenttags = static::get_item_tags('core', 'tag', $this->id);
1221 foreach ($tagobjects as $name => $tag) {
1222 foreach ($currenttags as $currenttag) {
1223 if ($currenttag->name === $name) {
1224 continue 2;
1227 $this->add_instance('core', 'tag', $tag->id, $context, 0);
1228 $tag->add_instance('core', 'tag', $this->id, $context, 0);
1229 $currenttags[] = $tag;
1234 * Returns the correlated tags of a tag, retrieved from the tag_correlation table.
1236 * Correlated tags are calculated in cron based on existing tag instances.
1238 * @param bool $keepduplicates if true, will return one record for each existing
1239 * tag instance which may result in duplicates of the actual tags
1240 * @return core_tag_tag[] an array of tag objects
1242 public function get_correlated_tags($keepduplicates = false) {
1243 global $DB;
1245 $correlated = $DB->get_field('tag_correlation', 'correlatedtags', array('tagid' => $this->id));
1247 if (!$correlated) {
1248 return array();
1250 $correlated = preg_split('/\s*,\s*/', trim($correlated), -1, PREG_SPLIT_NO_EMPTY);
1251 list($query, $params) = $DB->get_in_or_equal($correlated);
1253 // This is (and has to) return the same fields as the query in core_tag_tag::get_item_tags().
1254 $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
1255 tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
1256 FROM {tag} tg
1257 INNER JOIN {tag_instance} ti ON tg.id = ti.tagid
1258 WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ?
1259 ORDER BY ti.ordering ASC, ti.id";
1260 $params[] = $this->id;
1261 $params[] = $this->tagcollid;
1262 $records = $DB->get_records_sql($sql, $params);
1263 $seen = array();
1264 $result = array();
1265 foreach ($records as $id => $record) {
1266 if (!$keepduplicates && !empty($seen[$record->id])) {
1267 continue;
1269 $result[$id] = new static($record);
1270 $seen[$record->id] = true;
1272 return $result;
1276 * Returns tags that this tag was manually set as related to
1278 * @return core_tag_tag[]
1280 public function get_manual_related_tags() {
1281 return self::get_item_tags('core', 'tag', $this->id);
1285 * Returns tags related to a tag
1287 * Related tags of a tag come from two sources:
1288 * - manually added related tags, which are tag_instance entries for that tag
1289 * - correlated tags, which are calculated
1291 * @return core_tag_tag[] an array of tag objects
1293 public function get_related_tags() {
1294 $manual = $this->get_manual_related_tags();
1295 $automatic = $this->get_correlated_tags();
1296 $relatedtags = array_merge($manual, $automatic);
1298 // Remove duplicated tags (multiple instances of the same tag).
1299 $seen = array();
1300 foreach ($relatedtags as $instance => $tag) {
1301 if (isset($seen[$tag->id])) {
1302 unset($relatedtags[$instance]);
1303 } else {
1304 $seen[$tag->id] = 1;
1308 return $relatedtags;
1312 * Find all items tagged with a tag of a given type ('post', 'user', etc.)
1314 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1315 * query will be slow because DB index will not be used.
1316 * @param string $itemtype type to restrict search to
1317 * @param int $limitfrom (optional, required if $limitnum is set) return a subset of records, starting at this point.
1318 * @param int $limitnum (optional, required if $limitfrom is set) return a subset comprising this many records.
1319 * @param string $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1320 * @param array $params additional parameters for the DB query
1321 * @return array of matching objects, indexed by record id, from the table containing the type requested
1323 public function get_tagged_items($component, $itemtype, $limitfrom = '', $limitnum = '', $subquery = '', $params = array()) {
1324 global $DB;
1326 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1327 return array();
1329 $params = $params ? $params : array();
1331 $query = "SELECT it.*
1332 FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1333 WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1334 $params['itemtype'] = $itemtype;
1335 $params['tagid'] = $this->id;
1336 if ($component) {
1337 $query .= ' AND tt.component = :component';
1338 $params['component'] = $component;
1340 if ($subquery) {
1341 $query .= ' AND ' . $subquery;
1343 $query .= ' ORDER BY it.id';
1345 return $DB->get_records_sql($query, $params, $limitfrom, $limitnum);
1349 * Count how many items are tagged with a specific tag.
1351 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1352 * query will be slow because DB index will not be used.
1353 * @param string $itemtype type to restrict search to
1354 * @param string $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1355 * @param array $params additional parameters for the DB query
1356 * @return int number of mathing tags.
1358 public function count_tagged_items($component, $itemtype, $subquery = '', $params = array()) {
1359 global $DB;
1361 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1362 return 0;
1364 $params = $params ? $params : array();
1366 $query = "SELECT COUNT(it.id)
1367 FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1368 WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1369 $params['itemtype'] = $itemtype;
1370 $params['tagid'] = $this->id;
1371 if ($component) {
1372 $query .= ' AND tt.component = :component';
1373 $params['component'] = $component;
1375 if ($subquery) {
1376 $query .= ' AND ' . $subquery;
1379 return $DB->get_field_sql($query, $params);
1383 * Determine if an item is tagged with a specific tag
1385 * Note that this is a static method and not a method of core_tag object because the tag might not exist yet,
1386 * for example user searches for "php" and we offer him to add "php" to his interests.
1388 * @param string $component component responsible for tagging. For BC it can be empty but in this case the
1389 * query will be slow because DB index will not be used.
1390 * @param string $itemtype the record type to look for
1391 * @param int $itemid the record id to look for
1392 * @param string $tagname a tag name
1393 * @return int 1 if it is tagged, 0 otherwise
1395 public static function is_item_tagged_with($component, $itemtype, $itemid, $tagname) {
1396 global $DB;
1397 $tagcollid = core_tag_area::get_collection($component, $itemtype);
1398 $query = 'SELECT 1 FROM {tag} t
1399 JOIN {tag_instance} ti ON ti.tagid = t.id
1400 WHERE t.name = ? AND t.tagcollid = ? AND ti.itemtype = ? AND ti.itemid = ?';
1401 $cleanname = core_text::strtolower(clean_param($tagname, PARAM_TAG));
1402 $params = array($cleanname, $tagcollid, $itemtype, $itemid);
1403 if ($component) {
1404 $query .= ' AND ti.component = ?';
1405 $params[] = $component;
1407 return $DB->record_exists_sql($query, $params) ? 1 : 0;
1411 * Returns whether the tag area is enabled
1413 * @param string $component component responsible for tagging
1414 * @param string $itemtype what is being tagged, for example, 'post', 'course', 'user', etc.
1415 * @return bool|null
1417 public static function is_enabled($component, $itemtype) {
1418 return core_tag_area::is_enabled($component, $itemtype);
1422 * Retrieves contents of tag area for the tag/index.php page
1424 * @param stdClass $tagarea
1425 * @param bool $exclusivemode if set to true it means that no other entities tagged with this tag
1426 * are displayed on the page and the per-page limit may be bigger
1427 * @param int $fromctx context id where the link was displayed, may be used by callbacks
1428 * to display items in the same context first
1429 * @param int $ctx context id where to search for records
1430 * @param bool $rec search in subcontexts as well
1431 * @param int $page 0-based number of page being displayed
1432 * @return \core_tag\output\tagindex
1434 public function get_tag_index($tagarea, $exclusivemode, $fromctx, $ctx, $rec, $page = 0) {
1435 global $CFG;
1436 if (!empty($tagarea->callback)) {
1437 if (!empty($tagarea->callbackfile)) {
1438 require_once($CFG->dirroot . '/' . ltrim($tagarea->callbackfile, '/'));
1440 $callback = $tagarea->callback;
1441 return call_user_func_array($callback, [$this, $exclusivemode, $fromctx, $ctx, $rec, $page]);
1443 return null;
1447 * Returns formatted description of the tag
1449 * @param array $options
1450 * @return string
1452 public function get_formatted_description($options = array()) {
1453 $options = empty($options) ? array() : (array)$options;
1454 $options += array('para' => false, 'overflowdiv' => true);
1455 $description = file_rewrite_pluginfile_urls($this->description, 'pluginfile.php',
1456 context_system::instance()->id, 'tag', 'description', $this->id);
1457 return format_text($description, $this->descriptionformat, $options);
1461 * Returns the list of tag links available for the current user (edit, flag, etc.)
1463 * @return array
1465 public function get_links() {
1466 global $USER;
1467 $links = array();
1469 if (!isloggedin() || isguestuser()) {
1470 return $links;
1473 $tagname = $this->get_display_name();
1474 $systemcontext = context_system::instance();
1476 // Add a link for users to add/remove this from their interests.
1477 if (static::is_enabled('core', 'user') && core_tag_area::get_collection('core', 'user') == $this->tagcollid) {
1478 if (static::is_item_tagged_with('core', 'user', $USER->id, $this->name)) {
1479 $url = new moodle_url('/tag/user.php', array('action' => 'removeinterest',
1480 'sesskey' => sesskey(), 'tag' => $this->rawname));
1481 $links[] = html_writer::link($url, get_string('removetagfrommyinterests', 'tag', $tagname),
1482 array('class' => 'removefrommyinterests'));
1483 } else {
1484 $url = new moodle_url('/tag/user.php', array('action' => 'addinterest',
1485 'sesskey' => sesskey(), 'tag' => $this->rawname));
1486 $links[] = html_writer::link($url, get_string('addtagtomyinterests', 'tag', $tagname),
1487 array('class' => 'addtomyinterests'));
1491 // Flag as inappropriate link. Only people with moodle/tag:flag capability.
1492 if (has_capability('moodle/tag:flag', $systemcontext)) {
1493 $url = new moodle_url('/tag/user.php', array('action' => 'flaginappropriate',
1494 'sesskey' => sesskey(), 'id' => $this->id));
1495 $links[] = html_writer::link($url, get_string('flagasinappropriate', 'tag', $tagname),
1496 array('class' => 'flagasinappropriate'));
1499 // Edit tag: Only people with moodle/tag:edit capability who either have it as an interest or can manage tags.
1500 if (has_capability('moodle/tag:edit', $systemcontext) ||
1501 has_capability('moodle/tag:manage', $systemcontext)) {
1502 $url = new moodle_url('/tag/edit.php', array('id' => $this->id));
1503 $links[] = html_writer::link($url, get_string('edittag', 'tag'),
1504 array('class' => 'edittag'));
1507 return $links;
1511 * Delete one or more tag, and all their instances if there are any left.
1513 * @param int|array $tagids one tagid (int), or one array of tagids to delete
1514 * @return bool true on success, false otherwise
1516 public static function delete_tags($tagids) {
1517 global $DB;
1519 if (!is_array($tagids)) {
1520 $tagids = array($tagids);
1522 if (empty($tagids)) {
1523 return;
1526 // Use the tagids to create a select statement to be used later.
1527 list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids);
1529 // Store the tags and tag instances we are going to delete.
1530 $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
1531 $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
1533 // Delete all the tag instances.
1534 $select = 'WHERE tagid ' . $tagsql;
1535 $sql = "DELETE FROM {tag_instance} $select";
1536 $DB->execute($sql, $tagparams);
1538 // Delete all the tag correlations.
1539 $sql = "DELETE FROM {tag_correlation} $select";
1540 $DB->execute($sql, $tagparams);
1542 // Delete all the tags.
1543 $select = 'WHERE id ' . $tagsql;
1544 $sql = "DELETE FROM {tag} $select";
1545 $DB->execute($sql, $tagparams);
1547 // Fire an event that these items were untagged.
1548 if ($taginstances) {
1549 // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
1550 $syscontextid = context_system::instance()->id;
1551 // Loop through the tag instances and fire a 'tag_removed'' event.
1552 foreach ($taginstances as $taginstance) {
1553 // We can not fire an event with 'null' as the contextid.
1554 if (is_null($taginstance->contextid)) {
1555 $taginstance->contextid = $syscontextid;
1558 // Trigger tag removed event.
1559 \core\event\tag_removed::create_from_tag_instance($taginstance,
1560 $tags[$taginstance->tagid]->name, $tags[$taginstance->tagid]->rawname,
1561 true)->trigger();
1565 // Fire an event that these tags were deleted.
1566 if ($tags) {
1567 $context = context_system::instance();
1568 foreach ($tags as $tag) {
1569 // Delete all files associated with this tag.
1570 $fs = get_file_storage();
1571 $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
1572 foreach ($files as $file) {
1573 $file->delete();
1576 // Trigger an event for deleting this tag.
1577 $event = \core\event\tag_deleted::create(array(
1578 'objectid' => $tag->id,
1579 'relateduserid' => $tag->userid,
1580 'context' => $context,
1581 'other' => array(
1582 'name' => $tag->name,
1583 'rawname' => $tag->rawname
1586 $event->add_record_snapshot('tag', $tag);
1587 $event->trigger();
1591 return true;
1595 * Combine together correlated tags of several tags
1597 * This is a help method for method combine_tags()
1599 * @param core_tag_tag[] $tags
1601 protected function combine_correlated_tags($tags) {
1602 global $DB;
1603 $ids = array_map(function($t) {
1604 return $t->id;
1605 }, $tags);
1607 // Retrieve the correlated tags of this tag and correlated tags of all tags to be merged in one query
1608 // but store them separately. Calculate the list of correlated tags that need to be added to the current.
1609 list($sql, $params) = $DB->get_in_or_equal($ids);
1610 $params[] = $this->id;
1611 $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql.' OR tagid = ?',
1612 $params, '', 'tagid, id, correlatedtags');
1613 $correlated = array();
1614 $mycorrelated = array();
1615 foreach ($records as $record) {
1616 $taglist = preg_split('/\s*,\s*/', trim($record->correlatedtags), -1, PREG_SPLIT_NO_EMPTY);
1617 if ($record->tagid == $this->id) {
1618 $mycorrelated = $taglist;
1619 } else {
1620 $correlated = array_merge($correlated, $taglist);
1623 array_unique($correlated);
1624 // Strip out from $correlated the ids of the tags that are already in $mycorrelated
1625 // or are one of the tags that are going to be combined.
1626 $correlated = array_diff($correlated, [$this->id], $ids, $mycorrelated);
1628 if (empty($correlated)) {
1629 // Nothing to do, ignore situation when current tag is correlated to one of the merged tags - they will
1630 // be deleted later and get_tag_correlation() will not return them. Next cron will clean everything up.
1631 return;
1634 // Update correlated tags of this tag.
1635 $newcorrelatedlist = join(',', array_merge($mycorrelated, $correlated));
1636 if (isset($records[$this->id])) {
1637 $DB->update_record('tag_correlation', array('id' => $records[$this->id]->id, 'correlatedtags' => $newcorrelatedlist));
1638 } else {
1639 $DB->insert_record('tag_correlation', array('tagid' => $this->id, 'correlatedtags' => $newcorrelatedlist));
1642 // Add this tag to the list of correlated tags of each tag in $correlated.
1643 list($sql, $params) = $DB->get_in_or_equal($correlated);
1644 $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql, $params, '', 'tagid, id, correlatedtags');
1645 foreach ($correlated as $tagid) {
1646 if (isset($records[$tagid])) {
1647 $newcorrelatedlist = $records[$tagid]->correlatedtags . ',' . $this->id;
1648 $DB->update_record('tag_correlation', array('id' => $records[$tagid]->id, 'correlatedtags' => $newcorrelatedlist));
1649 } else {
1650 $DB->insert_record('tag_correlation', array('tagid' => $tagid, 'correlatedtags' => '' . $this->id));
1656 * Combines several other tags into this one
1658 * Combining rules:
1659 * - current tag becomes the "main" one, all instances
1660 * pointing to other tags are changed to point to it.
1661 * - if any of the tags is standard, the "main" tag becomes standard too
1662 * - all tags except for the current ("main") are deleted, even when they are standard
1664 * @param core_tag_tag[] $tags tags to combine into this one
1666 public function combine_tags($tags) {
1667 global $DB;
1669 $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags');
1671 // Retrieve all tag objects, find if there are any standard tags in the set.
1672 $isstandard = false;
1673 $tagstocombine = array();
1674 $ids = array();
1675 $relatedtags = $this->get_manual_related_tags();
1676 foreach ($tags as $tag) {
1677 $tag->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'tagcollid', 'name', 'rawname'), 'combine_tags');
1678 if ($tag && $tag->id != $this->id && $tag->tagcollid == $this->tagcollid) {
1679 $isstandard = $isstandard || $tag->isstandard;
1680 $tagstocombine[$tag->name] = $tag;
1681 $ids[] = $tag->id;
1682 $relatedtags = array_merge($relatedtags, $tag->get_manual_related_tags());
1686 if (empty($tagstocombine)) {
1687 // Nothing to do.
1688 return;
1691 // Combine all manually set related tags, exclude itself all the tags it is about to be combined with.
1692 if ($relatedtags) {
1693 $relatedtags = array_map(function($t) {
1694 return $t->name;
1695 }, $relatedtags);
1696 array_unique($relatedtags);
1697 $relatedtags = array_diff($relatedtags, [$this->name], array_keys($tagstocombine));
1699 $this->set_related_tags($relatedtags);
1701 // Combine all correlated tags, exclude itself all the tags it is about to be combined with.
1702 $this->combine_correlated_tags($tagstocombine);
1704 // If any of the duplicate tags are standard, mark this one as standard too.
1705 if ($isstandard && !$this->isstandard) {
1706 $this->update(array('isstandard' => 1));
1709 // Go through all instances of each tag that needs to be combined and make them point to this tag instead.
1710 // We go though the list one by one because otherwise looking-for-duplicates logic would be too complicated.
1711 foreach ($tagstocombine as $tag) {
1712 $params = array('tagid' => $tag->id, 'mainid' => $this->id);
1713 $mainsql = 'SELECT ti.*, t.name, t.rawname, tim.id AS alreadyhasmaintag '
1714 . 'FROM {tag_instance} ti '
1715 . 'LEFT JOIN {tag} t ON t.id = ti.tagid '
1716 . 'LEFT JOIN {tag_instance} tim ON ti.component = tim.component AND '
1717 . ' ti.itemtype = tim.itemtype AND ti.itemid = tim.itemid AND '
1718 . ' ti.tiuserid = tim.tiuserid AND tim.tagid = :mainid '
1719 . 'WHERE ti.tagid = :tagid';
1721 $records = $DB->get_records_sql($mainsql, $params);
1722 foreach ($records as $record) {
1723 if ($record->alreadyhasmaintag) {
1724 // Item is tagged with both main tag and the duplicate tag.
1725 // Remove instance pointing to the duplicate tag.
1726 $tag->delete_instance_as_record($record, false);
1727 $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
1728 WHERE itemtype = :itemtype
1729 AND itemid = :itemid AND component = :component AND tiuserid = :tiuserid
1730 AND ordering > :ordering";
1731 $DB->execute($sql, (array)$record);
1732 } else {
1733 // Item is tagged only with duplicate tag but not the main tag.
1734 // Replace tagid in the instance pointing to the duplicate tag with this tag.
1735 $DB->update_record('tag_instance', array('id' => $record->id, 'tagid' => $this->id));
1736 \core\event\tag_removed::create_from_tag_instance($record, $record->name, $record->rawname)->trigger();
1737 $record->tagid = $this->id;
1738 \core\event\tag_added::create_from_tag_instance($record, $this->name, $this->rawname)->trigger();
1743 // Finally delete all tags that we combined into the current one.
1744 self::delete_tags($ids);
1748 * Retrieve a list of tags that have been used to tag the given $component
1749 * and $itemtype in the provided $contexts.
1751 * @param string $component The tag instance component
1752 * @param string $itemtype The tag instance item type
1753 * @param context[] $contexts The list of contexts to look for tag instances in
1754 * @return core_tag_tag[]
1756 public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) {
1757 global $DB;
1759 $params = [$component, $itemtype];
1760 $contextids = array_map(function($context) {
1761 return $context->id;
1762 }, $contexts);
1763 list($contextsql, $contextsqlparams) = $DB->get_in_or_equal($contextids);
1764 $params = array_merge($params, $contextsqlparams);
1766 $subsql = "SELECT DISTINCT t.id
1767 FROM {tag} t
1768 JOIN {tag_instance} ti ON t.id = ti.tagid
1769 WHERE component = ?
1770 AND itemtype = ?
1771 AND contextid {$contextsql}";
1773 $sql = "SELECT tt.*
1774 FROM ($subsql) tv
1775 JOIN {tag} tt ON tt.id = tv.id";
1777 return array_map(function($record) {
1778 return new core_tag_tag($record);
1779 }, $DB->get_records_sql($sql, $params));