Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
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. 13 // 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/>. 16 17 /** 18 * Contains class core_tag_tag 19 * 20 * @package core_tag 21 * @copyright 2015 Marina Glancy 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 defined('MOODLE_INTERNAL') || die(); 26 27 /** 28 * Represents one tag and also contains lots of useful tag-related methods as static functions. 29 * 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 35 * 36 * BASIC INSTRUCTIONS : 37 * - to "tag a blog post" (for example): 38 * core_tag_tag::set_item_tags('post', 'core', $blogpost->id, $context, $arrayoftags); 39 * 40 * - to "remove all the tags on a blog post": 41 * core_tag_tag::remove_all_item_tags('post', 'core', $blogpost->id); 42 * 43 * set_item_tags() will create tags that do not exist yet. 44 * 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 55 * 56 * @package core_tag 57 * @copyright 2015 Marina Glancy 58 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 59 */ 60 class core_tag_tag { 61 62 /** @var stdClass data about the tag */ 63 protected $record = null; 64 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; 67 68 /** @var int indicates that only standard tags can be used (or must be returned) */ 69 const STANDARD_ONLY = 1; 70 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; 73 74 /** @var int option to hide standard tags when editing item tags */ 75 const HIDE_STANDARD = 2; 76 77 /** @var int|null tag context ID. */ 78 public $taginstancecontextid; 79 80 /** @var int|null time modification. */ 81 public $timemodified; 82 83 /** @var int|null 0 if not flagged or positive integer if flagged. */ 84 public $flag; 85 86 /** 87 * Constructor. Use functions get(), get_by_name(), etc. 88 * 89 * @param stdClass $record 90 */ 91 protected function __construct($record) { 92 if (empty($record->id)) { 93 throw new coding_exception("Record must contain at least field 'id'"); 94 } 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; 99 } 100 101 /** 102 * Magic getter 103 * 104 * @param string $name 105 * @return mixed 106 */ 107 public function __get($name) { 108 return $this->record->$name; 109 } 110 111 /** 112 * Magic isset method 113 * 114 * @param string $name 115 * @return bool 116 */ 117 public function __isset($name) { 118 return isset($this->record->$name); 119 } 120 121 /** 122 * Converts to object 123 * 124 * @return stdClass 125 */ 126 public function to_object() { 127 return fullclone($this->record); 128 } 129 130 /** 131 * Returns tag name ready to be displayed 132 * 133 * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string 134 * @return string 135 */ 136 public function get_display_name($ashtml = true) { 137 return static::make_display_name($this->record, $ashtml); 138 } 139 140 /** 141 * Prepares tag name ready to be displayed 142 * 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 146 */ 147 public static function make_display_name($tag, $ashtml = true) { 148 global $CFG; 149 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; 156 } 157 158 // Clean up a bit just in case the rules change again. 159 $tagname = clean_param($tagname, PARAM_TAG); 160 161 return $ashtml ? htmlspecialchars($tagname, ENT_COMPAT) : $tagname; 162 } 163 164 /** 165 * Adds one or more tag in the database. This function should not be called directly : you should 166 * use tag_set. 167 * 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. 173 */ 174 protected static function add($tagcollid, $tags, $isstandard = false) { 175 global $USER, $DB; 176 177 $tagobject = new stdClass(); 178 $tagobject->isstandard = $isstandard ? 1 : 0; 179 $tagobject->userid = $USER->id; 180 $tagobject->timemodified = time(); 181 $tagobject->tagcollid = $tagcollid; 182 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); 194 195 \core\event\tag_created::create_from_tag($rv[$obj->name])->trigger(); 196 } 197 } 198 199 return $rv; 200 } 201 202 /** 203 * Simple function to just return a single tag object by its id 204 * 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 213 */ 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); 219 } 220 return false; 221 } 222 223 /** 224 * Simple function to just return an array of tag objects by their ids 225 * 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 231 */ 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; 237 } 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); 242 } 243 return $result; 244 } 245 246 /** 247 * Simple function to just return a single tag object by tagcollid and name 248 * 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 258 */ 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)); 269 } 270 return false; 271 } 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); 277 } 278 return false; 279 } 280 281 /** 282 * Looking in all tag collections for the tag with the given name 283 * 284 * @param string $name tag name 285 * @param string $returnfields 286 * @return array array of core_tag_tag instances 287 */ 288 public static function guess_by_name($name, $returnfields='id, name, rawname, tagcollid') { 289 global $DB; 290 if (empty($name)) { 291 return array(); 292 } 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; 302 }); 303 } 304 $rv = array(); 305 foreach ($tags as $id => $tag) { 306 $rv[$id] = new static($tag); 307 } 308 return $rv; 309 } 310 311 /** 312 * Returns the list of tag objects by tag collection id and the list of tag names 313 * 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. 318 */ 319 public static function get_by_name_bulk($tagcollid, $tags, $returnfields = 'id, name, rawname, tagcollid') { 320 global $DB; 321 322 if (empty($tags)) { 323 return array(); 324 } 325 326 $cleantags = self::normalize(self::normalize($tags, false)); // Format: rawname => normalised name. 327 328 list($namesql, $params) = $DB->get_in_or_equal(array_values($cleantags)); 329 array_unshift($params, $tagcollid); 330 331 $recordset = $DB->get_recordset_sql("SELECT $returnfields FROM {tag} WHERE tagcollid = ? AND name $namesql", $params); 332 333 $result = array_fill_keys($cleantags, null); 334 foreach ($recordset as $record) { 335 $result[$record->name] = new static($record); 336 } 337 $recordset->close(); 338 return $result; 339 } 340 341 342 /** 343 * Function that normalizes a list of tag names. 344 * 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'). 349 */ 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; 360 } 361 } 362 } 363 return $result; 364 } 365 366 /** 367 * Retrieves tags and/or creates them if do not exist yet 368 * 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 373 */ 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 . 376 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; 384 } 385 } 386 return $result; 387 } 388 389 /** 390 * Creates a URL to view a tag 391 * 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 399 */ 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'; 406 } 407 $params = array('tc' => $tagcollid, 'tag' => $name); 408 if ($exclusivemode) { 409 $params['excl'] = 1; 410 } 411 if ($fromctx) { 412 $params['from'] = $fromctx; 413 } 414 if ($ctx) { 415 $params['ctx'] = $ctx; 416 } 417 if (!$rec) { 418 $params['rec'] = 0; 419 } 420 return new moodle_url($url, $params); 421 } 422 423 /** 424 * Returns URL to view the tag 425 * 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 431 */ 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); 435 } 436 437 /** 438 * Validates that the required fields were retrieved and retrieves them if missing 439 * 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 442 */ 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); 450 } 451 } 452 453 /** 454 * Deletes the tag instance given the record from tag_instance DB table 455 * 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 460 */ 461 protected function delete_instance_as_record($taginstance, $fullobject = false) { 462 global $DB; 463 464 $this->ensure_fields_exist(array('name', 'rawname', 'isstandard'), 'delete_instance_as_record'); 465 466 $DB->delete_records('tag_instance', array('id' => $taginstance->id)); 467 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; 471 } 472 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(); 476 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); 481 } 482 } 483 484 return true; 485 } 486 487 /** 488 * Delete one instance of a tag. If the last instance was deleted, it will also delete the tag, unless it is standard. 489 * 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) 495 */ 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; 502 } 503 if ($component) { 504 $params['component'] = $component; 505 } 506 507 $taginstance = $DB->get_record('tag_instance', $params); 508 if (!$taginstance) { 509 return; 510 } 511 $this->delete_instance_as_record($taginstance, true); 512 } 513 514 /** 515 * Bulk delete all tag instances. 516 * 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. 520 */ 521 public static function delete_instances_as_record(array $taginstances) { 522 global $DB; 523 524 if (empty($taginstances)) { 525 return; 526 } 527 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; 540 } 541 542 // Trigger tag removed event. 543 \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name, 544 $taginstance->rawname, true)->trigger(); 545 } 546 } 547 548 /** 549 * Bulk delete all tag instances by tag id. 550 * 551 * @param int[] $taginstanceids List of tag instance ids to be deleted. 552 */ 553 public static function delete_instances_by_id(array $taginstanceids) { 554 global $DB; 555 556 if (empty($taginstanceids)) { 557 return; 558 } 559 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}"; 566 567 if ($taginstances = $DB->get_records_sql($sql, $params)) { 568 static::delete_instances_as_record($taginstances); 569 } 570 } 571 572 /** 573 * Bulk delete all tag instances for a component or tag area 574 * 575 * @param string $component 576 * @param string $itemtype (optional) 577 * @param int $contextid (optional) 578 */ 579 public static function delete_instances($component, $itemtype = null, $contextid = null) { 580 global $DB; 581 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; 591 } 592 if (!is_null($itemtype)) { 593 $sql .= " AND ti.itemtype = :itemtype"; 594 $params['itemtype'] = $itemtype; 595 } 596 597 if ($taginstances = $DB->get_records_sql($sql, $params)) { 598 static::delete_instances_as_record($taginstances); 599 } 600 } 601 602 /** 603 * Adds a tag instance 604 * 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 612 */ 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'); 616 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; 627 628 $taginstance->id = $DB->insert_record('tag_instance', $taginstance); 629 630 // Trigger tag added event. 631 \core\event\tag_added::create_from_tag_instance($taginstance, $this->name, $this->rawname, true)->trigger(); 632 633 return $taginstance->id; 634 } 635 636 /** 637 * Updates the ordering on tag instance 638 * 639 * @param int $instanceid 640 * @param int $ordering 641 */ 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(); 648 649 $DB->update_record('tag_instance', $data); 650 } 651 652 /** 653 * Get the array of core_tag_tag objects associated with a list of items. 654 * 655 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array. 656 * 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 665 */ 666 public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT, 667 $tiuserid = 0) { 668 global $DB; 669 670 if (static::is_enabled($component, $itemtype) === false) { 671 // Tagging area is properly defined but not enabled - return empty array. 672 return array(); 673 } 674 675 if (empty($itemids)) { 676 return array(); 677 } 678 679 $standardonly = (int)$standardonly; // In case somebody passed bool. 680 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"; 693 694 $params['itemtype'] = $itemtype; 695 $params['component'] = $component; 696 $params['tiuserid'] = $tiuserid; 697 698 $records = $DB->get_records_sql($sql, $params); 699 $result = array(); 700 foreach ($itemids as $itemid) { 701 $result[$itemid] = []; 702 } 703 foreach ($records as $id => $record) { 704 $result[$record->itemid][$id] = new static($record); 705 } 706 return $result; 707 } 708 709 /** 710 * Get the array of core_tag_tag objects associated with an item (instances). 711 * 712 * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array. 713 * 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 721 */ 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]; 726 } 727 728 /** 729 * Returns the list of display names of the tags that are associated with an item 730 * 731 * This method is usually used to prefill the form data for the 'tags' form element 732 * 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 741 */ 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); 747 } 748 return $tags; 749 } 750 751 /** 752 * Sets the list of tag instances for one item (table record). 753 * 754 * Extra exsisting instances are removed, new ones are added. New tags are created if needed. 755 * 756 * This method can not be used for setting tags relations, please use set_related_tags() 757 * 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) 764 */ 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'); 769 } 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; 773 } 774 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; 779 } 780 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(); 787 } 788 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 = []; 792 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; 798 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; 814 } 815 } 816 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); 822 } 823 } 824 825 if (!empty($taginstanceidstomovecontext)) { 826 static::change_instances_context($taginstanceidstomovecontext, $context); 827 } 828 829 $ordering = -1; 830 foreach ($tagobjects as $name => $tag) { 831 $ordering++; 832 foreach ($currenttags as $currenttag) { 833 $namesmatch = strval($currenttag->name) === strval($name); 834 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; 846 } 847 848 if ($shouldskipinstance) { 849 if ($currenttag->ordering != $ordering) { 850 $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering); 851 } 852 continue 2; 853 } 854 } 855 $tag->add_instance($component, $itemtype, $itemid, $context, $ordering, $tiuserid); 856 } 857 } 858 859 /** 860 * Removes all tags from an item. 861 * 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. 864 * 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) 869 */ 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); 873 } 874 875 /** 876 * Adds a tag to an item, without overwriting the current tags. 877 * 878 * If the tag has already been added to the record, no changes are made. 879 * 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 887 */ 888 public static function add_item_tag($component, $itemtype, $itemid, context $context, $tagname, $tiuserid = 0) { 889 global $DB; 890 891 if (static::is_enabled($component, $itemtype) === false) { 892 // Tagging area is properly defined but not enabled - do nothing. 893 return null; 894 } 895 896 $rawname = clean_param($tagname, PARAM_TAG); 897 $normalisedname = core_text::strtolower($rawname); 898 $tagcollid = core_tag_area::get_collection($component, $itemtype); 899 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; 914 } 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); 920 } 921 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); 925 926 return $tag->add_instance($component, $itemtype, $itemid, $context, $ordering + 1, $tiuserid); 927 } 928 929 /** 930 * Removes the tag from an item without changing the other tags 931 * 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) 937 */ 938 public static function remove_item_tag($component, $itemtype, $itemid, $tagname, $tiuserid = 0) { 939 global $DB; 940 941 if (static::is_enabled($component, $itemtype) === false) { 942 // Tagging area is properly defined but not enabled - do nothing. 943 return array(); 944 } 945 946 $rawname = clean_param($tagname, PARAM_TAG); 947 $normalisedname = core_text::strtolower($rawname); 948 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); 971 } 972 } 973 974 /** 975 * Allows to move all tag instances from one context to another 976 * 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 981 */ 982 public static function move_context($component, $itemtype, $oldcontext, $newcontext) { 983 global $DB; 984 if ($oldcontext instanceof context) { 985 $oldcontext = $oldcontext->id; 986 } 987 if ($newcontext instanceof context) { 988 $newcontext = $newcontext->id; 989 } 990 $DB->set_field('tag_instance', 'contextid', $newcontext, 991 array('component' => $component, 'itemtype' => $itemtype, 'contextid' => $oldcontext)); 992 } 993 994 /** 995 * Moves all tags of the specified items to the new context 996 * 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 1001 */ 1002 public static function change_items_context($component, $itemtype, $itemids, $newcontext) { 1003 global $DB; 1004 if (empty($itemids)) { 1005 return; 1006 } 1007 if (!is_array($itemids)) { 1008 $itemids = array($itemids); 1009 } 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; 1015 } 1016 1017 $DB->set_field_select('tag_instance', 'contextid', $newcontext, 1018 'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params); 1019 } 1020 1021 /** 1022 * Moves all of the specified tag instances into a new context. 1023 * 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 1026 */ 1027 public static function change_instances_context(array $taginstanceids, context $newcontext) { 1028 global $DB; 1029 1030 if (empty($taginstanceids)) { 1031 return; 1032 } 1033 1034 list($sql, $params) = $DB->get_in_or_equal($taginstanceids); 1035 $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params); 1036 } 1037 1038 /** 1039 * Updates the information about the tag 1040 * 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. 1044 */ 1045 public function update($data) { 1046 global $DB, $COURSE; 1047 1048 $allowedfields = array('isstandard', 'description', 'descriptionformat', 'rawname'); 1049 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); 1054 } 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'); 1057 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']); 1062 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'); 1069 } 1070 } 1071 if (isset($data['rawname'])) { 1072 $data['name'] = $name; 1073 } 1074 } 1075 1076 // Validate the tag type. 1077 if (array_key_exists('isstandard', $data)) { 1078 $data['isstandard'] = $data['isstandard'] ? 1 : 0; 1079 } 1080 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]); 1088 } 1089 } 1090 if (empty($data)) { 1091 return false; 1092 } 1093 1094 $data['id'] = $this->id; 1095 $data['timemodified'] = time(); 1096 $DB->update_record('tag', $data); 1097 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 1105 ) 1106 )); 1107 $event->trigger(); 1108 return true; 1109 } 1110 1111 /** 1112 * Flag a tag as inappropriate 1113 */ 1114 public function flag() { 1115 global $DB; 1116 1117 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag'); 1118 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)); 1124 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 1132 ) 1133 1134 )); 1135 $event->trigger(); 1136 } 1137 1138 /** 1139 * Remove the inappropriate flag on a tag. 1140 */ 1141 public function reset_flag() { 1142 global $DB; 1143 1144 $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag'); 1145 1146 if (!$this->flag) { 1147 // Nothing to do. 1148 return false; 1149 } 1150 1151 $this->timemodified = time(); 1152 $this->flag = 0; 1153 $DB->update_record('tag', array('timemodified' => $this->timemodified, 1154 'flag' => 0, 'id' => $this->id)); 1155 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 1163 ) 1164 )); 1165 $event->trigger(); 1166 } 1167 1168 /** 1169 * Sets the list of tags related to this one. 1170 * 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. 1173 * 1174 * @param array $tagnames 1175 */ 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. 1180 1181 $currenttags = static::get_item_tags('core', 'tag', $this->id); 1182 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); 1192 } 1193 } 1194 1195 foreach ($tagobjects as $name => $tag) { 1196 foreach ($currenttags as $currenttag) { 1197 if ($currenttag->name === $name) { 1198 continue 2; 1199 } 1200 } 1201 $this->add_instance('core', 'tag', $tag->id, $context, 0); 1202 $tag->add_instance('core', 'tag', $this->id, $context, 0); 1203 $currenttags[] = $tag; 1204 } 1205 } 1206 1207 /** 1208 * Adds to the list of related tags without removing existing 1209 * 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. 1212 * 1213 * @param array $tagnames 1214 */ 1215 public function add_related_tags($tagnames) { 1216 $context = context_system::instance(); 1217 $tagobjects = static::create_if_missing($this->tagcollid, $tagnames); 1218 1219 $currenttags = static::get_item_tags('core', 'tag', $this->id); 1220 1221 foreach ($tagobjects as $name => $tag) { 1222 foreach ($currenttags as $currenttag) { 1223 if ($currenttag->name === $name) { 1224 continue 2; 1225 } 1226 } 1227 $this->add_instance('core', 'tag', $tag->id, $context, 0); 1228 $tag->add_instance('core', 'tag', $this->id, $context, 0); 1229 $currenttags[] = $tag; 1230 } 1231 } 1232 1233 /** 1234 * Returns the correlated tags of a tag, retrieved from the tag_correlation table. 1235 * 1236 * Correlated tags are calculated in cron based on existing tag instances. 1237 * 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 1241 */ 1242 public function get_correlated_tags($keepduplicates = false) { 1243 global $DB; 1244 1245 $correlated = $DB->get_field('tag_correlation', 'correlatedtags', array('tagid' => $this->id)); 1246 1247 if (!$correlated) { 1248 return array(); 1249 } 1250 $correlated = preg_split('/\s*,\s*/', trim($correlated), -1, PREG_SPLIT_NO_EMPTY); 1251 list($query, $params) = $DB->get_in_or_equal($correlated); 1252 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; 1268 } 1269 $result[$id] = new static($record); 1270 $seen[$record->id] = true; 1271 } 1272 return $result; 1273 } 1274 1275 /** 1276 * Returns tags that this tag was manually set as related to 1277 * 1278 * @return core_tag_tag[] 1279 */ 1280 public function get_manual_related_tags() { 1281 return self::get_item_tags('core', 'tag', $this->id); 1282 } 1283 1284 /** 1285 * Returns tags related to a tag 1286 * 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 1290 * 1291 * @return core_tag_tag[] an array of tag objects 1292 */ 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); 1297 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; 1305 } 1306 } 1307 1308 return $relatedtags; 1309 } 1310 1311 /** 1312 * Find all items tagged with a tag of a given type ('post', 'user', etc.) 1313 * 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 1322 */ 1323 public function get_tagged_items($component, $itemtype, $limitfrom = '', $limitnum = '', $subquery = '', $params = array()) { 1324 global $DB; 1325 1326 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) { 1327 return array(); 1328 } 1329 $params = $params ? $params : array(); 1330 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; 1339 } 1340 if ($subquery) { 1341 $query .= ' AND ' . $subquery; 1342 } 1343 $query .= ' ORDER BY it.id'; 1344 1345 return $DB->get_records_sql($query, $params, $limitfrom, $limitnum); 1346 } 1347 1348 /** 1349 * Count how many items are tagged with a specific tag. 1350 * 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. 1357 */ 1358 public function count_tagged_items($component, $itemtype, $subquery = '', $params = array()) { 1359 global $DB; 1360 1361 if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) { 1362 return 0; 1363 } 1364 $params = $params ? $params : array(); 1365 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; 1374 } 1375 if ($subquery) { 1376 $query .= ' AND ' . $subquery; 1377 } 1378 1379 return $DB->get_field_sql($query, $params); 1380 } 1381 1382 /** 1383 * Determine if an item is tagged with a specific tag 1384 * 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. 1387 * 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 1394 */ 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; 1406 } 1407 return $DB->record_exists_sql($query, $params) ? 1 : 0; 1408 } 1409 1410 /** 1411 * Returns whether the tag area is enabled 1412 * 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 1416 */ 1417 public static function is_enabled($component, $itemtype) { 1418 return core_tag_area::is_enabled($component, $itemtype); 1419 } 1420 1421 /** 1422 * Retrieves contents of tag area for the tag/index.php page 1423 * 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 1433 */ 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, '/')); 1439 } 1440 $callback = $tagarea->callback; 1441 return call_user_func_array($callback, [$this, $exclusivemode, $fromctx, $ctx, $rec, $page]); 1442 } 1443 return null; 1444 } 1445 1446 /** 1447 * Returns formatted description of the tag 1448 * 1449 * @param array $options 1450 * @return string 1451 */ 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); 1458 } 1459 1460 /** 1461 * Returns the list of tag links available for the current user (edit, flag, etc.) 1462 * 1463 * @return array 1464 */ 1465 public function get_links() { 1466 global $USER; 1467 $links = array(); 1468 1469 if (!isloggedin() || isguestuser()) { 1470 return $links; 1471 } 1472 1473 $tagname = $this->get_display_name(); 1474 $systemcontext = context_system::instance(); 1475 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')); 1488 } 1489 } 1490 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')); 1497 } 1498 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')); 1505 } 1506 1507 return $links; 1508 } 1509 1510 /** 1511 * Delete one or more tag, and all their instances if there are any left. 1512 * 1513 * @param int|array $tagids one tagid (int), or one array of tagids to delete 1514 * @return bool true on success, false otherwise 1515 */ 1516 public static function delete_tags($tagids) { 1517 global $DB; 1518 1519 if (!is_array($tagids)) { 1520 $tagids = array($tagids); 1521 } 1522 if (empty($tagids)) { 1523 return; 1524 } 1525 1526 // Use the tagids to create a select statement to be used later. 1527 list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids); 1528 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); 1532 1533 // Delete all the tag instances. 1534 $select = 'WHERE tagid ' . $tagsql; 1535 $sql = "DELETE FROM {tag_instance} $select"; 1536 $DB->execute($sql, $tagparams); 1537 1538 // Delete all the tag correlations. 1539 $sql = "DELETE FROM {tag_correlation} $select"; 1540 $DB->execute($sql, $tagparams); 1541 1542 // Delete all the tags. 1543 $select = 'WHERE id ' . $tagsql; 1544 $sql = "DELETE FROM {tag} $select"; 1545 $DB->execute($sql, $tagparams); 1546 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; 1556 } 1557 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(); 1562 } 1563 } 1564 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(); 1574 } 1575 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 1584 ) 1585 )); 1586 $event->add_record_snapshot('tag', $tag); 1587 $event->trigger(); 1588 } 1589 } 1590 1591 return true; 1592 } 1593 1594 /** 1595 * Combine together correlated tags of several tags 1596 * 1597 * This is a help method for method combine_tags() 1598 * 1599 * @param core_tag_tag[] $tags 1600 */ 1601 protected function combine_correlated_tags($tags) { 1602 global $DB; 1603 $ids = array_map(function($t) { 1604 return $t->id; 1605 }, $tags); 1606 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); 1621 } 1622 } 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); 1627 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; 1632 } 1633 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)); 1640 } 1641 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)); 1651 } 1652 } 1653 } 1654 1655 /** 1656 * Combines several other tags into this one 1657 * 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 1663 * 1664 * @param core_tag_tag[] $tags tags to combine into this one 1665 */ 1666 public function combine_tags($tags) { 1667 global $DB; 1668 1669 $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags'); 1670 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()); 1683 } 1684 } 1685 1686 if (empty($tagstocombine)) { 1687 // Nothing to do. 1688 return; 1689 } 1690 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)); 1698 } 1699 $this->set_related_tags($relatedtags); 1700 1701 // Combine all correlated tags, exclude itself all the tags it is about to be combined with. 1702 $this->combine_correlated_tags($tagstocombine); 1703 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)); 1707 } 1708 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'; 1720 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(); 1739 } 1740 } 1741 } 1742 1743 // Finally delete all tags that we combined into the current one. 1744 self::delete_tags($ids); 1745 } 1746 1747 /** 1748 * Retrieve a list of tags that have been used to tag the given $component 1749 * and $itemtype in the provided $contexts. 1750 * 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[] 1755 */ 1756 public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) { 1757 global $DB; 1758 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); 1765 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}"; 1772 1773 $sql = "SELECT tt.* 1774 FROM ($subsql) tv 1775 JOIN {tag} tt ON tt.id = tv.id"; 1776 1777 return array_map(function($record) { 1778 return new core_tag_tag($record); 1779 }, $DB->get_records_sql($sql, $params)); 1780 } 1781 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body