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
25defined('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 */
60class 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    /**
78     * Constructor. Use functions get(), get_by_name(), etc.
79     *
80     * @param stdClass $record
81     */
82    protected function __construct($record) {
83        if (empty($record->id)) {
84            throw new coding_exception("Record must contain at least field 'id'");
85        }
86        $this->record = $record;
87    }
88
89    /**
90     * Magic getter
91     *
92     * @param string $name
93     * @return mixed
94     */
95    public function __get($name) {
96        return $this->record->$name;
97    }
98
99    /**
100     * Magic isset method
101     *
102     * @param string $name
103     * @return bool
104     */
105    public function __isset($name) {
106        return isset($this->record->$name);
107    }
108
109    /**
110     * Converts to object
111     *
112     * @return stdClass
113     */
114    public function to_object() {
115        return fullclone($this->record);
116    }
117
118    /**
119     * Returns tag name ready to be displayed
120     *
121     * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
122     * @return string
123     */
124    public function get_display_name($ashtml = true) {
125        return static::make_display_name($this->record, $ashtml);
126    }
127
128    /**
129     * Prepares tag name ready to be displayed
130     *
131     * @param stdClass|core_tag_tag $tag record from db table tag, must contain properties name and rawname
132     * @param bool $ashtml (default true) if true will return htmlspecialchars encoded string
133     * @return string
134     */
135    public static function make_display_name($tag, $ashtml = true) {
136        global $CFG;
137
138        if (empty($CFG->keeptagnamecase)) {
139            // This is the normalized tag name.
140            $tagname = core_text::strtotitle($tag->name);
141        } else {
142            // Original casing of the tag name.
143            $tagname = $tag->rawname;
144        }
145
146        // Clean up a bit just in case the rules change again.
147        $tagname = clean_param($tagname, PARAM_TAG);
148
149        return $ashtml ? htmlspecialchars($tagname) : $tagname;
150    }
151
152    /**
153     * Adds one or more tag in the database.  This function should not be called directly : you should
154     * use tag_set.
155     *
156     * @param   int      $tagcollid
157     * @param   string|array $tags     one tag, or an array of tags, to be created
158     * @param   bool     $isstandard type of tag to be created. A standard tag is kept even if there are no records tagged with it.
159     * @return  array    tag objects indexed by their lowercase normalized names. Any boolean false in the array
160     *                             indicates an error while adding the tag.
161     */
162    protected static function add($tagcollid, $tags, $isstandard = false) {
163        global $USER, $DB;
164
165        $tagobject = new stdClass();
166        $tagobject->isstandard   = $isstandard ? 1 : 0;
167        $tagobject->userid       = $USER->id;
168        $tagobject->timemodified = time();
169        $tagobject->tagcollid    = $tagcollid;
170
171        $rv = array();
172        foreach ($tags as $veryrawname) {
173            $rawname = clean_param($veryrawname, PARAM_TAG);
174            if (!$rawname) {
175                $rv[$rawname] = false;
176            } else {
177                $obj = (object)(array)$tagobject;
178                $obj->rawname = $rawname;
179                $obj->name    = core_text::strtolower($rawname);
180                $obj->id      = $DB->insert_record('tag', $obj);
181                $rv[$obj->name] = new static($obj);
182
183                \core\event\tag_created::create_from_tag($rv[$obj->name])->trigger();
184            }
185        }
186
187        return $rv;
188    }
189
190    /**
191     * Simple function to just return a single tag object by its id
192     *
193     * @param    int    $id
194     * @param    string $returnfields which fields do we want returned from table {tag}.
195     *                        Default value is 'id,name,rawname,tagcollid',
196     *                        specify '*' to include all fields.
197     * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
198     *                        IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
199     *                        MUST_EXIST means throw exception if no record or multiple records found
200     * @return   core_tag_tag|false  tag object
201     */
202    public static function get($id, $returnfields = 'id, name, rawname, tagcollid', $strictness = IGNORE_MISSING) {
203        global $DB;
204        $record = $DB->get_record('tag', array('id' => $id), $returnfields, $strictness);
205        if ($record) {
206            return new static($record);
207        }
208        return false;
209    }
210
211    /**
212     * Simple function to just return an array of tag objects by their ids
213     *
214     * @param    int[]  $ids
215     * @param    string $returnfields which fields do we want returned from table {tag}.
216     *                        Default value is 'id,name,rawname,tagcollid',
217     *                        specify '*' to include all fields.
218     * @return   core_tag_tag[] array of retrieved tags
219     */
220    public static function get_bulk($ids, $returnfields = 'id, name, rawname, tagcollid') {
221        global $DB;
222        $result = array();
223        if (empty($ids)) {
224            return $result;
225        }
226        list($sql, $params) = $DB->get_in_or_equal($ids);
227        $records = $DB->get_records_select('tag', 'id '.$sql, $params, '', $returnfields);
228        foreach ($records as $record) {
229            $result[$record->id] = new static($record);
230        }
231        return $result;
232    }
233
234    /**
235     * Simple function to just return a single tag object by tagcollid and name
236     *
237     * @param int $tagcollid tag collection to use,
238     *        if 0 is given we will try to guess the tag collection and return the first match
239     * @param string $name tag name
240     * @param string $returnfields which fields do we want returned. This is a comma separated string
241     *         containing any combination of 'id', 'name', 'rawname', 'tagcollid' or '*' to include all fields.
242     * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
243     *                        IGNORE_MULTIPLE means return first, ignore multiple records found(not recommended);
244     *                        MUST_EXIST means throw exception if no record or multiple records found
245     * @return core_tag_tag|false tag object
246     */
247    public static function get_by_name($tagcollid, $name, $returnfields='id, name, rawname, tagcollid',
248                        $strictness = IGNORE_MISSING) {
249        global $DB;
250        if ($tagcollid == 0) {
251            $tags = static::guess_by_name($name, $returnfields);
252            if ($tags) {
253                $tag = reset($tags);
254                return $tag;
255            } else if ($strictness == MUST_EXIST) {
256                throw new dml_missing_record_exception('tag', 'name=?', array($name));
257            }
258            return false;
259        }
260        $name = core_text::strtolower($name);   // To cope with input that might just be wrong case.
261        $params = array('name' => $name, 'tagcollid' => $tagcollid);
262        $record = $DB->get_record('tag', $params, $returnfields, $strictness);
263        if ($record) {
264            return new static($record);
265        }
266        return false;
267    }
268
269    /**
270     * Looking in all tag collections for the tag with the given name
271     *
272     * @param string $name tag name
273     * @param string $returnfields
274     * @return array array of core_tag_tag instances
275     */
276    public static function guess_by_name($name, $returnfields='id, name, rawname, tagcollid') {
277        global $DB;
278        if (empty($name)) {
279            return array();
280        }
281        $tagcolls = core_tag_collection::get_collections();
282        list($sql, $params) = $DB->get_in_or_equal(array_keys($tagcolls), SQL_PARAMS_NAMED);
283        $params['name'] = core_text::strtolower($name);
284        $tags = $DB->get_records_select('tag', 'name = :name AND tagcollid ' . $sql, $params, '', $returnfields);
285        if (count($tags) > 1) {
286            // Sort in the same order as tag collections.
287            $tagcolls = core_tag_collection::get_collections();
288            uasort($tags, function($a, $b) use ($tagcolls) {
289                return $tagcolls[$a->tagcollid]->sortorder < $tagcolls[$b->tagcollid]->sortorder ? -1 : 1;
290            });
291        }
292        $rv = array();
293        foreach ($tags as $id => $tag) {
294            $rv[$id] = new static($tag);
295        }
296        return $rv;
297    }
298
299    /**
300     * Returns the list of tag objects by tag collection id and the list of tag names
301     *
302     * @param    int   $tagcollid
303     * @param    array $tags array of tags to look for
304     * @param    string $returnfields list of DB fields to return, must contain 'id', 'name' and 'rawname'
305     * @return   array tag-indexed array of objects. No value for a key means the tag wasn't found.
306     */
307    public static function get_by_name_bulk($tagcollid, $tags, $returnfields = 'id, name, rawname, tagcollid') {
308        global $DB;
309
310        if (empty($tags)) {
311            return array();
312        }
313
314        $cleantags = self::normalize(self::normalize($tags, false)); // Format: rawname => normalised name.
315
316        list($namesql, $params) = $DB->get_in_or_equal(array_values($cleantags));
317        array_unshift($params, $tagcollid);
318
319        $recordset = $DB->get_recordset_sql("SELECT $returnfields FROM {tag} WHERE tagcollid = ? AND name $namesql", $params);
320
321        $result = array_fill_keys($cleantags, null);
322        foreach ($recordset as $record) {
323            $result[$record->name] = new static($record);
324        }
325        $recordset->close();
326        return $result;
327    }
328
329
330    /**
331     * Function that normalizes a list of tag names.
332     *
333     * @param   array        $rawtags array of tags
334     * @param   bool         $tolowercase convert to lower case?
335     * @return  array        lowercased normalized tags, indexed by the normalized tag, in the same order as the original array.
336     *                       (Eg: 'Banana' => 'banana').
337     */
338    public static function normalize($rawtags, $tolowercase = true) {
339        $result = array();
340        foreach ($rawtags as $rawtag) {
341            $rawtag = trim($rawtag);
342            if (strval($rawtag) !== '') {
343                $clean = clean_param($rawtag, PARAM_TAG);
344                if ($tolowercase) {
345                    $result[$rawtag] = core_text::strtolower($clean);
346                } else {
347                    $result[$rawtag] = $clean;
348                }
349            }
350        }
351        return $result;
352    }
353
354    /**
355     * Retrieves tags and/or creates them if do not exist yet
356     *
357     * @param int $tagcollid
358     * @param array $tags array of raw tag names, do not have to be normalised
359     * @param bool $isstandard create as standard tag (default false)
360     * @return core_tag_tag[] array of tag objects indexed with lowercase normalised tag name
361     */
362    public static function create_if_missing($tagcollid, $tags, $isstandard = false) {
363        $cleantags = self::normalize(array_filter(self::normalize($tags, false))); // Array rawname => normalised name .
364
365        $result = static::get_by_name_bulk($tagcollid, $tags, '*');
366        $existing = array_filter($result);
367        $missing = array_diff_key(array_flip($cleantags), $existing); // Array normalised name => rawname.
368        if ($missing) {
369            $newtags = static::add($tagcollid, array_values($missing), $isstandard);
370            foreach ($newtags as $tag) {
371                $result[$tag->name] = $tag;
372            }
373        }
374        return $result;
375    }
376
377    /**
378     * Creates a URL to view a tag
379     *
380     * @param int $tagcollid
381     * @param string $name
382     * @param int $exclusivemode
383     * @param int $fromctx context id where this tag cloud is displayed
384     * @param int $ctx context id for tag view link
385     * @param int $rec recursive argument for tag view link
386     * @return \moodle_url
387     */
388    public static function make_url($tagcollid, $name, $exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
389        $coll = core_tag_collection::get_by_id($tagcollid);
390        if (!empty($coll->customurl)) {
391            $url = '/' . ltrim(trim($coll->customurl), '/');
392        } else {
393            $url = '/tag/index.php';
394        }
395        $params = array('tc' => $tagcollid, 'tag' => $name);
396        if ($exclusivemode) {
397            $params['excl'] = 1;
398        }
399        if ($fromctx) {
400            $params['from'] = $fromctx;
401        }
402        if ($ctx) {
403            $params['ctx'] = $ctx;
404        }
405        if (!$rec) {
406            $params['rec'] = 0;
407        }
408        return new moodle_url($url, $params);
409    }
410
411    /**
412     * Returns URL to view the tag
413     *
414     * @param int $exclusivemode
415     * @param int $fromctx context id where this tag cloud is displayed
416     * @param int $ctx context id for tag view link
417     * @param int $rec recursive argument for tag view link
418     * @return \moodle_url
419     */
420    public function get_view_url($exclusivemode = 0, $fromctx = 0, $ctx = 0, $rec = 1) {
421        return static::make_url($this->record->tagcollid, $this->record->rawname,
422            $exclusivemode, $fromctx, $ctx, $rec);
423    }
424
425    /**
426     * Validates that the required fields were retrieved and retrieves them if missing
427     *
428     * @param array $list array of the fields that need to be validated
429     * @param string $caller name of the function that requested it, for the debugging message
430     */
431    protected function ensure_fields_exist($list, $caller) {
432        global $DB;
433        $missing = array_diff($list, array_keys((array)$this->record));
434        if ($missing) {
435            debugging('core_tag_tag::' . $caller . '() must be called on fully retrieved tag object. Missing fields: '.
436                    join(', ', $missing), DEBUG_DEVELOPER);
437            $this->record = $DB->get_record('tag', array('id' => $this->record->id), '*', MUST_EXIST);
438        }
439    }
440
441    /**
442     * Deletes the tag instance given the record from tag_instance DB table
443     *
444     * @param stdClass $taginstance
445     * @param bool $fullobject whether $taginstance contains all fields from DB table tag_instance
446     *          (in this case it is safe to add a record snapshot to the event)
447     * @return bool
448     */
449    protected function delete_instance_as_record($taginstance, $fullobject = false) {
450        global $DB;
451
452        $this->ensure_fields_exist(array('name', 'rawname', 'isstandard'), 'delete_instance_as_record');
453
454        $DB->delete_records('tag_instance', array('id' => $taginstance->id));
455
456        // We can not fire an event with 'null' as the contextid.
457        if (is_null($taginstance->contextid)) {
458            $taginstance->contextid = context_system::instance()->id;
459        }
460
461        // Trigger tag removed event.
462        $taginstance->tagid = $this->id;
463        \core\event\tag_removed::create_from_tag_instance($taginstance, $this->name, $this->rawname, $fullobject)->trigger();
464
465        // If there are no other instances of the tag then consider deleting the tag as well.
466        if (!$this->isstandard) {
467            if (!$DB->record_exists('tag_instance', array('tagid' => $this->id))) {
468                self::delete_tags($this->id);
469            }
470        }
471
472        return true;
473    }
474
475    /**
476     * Delete one instance of a tag.  If the last instance was deleted, it will also delete the tag, unless it is standard.
477     *
478     * @param    string $component component responsible for tagging. For BC it can be empty but in this case the
479     *                  query will be slow because DB index will not be used.
480     * @param    string $itemtype the type of the record for which to remove the instance
481     * @param    int    $itemid   the id of the record for which to remove the instance
482     * @param    int    $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
483     */
484    protected function delete_instance($component, $itemtype, $itemid, $tiuserid = 0) {
485        global $DB;
486        $params = array('tagid' => $this->id,
487                'itemtype' => $itemtype, 'itemid' => $itemid);
488        if ($tiuserid) {
489            $params['tiuserid'] = $tiuserid;
490        }
491        if ($component) {
492            $params['component'] = $component;
493        }
494
495        $taginstance = $DB->get_record('tag_instance', $params);
496        if (!$taginstance) {
497            return;
498        }
499        $this->delete_instance_as_record($taginstance, true);
500    }
501
502    /**
503     * Bulk delete all tag instances.
504     *
505     * @param stdClass[] $taginstances A list of tag_instance records to delete. Each
506     *                                 record must also contain the name and rawname
507     *                                 columns from the related tag record.
508     */
509    public static function delete_instances_as_record(array $taginstances) {
510        global $DB;
511
512        if (empty($taginstances)) {
513            return;
514        }
515
516        $taginstanceids = array_map(function($taginstance) {
517            return $taginstance->id;
518        }, $taginstances);
519        // Now remove all the tag instances.
520        $DB->delete_records_list('tag_instance', 'id', $taginstanceids);
521        // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
522        $syscontextid = context_system::instance()->id;
523        // Loop through the tag instances and fire an 'tag_removed' event.
524        foreach ($taginstances as $taginstance) {
525            // We can not fire an event with 'null' as the contextid.
526            if (is_null($taginstance->contextid)) {
527                $taginstance->contextid = $syscontextid;
528            }
529
530            // Trigger tag removed event.
531            \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name,
532                    $taginstance->rawname, true)->trigger();
533        }
534    }
535
536    /**
537     * Bulk delete all tag instances by tag id.
538     *
539     * @param int[] $taginstanceids List of tag instance ids to be deleted.
540     */
541    public static function delete_instances_by_id(array $taginstanceids) {
542        global $DB;
543
544        if (empty($taginstanceids)) {
545            return;
546        }
547
548        list($idsql, $params) = $DB->get_in_or_equal($taginstanceids);
549        $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
550                  FROM {tag_instance} ti
551                  JOIN {tag} t
552                    ON ti.tagid = t.id
553                 WHERE ti.id {$idsql}";
554
555        if ($taginstances = $DB->get_records_sql($sql, $params)) {
556            static::delete_instances_as_record($taginstances);
557        }
558    }
559
560    /**
561     * Bulk delete all tag instances for a component or tag area
562     *
563     * @param string $component
564     * @param string $itemtype (optional)
565     * @param int $contextid (optional)
566     */
567    public static function delete_instances($component, $itemtype = null, $contextid = null) {
568        global $DB;
569
570        $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard
571                  FROM {tag_instance} ti
572                  JOIN {tag} t
573                    ON ti.tagid = t.id
574                 WHERE ti.component = :component";
575        $params = array('component' => $component);
576        if (!is_null($contextid)) {
577            $sql .= " AND ti.contextid = :contextid";
578            $params['contextid'] = $contextid;
579        }
580        if (!is_null($itemtype)) {
581            $sql .= " AND ti.itemtype = :itemtype";
582            $params['itemtype'] = $itemtype;
583        }
584
585        if ($taginstances = $DB->get_records_sql($sql, $params)) {
586            static::delete_instances_as_record($taginstances);
587        }
588    }
589
590    /**
591     * Adds a tag instance
592     *
593     * @param string $component
594     * @param string $itemtype
595     * @param string $itemid
596     * @param context $context
597     * @param int $ordering
598     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
599     * @return int id of tag_instance
600     */
601    protected function add_instance($component, $itemtype, $itemid, context $context, $ordering, $tiuserid = 0) {
602        global $DB;
603        $this->ensure_fields_exist(array('name', 'rawname'), 'add_instance');
604
605        $taginstance = new stdClass;
606        $taginstance->tagid        = $this->id;
607        $taginstance->component    = $component ? $component : '';
608        $taginstance->itemid       = $itemid;
609        $taginstance->itemtype     = $itemtype;
610        $taginstance->contextid    = $context->id;
611        $taginstance->ordering     = $ordering;
612        $taginstance->timecreated  = time();
613        $taginstance->timemodified = $taginstance->timecreated;
614        $taginstance->tiuserid     = $tiuserid;
615
616        $taginstance->id = $DB->insert_record('tag_instance', $taginstance);
617
618        // Trigger tag added event.
619        \core\event\tag_added::create_from_tag_instance($taginstance, $this->name, $this->rawname, true)->trigger();
620
621        return $taginstance->id;
622    }
623
624    /**
625     * Updates the ordering on tag instance
626     *
627     * @param int $instanceid
628     * @param int $ordering
629     */
630    protected function update_instance_ordering($instanceid, $ordering) {
631        global $DB;
632        $data = new stdClass();
633        $data->id = $instanceid;
634        $data->ordering = $ordering;
635        $data->timemodified = time();
636
637        $DB->update_record('tag_instance', $data);
638    }
639
640    /**
641     * Get the array of core_tag_tag objects associated with a list of items.
642     *
643     * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
644     *
645     * @param string $component component responsible for tagging. For BC it can be empty but in this case the
646     *               query will be slow because DB index will not be used.
647     * @param string $itemtype type of the tagged item
648     * @param int[] $itemids
649     * @param int $standardonly wether to return only standard tags or any
650     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
651     * @return core_tag_tag[][] first array key is itemid. For each itemid,
652     *      an array tagid => tag object with additional fields taginstanceid, taginstancecontextid and ordering
653     */
654    public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT,
655            $tiuserid = 0) {
656        global $DB;
657
658        if (static::is_enabled($component, $itemtype) === false) {
659            // Tagging area is properly defined but not enabled - return empty array.
660            return array();
661        }
662
663        if (empty($itemids)) {
664            return array();
665        }
666
667        $standardonly = (int)$standardonly; // In case somebody passed bool.
668
669        list($idsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
670        // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags().
671        $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
672                    tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
673                  FROM {tag_instance} ti
674                  JOIN {tag} tg ON tg.id = ti.tagid
675                  WHERE ti.itemtype = :itemtype AND ti.itemid $idsql ".
676                ($component ? "AND ti.component = :component " : "").
677                ($tiuserid ? "AND ti.tiuserid = :tiuserid " : "").
678                (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : "").
679                (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : "").
680               "ORDER BY ti.ordering ASC, ti.id";
681
682        $params['itemtype'] = $itemtype;
683        $params['component'] = $component;
684        $params['tiuserid'] = $tiuserid;
685
686        $records = $DB->get_records_sql($sql, $params);
687        $result = array();
688        foreach ($itemids as $itemid) {
689            $result[$itemid] = [];
690        }
691        foreach ($records as $id => $record) {
692            $result[$record->itemid][$id] = new static($record);
693        }
694        return $result;
695    }
696
697    /**
698     * Get the array of core_tag_tag objects associated with an item (instances).
699     *
700     * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array.
701     *
702     * @param string $component component responsible for tagging. For BC it can be empty but in this case the
703     *               query will be slow because DB index will not be used.
704     * @param string $itemtype type of the tagged item
705     * @param int $itemid
706     * @param int $standardonly wether to return only standard tags or any
707     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
708     * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
709     */
710    public static function get_item_tags($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
711            $tiuserid = 0) {
712        $tagobjects = static::get_items_tags($component, $itemtype, [$itemid], $standardonly, $tiuserid);
713        return empty($tagobjects) ? [] : $tagobjects[$itemid];
714    }
715
716    /**
717     * Returns the list of display names of the tags that are associated with an item
718     *
719     * This method is usually used to prefill the form data for the 'tags' form element
720     *
721     * @param string $component component responsible for tagging. For BC it can be empty but in this case the
722     *               query will be slow because DB index will not be used.
723     * @param string $itemtype type of the tagged item
724     * @param int $itemid
725     * @param int $standardonly wether to return only standard tags or any
726     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
727     * @param bool $ashtml (default true) if true will return htmlspecialchars encoded tag names
728     * @return string[] array of tags display names
729     */
730    public static function get_item_tags_array($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT,
731            $tiuserid = 0, $ashtml = true) {
732        $tags = array();
733        foreach (static::get_item_tags($component, $itemtype, $itemid, $standardonly, $tiuserid) as $tag) {
734            $tags[$tag->id] = $tag->get_display_name($ashtml);
735        }
736        return $tags;
737    }
738
739    /**
740     * Sets the list of tag instances for one item (table record).
741     *
742     * Extra exsisting instances are removed, new ones are added. New tags are created if needed.
743     *
744     * This method can not be used for setting tags relations, please use set_related_tags()
745     *
746     * @param string $component component responsible for tagging
747     * @param string $itemtype type of the tagged item
748     * @param int $itemid
749     * @param context $context
750     * @param array $tagnames
751     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
752     */
753    public static function set_item_tags($component, $itemtype, $itemid, context $context, $tagnames, $tiuserid = 0) {
754        if ($itemtype === 'tag') {
755            if ($tiuserid) {
756                throw new coding_exception('Related tags can not have tag instance userid');
757            }
758            debugging('You can not use set_item_tags() for tagging a tag, please use set_related_tags()', DEBUG_DEVELOPER);
759            static::get($itemid, '*', MUST_EXIST)->set_related_tags($tagnames);
760            return;
761        }
762
763        if ($tagnames !== null && static::is_enabled($component, $itemtype) === false) {
764            // Tagging area is properly defined but not enabled - do nothing.
765            // Unless we are deleting the item tags ($tagnames === null), in which case proceed with deleting.
766            return;
767        }
768
769        // Apply clean_param() to all tags.
770        if ($tagnames) {
771            $tagcollid = core_tag_area::get_collection($component, $itemtype);
772            $tagobjects = static::create_if_missing($tagcollid, $tagnames);
773        } else {
774            $tagobjects = array();
775        }
776
777        $allowmultiplecontexts = core_tag_area::allows_tagging_in_multiple_contexts($component, $itemtype);
778        $currenttags = static::get_item_tags($component, $itemtype, $itemid, self::BOTH_STANDARD_AND_NOT, $tiuserid);
779        $taginstanceidstomovecontext = [];
780
781        // For data coherence reasons, it's better to remove deleted tags
782        // before adding new data: ordering could be duplicated.
783        foreach ($currenttags as $currenttag) {
784            $hasbeenrequested = array_key_exists($currenttag->name, $tagobjects);
785            $issamecontext = $currenttag->taginstancecontextid == $context->id;
786
787            if ($allowmultiplecontexts) {
788                // If the tag area allows multiple contexts then we should only be
789                // managing tags in the given $context. All other tags can be ignored.
790                $shoulddelete = $issamecontext && !$hasbeenrequested;
791            } else {
792                // If the tag area only allows tag instances in a single context then
793                // all tags that aren't in the requested tags should be deleted, regardless
794                // of their context, if they are not part of the new set of tags.
795                $shoulddelete = !$hasbeenrequested;
796                // If the tag instance isn't in the correct context (legacy data)
797                // then we should take this opportunity to update it with the correct
798                // context id.
799                if (!$shoulddelete && !$issamecontext) {
800                    $currenttag->taginstancecontextid = $context->id;
801                    $taginstanceidstomovecontext[] = $currenttag->taginstanceid;
802                }
803            }
804
805            if ($shoulddelete) {
806                $taginstance = (object)array('id' => $currenttag->taginstanceid,
807                    'itemtype' => $itemtype, 'itemid' => $itemid,
808                    'contextid' => $currenttag->taginstancecontextid, 'tiuserid' => $tiuserid);
809                $currenttag->delete_instance_as_record($taginstance, false);
810            }
811        }
812
813        if (!empty($taginstanceidstomovecontext)) {
814            static::change_instances_context($taginstanceidstomovecontext, $context);
815        }
816
817        $ordering = -1;
818        foreach ($tagobjects as $name => $tag) {
819            $ordering++;
820            foreach ($currenttags as $currenttag) {
821                $namesmatch = strval($currenttag->name) === strval($name);
822
823                if ($allowmultiplecontexts) {
824                    // If the tag area allows multiple contexts then we should only
825                    // skip adding a new instance if the existing one is in the correct
826                    // context.
827                    $contextsmatch = $currenttag->taginstancecontextid == $context->id;
828                    $shouldskipinstance = $namesmatch && $contextsmatch;
829                } else {
830                    // The existing behaviour for single context tag areas is to
831                    // skip adding a new instance regardless of whether the existing
832                    // instance is in the same context as the provided $context.
833                    $shouldskipinstance = $namesmatch;
834                }
835
836                if ($shouldskipinstance) {
837                    if ($currenttag->ordering != $ordering) {
838                        $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering);
839                    }
840                    continue 2;
841                }
842            }
843            $tag->add_instance($component, $itemtype, $itemid, $context, $ordering, $tiuserid);
844        }
845    }
846
847    /**
848     * Removes all tags from an item.
849     *
850     * All tags will be removed even if tagging is disabled in this area. This is
851     * usually called when the item itself has been deleted.
852     *
853     * @param string $component component responsible for tagging
854     * @param string $itemtype type of the tagged item
855     * @param int $itemid
856     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
857     */
858    public static function remove_all_item_tags($component, $itemtype, $itemid, $tiuserid = 0) {
859        $context = context_system::instance(); // Context will not be used.
860        static::set_item_tags($component, $itemtype, $itemid, $context, null, $tiuserid);
861    }
862
863    /**
864     * Adds a tag to an item, without overwriting the current tags.
865     *
866     * If the tag has already been added to the record, no changes are made.
867     *
868     * @param string $component the component that was tagged
869     * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
870     * @param int $itemid the id of the record to tag
871     * @param context $context the context of where this tag was assigned
872     * @param string $tagname the tag to add
873     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
874     * @return int id of tag_instance that was either created or already existed or null if tagging is not enabled
875     */
876    public static function add_item_tag($component, $itemtype, $itemid, context $context, $tagname, $tiuserid = 0) {
877        global $DB;
878
879        if (static::is_enabled($component, $itemtype) === false) {
880            // Tagging area is properly defined but not enabled - do nothing.
881            return null;
882        }
883
884        $rawname = clean_param($tagname, PARAM_TAG);
885        $normalisedname = core_text::strtolower($rawname);
886        $tagcollid = core_tag_area::get_collection($component, $itemtype);
887
888        $usersql = $tiuserid ? " AND ti.tiuserid = :tiuserid " : "";
889        $sql = 'SELECT t.*, ti.id AS taginstanceid
890                FROM {tag} t
891                LEFT JOIN {tag_instance} ti ON ti.tagid = t.id AND ti.itemtype = :itemtype '.
892                $usersql .
893                'AND ti.itemid = :itemid AND ti.component = :component
894                WHERE t.name = :name AND t.tagcollid = :tagcollid';
895        $params = array('name' => $normalisedname, 'tagcollid' => $tagcollid, 'itemtype' => $itemtype,
896            'itemid' => $itemid, 'component' => $component, 'tiuserid' => $tiuserid);
897        $record = $DB->get_record_sql($sql, $params);
898        if ($record) {
899            if ($record->taginstanceid) {
900                // Tag was already added to the item, nothing to do here.
901                return $record->taginstanceid;
902            }
903            $tag = new static($record);
904        } else {
905            // The tag does not exist yet, create it.
906            $tags = static::add($tagcollid, array($tagname));
907            $tag = reset($tags);
908        }
909
910        $ordering = $DB->get_field_sql('SELECT MAX(ordering) FROM {tag_instance} ti
911                WHERE ti.itemtype = :itemtype AND ti.itemid = :itemid AND
912                ti.component = :component' . $usersql, $params);
913
914        return $tag->add_instance($component, $itemtype, $itemid, $context, $ordering + 1, $tiuserid);
915    }
916
917    /**
918     * Removes the tag from an item without changing the other tags
919     *
920     * @param string $component the component that was tagged
921     * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
922     * @param int $itemid the id of the record to tag
923     * @param string $tagname the tag to remove
924     * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging (such as core/course)
925     */
926    public static function remove_item_tag($component, $itemtype, $itemid, $tagname, $tiuserid = 0) {
927        global $DB;
928
929        if (static::is_enabled($component, $itemtype) === false) {
930            // Tagging area is properly defined but not enabled - do nothing.
931            return array();
932        }
933
934        $rawname = clean_param($tagname, PARAM_TAG);
935        $normalisedname = core_text::strtolower($rawname);
936
937        $usersql = $tiuserid ? " AND tiuserid = :tiuserid " : "";
938        $componentsql = $component ? " AND ti.component = :component " : "";
939        $sql = 'SELECT t.*, ti.id AS taginstanceid, ti.contextid AS taginstancecontextid, ti.ordering
940                FROM {tag} t JOIN {tag_instance} ti ON ti.tagid = t.id ' . $usersql . '
941                WHERE t.name = :name AND ti.itemtype = :itemtype
942                AND ti.itemid = :itemid ' . $componentsql;
943        $params = array('name' => $normalisedname,
944            'itemtype' => $itemtype, 'itemid' => $itemid, 'component' => $component,
945            'tiuserid' => $tiuserid);
946        if ($record = $DB->get_record_sql($sql, $params)) {
947            $taginstance = (object)array('id' => $record->taginstanceid,
948                'itemtype' => $itemtype, 'itemid' => $itemid,
949                'contextid' => $record->taginstancecontextid, 'tiuserid' => $tiuserid);
950            $tag = new static($record);
951            $tag->delete_instance_as_record($taginstance, false);
952            $componentsql = $component ? " AND component = :component " : "";
953            $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
954                    WHERE itemtype = :itemtype
955                AND itemid = :itemid $componentsql $usersql
956                AND ordering > :ordering";
957            $params['ordering'] = $record->ordering;
958            $DB->execute($sql, $params);
959        }
960    }
961
962    /**
963     * Allows to move all tag instances from one context to another
964     *
965     * @param string $component the component that was tagged
966     * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
967     * @param context $oldcontext
968     * @param context $newcontext
969     */
970    public static function move_context($component, $itemtype, $oldcontext, $newcontext) {
971        global $DB;
972        if ($oldcontext instanceof context) {
973            $oldcontext = $oldcontext->id;
974        }
975        if ($newcontext instanceof context) {
976            $newcontext = $newcontext->id;
977        }
978        $DB->set_field('tag_instance', 'contextid', $newcontext,
979                array('component' => $component, 'itemtype' => $itemtype, 'contextid' => $oldcontext));
980    }
981
982    /**
983     * Moves all tags of the specified items to the new context
984     *
985     * @param string $component the component that was tagged
986     * @param string $itemtype the type of record to tag ('post' for blogs, 'user' for users, etc.)
987     * @param array $itemids
988     * @param context|int $newcontext target context to move tags to
989     */
990    public static function change_items_context($component, $itemtype, $itemids, $newcontext) {
991        global $DB;
992        if (empty($itemids)) {
993            return;
994        }
995        if (!is_array($itemids)) {
996            $itemids = array($itemids);
997        }
998        list($sql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
999        $params['component'] = $component;
1000        $params['itemtype'] = $itemtype;
1001        if ($newcontext instanceof context) {
1002            $newcontext = $newcontext->id;
1003        }
1004
1005        $DB->set_field_select('tag_instance', 'contextid', $newcontext,
1006            'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params);
1007    }
1008
1009    /**
1010     * Moves all of the specified tag instances into a new context.
1011     *
1012     * @param array $taginstanceids The list of tag instance ids that should be moved
1013     * @param context $newcontext The context to move the tag instances into
1014     */
1015    public static function change_instances_context(array $taginstanceids, context $newcontext) {
1016        global $DB;
1017
1018        if (empty($taginstanceids)) {
1019            return;
1020        }
1021
1022        list($sql, $params) = $DB->get_in_or_equal($taginstanceids);
1023        $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params);
1024    }
1025
1026    /**
1027     * Updates the information about the tag
1028     *
1029     * @param array|stdClass $data data to update, may contain: isstandard, description, descriptionformat, rawname
1030     * @return bool whether the tag was updated. False may be returned if: all new values match the existing,
1031     *         or it was attempted to rename the tag to the name that is already used.
1032     */
1033    public function update($data) {
1034        global $DB, $COURSE;
1035
1036        $allowedfields = array('isstandard', 'description', 'descriptionformat', 'rawname');
1037
1038        $data = (array)$data;
1039        if ($extrafields = array_diff(array_keys($data), $allowedfields)) {
1040            debugging('The field(s) '.join(', ', $extrafields).' will be ignored when updating the tag',
1041                    DEBUG_DEVELOPER);
1042        }
1043        $data = array_intersect_key($data, array_fill_keys($allowedfields, 1));
1044        $this->ensure_fields_exist(array_merge(array('tagcollid', 'userid', 'name', 'rawname'), array_keys($data)), 'update');
1045
1046        // Validate the tag name.
1047        if (array_key_exists('rawname', $data)) {
1048            $data['rawname'] = clean_param($data['rawname'], PARAM_TAG);
1049            $name = core_text::strtolower($data['rawname']);
1050
1051            if (!$name || $data['rawname'] === $this->rawname) {
1052                unset($data['rawname']);
1053            } else if ($existing = static::get_by_name($this->tagcollid, $name, 'id')) {
1054                // Prevent the rename if a tag with that name already exists.
1055                if ($existing->id != $this->id) {
1056                    throw new moodle_exception('namesalreadybeeingused', 'core_tag');
1057                }
1058            }
1059            if (isset($data['rawname'])) {
1060                $data['name'] = $name;
1061            }
1062        }
1063
1064        // Validate the tag type.
1065        if (array_key_exists('isstandard', $data)) {
1066            $data['isstandard'] = $data['isstandard'] ? 1 : 0;
1067        }
1068
1069        // Find only the attributes that need to be changed.
1070        $originalname = $this->name;
1071        foreach ($data as $key => $value) {
1072            if ($this->record->$key !== $value) {
1073                $this->record->$key = $value;
1074            } else {
1075                unset($data[$key]);
1076            }
1077        }
1078        if (empty($data)) {
1079            return false;
1080        }
1081
1082        $data['id'] = $this->id;
1083        $data['timemodified'] = time();
1084        $DB->update_record('tag', $data);
1085
1086        $event = \core\event\tag_updated::create(array(
1087            'objectid' => $this->id,
1088            'relateduserid' => $this->userid,
1089            'context' => context_system::instance(),
1090            'other' => array(
1091                'name' => $this->name,
1092                'rawname' => $this->rawname
1093            )
1094        ));
1095        if (isset($data['rawname'])) {
1096            $event->set_legacy_logdata(array($COURSE->id, 'tag', 'update', 'index.php?id='. $this->id,
1097                $originalname . '->'. $this->name));
1098        }
1099        $event->trigger();
1100        return true;
1101    }
1102
1103    /**
1104     * Flag a tag as inappropriate
1105     */
1106    public function flag() {
1107        global $DB;
1108
1109        $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1110
1111        // Update all the tags to flagged.
1112        $this->timemodified = time();
1113        $this->flag++;
1114        $DB->update_record('tag', array('timemodified' => $this->timemodified,
1115            'flag' => $this->flag, 'id' => $this->id));
1116
1117        $event = \core\event\tag_flagged::create(array(
1118            'objectid' => $this->id,
1119            'relateduserid' => $this->userid,
1120            'context' => context_system::instance(),
1121            'other' => array(
1122                'name' => $this->name,
1123                'rawname' => $this->rawname
1124            )
1125
1126        ));
1127        $event->trigger();
1128    }
1129
1130    /**
1131     * Remove the inappropriate flag on a tag.
1132     */
1133    public function reset_flag() {
1134        global $DB;
1135
1136        $this->ensure_fields_exist(array('name', 'userid', 'rawname', 'flag'), 'flag');
1137
1138        if (!$this->flag) {
1139            // Nothing to do.
1140            return false;
1141        }
1142
1143        $this->timemodified = time();
1144        $this->flag = 0;
1145        $DB->update_record('tag', array('timemodified' => $this->timemodified,
1146            'flag' => 0, 'id' => $this->id));
1147
1148        $event = \core\event\tag_unflagged::create(array(
1149            'objectid' => $this->id,
1150            'relateduserid' => $this->userid,
1151            'context' => context_system::instance(),
1152            'other' => array(
1153                'name' => $this->name,
1154                'rawname' => $this->rawname
1155            )
1156        ));
1157        $event->trigger();
1158    }
1159
1160    /**
1161     * Sets the list of tags related to this one.
1162     *
1163     * Tag relations are recorded by two instances linking two tags to each other.
1164     * For tag relations ordering is not used and may be random.
1165     *
1166     * @param array $tagnames
1167     */
1168    public function set_related_tags($tagnames) {
1169        $context = context_system::instance();
1170        $tagobjects = $tagnames ? static::create_if_missing($this->tagcollid, $tagnames) : array();
1171        unset($tagobjects[$this->name]); // Never link to itself.
1172
1173        $currenttags = static::get_item_tags('core', 'tag', $this->id);
1174
1175        // For data coherence reasons, it's better to remove deleted tags
1176        // before adding new data: ordering could be duplicated.
1177        foreach ($currenttags as $currenttag) {
1178            if (!array_key_exists($currenttag->name, $tagobjects)) {
1179                $taginstance = (object)array('id' => $currenttag->taginstanceid,
1180                    'itemtype' => 'tag', 'itemid' => $this->id,
1181                    'contextid' => $context->id);
1182                $currenttag->delete_instance_as_record($taginstance, false);
1183                $this->delete_instance('core', 'tag', $currenttag->id);
1184            }
1185        }
1186
1187        foreach ($tagobjects as $name => $tag) {
1188            foreach ($currenttags as $currenttag) {
1189                if ($currenttag->name === $name) {
1190                    continue 2;
1191                }
1192            }
1193            $this->add_instance('core', 'tag', $tag->id, $context, 0);
1194            $tag->add_instance('core', 'tag', $this->id, $context, 0);
1195            $currenttags[] = $tag;
1196        }
1197    }
1198
1199    /**
1200     * Adds to the list of related tags without removing existing
1201     *
1202     * Tag relations are recorded by two instances linking two tags to each other.
1203     * For tag relations ordering is not used and may be random.
1204     *
1205     * @param array $tagnames
1206     */
1207    public function add_related_tags($tagnames) {
1208        $context = context_system::instance();
1209        $tagobjects = static::create_if_missing($this->tagcollid, $tagnames);
1210
1211        $currenttags = static::get_item_tags('core', 'tag', $this->id);
1212
1213        foreach ($tagobjects as $name => $tag) {
1214            foreach ($currenttags as $currenttag) {
1215                if ($currenttag->name === $name) {
1216                    continue 2;
1217                }
1218            }
1219            $this->add_instance('core', 'tag', $tag->id, $context, 0);
1220            $tag->add_instance('core', 'tag', $this->id, $context, 0);
1221            $currenttags[] = $tag;
1222        }
1223    }
1224
1225    /**
1226     * Returns the correlated tags of a tag, retrieved from the tag_correlation table.
1227     *
1228     * Correlated tags are calculated in cron based on existing tag instances.
1229     *
1230     * @param bool $keepduplicates if true, will return one record for each existing
1231     *      tag instance which may result in duplicates of the actual tags
1232     * @return core_tag_tag[] an array of tag objects
1233     */
1234    public function get_correlated_tags($keepduplicates = false) {
1235        global $DB;
1236
1237        $correlated = $DB->get_field('tag_correlation', 'correlatedtags', array('tagid' => $this->id));
1238
1239        if (!$correlated) {
1240            return array();
1241        }
1242        $correlated = preg_split('/\s*,\s*/', trim($correlated), -1, PREG_SPLIT_NO_EMPTY);
1243        list($query, $params) = $DB->get_in_or_equal($correlated);
1244
1245        // This is (and has to) return the same fields as the query in core_tag_tag::get_item_tags().
1246        $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
1247                tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid
1248              FROM {tag} tg
1249        INNER JOIN {tag_instance} ti ON tg.id = ti.tagid
1250             WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ?
1251          ORDER BY ti.ordering ASC, ti.id";
1252        $params[] = $this->id;
1253        $params[] = $this->tagcollid;
1254        $records = $DB->get_records_sql($sql, $params);
1255        $seen = array();
1256        $result = array();
1257        foreach ($records as $id => $record) {
1258            if (!$keepduplicates && !empty($seen[$record->id])) {
1259                continue;
1260            }
1261            $result[$id] = new static($record);
1262            $seen[$record->id] = true;
1263        }
1264        return $result;
1265    }
1266
1267    /**
1268     * Returns tags that this tag was manually set as related to
1269     *
1270     * @return core_tag_tag[]
1271     */
1272    public function get_manual_related_tags() {
1273        return self::get_item_tags('core', 'tag', $this->id);
1274    }
1275
1276    /**
1277     * Returns tags related to a tag
1278     *
1279     * Related tags of a tag come from two sources:
1280     *   - manually added related tags, which are tag_instance entries for that tag
1281     *   - correlated tags, which are calculated
1282     *
1283     * @return core_tag_tag[] an array of tag objects
1284     */
1285    public function get_related_tags() {
1286        $manual = $this->get_manual_related_tags();
1287        $automatic = $this->get_correlated_tags();
1288        $relatedtags = array_merge($manual, $automatic);
1289
1290        // Remove duplicated tags (multiple instances of the same tag).
1291        $seen = array();
1292        foreach ($relatedtags as $instance => $tag) {
1293            if (isset($seen[$tag->id])) {
1294                unset($relatedtags[$instance]);
1295            } else {
1296                $seen[$tag->id] = 1;
1297            }
1298        }
1299
1300        return $relatedtags;
1301    }
1302
1303    /**
1304     * Find all items tagged with a tag of a given type ('post', 'user', etc.)
1305     *
1306     * @param    string   $component component responsible for tagging. For BC it can be empty but in this case the
1307     *                    query will be slow because DB index will not be used.
1308     * @param    string   $itemtype  type to restrict search to
1309     * @param    int      $limitfrom (optional, required if $limitnum is set) return a subset of records, starting at this point.
1310     * @param    int      $limitnum  (optional, required if $limitfrom is set) return a subset comprising this many records.
1311     * @param    string   $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1312     * @param    array    $params additional parameters for the DB query
1313     * @return   array of matching objects, indexed by record id, from the table containing the type requested
1314     */
1315    public function get_tagged_items($component, $itemtype, $limitfrom = '', $limitnum = '', $subquery = '', $params = array()) {
1316        global $DB;
1317
1318        if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1319            return array();
1320        }
1321        $params = $params ? $params : array();
1322
1323        $query = "SELECT it.*
1324                    FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1325                   WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1326        $params['itemtype'] = $itemtype;
1327        $params['tagid'] = $this->id;
1328        if ($component) {
1329            $query .= ' AND tt.component = :component';
1330            $params['component'] = $component;
1331        }
1332        if ($subquery) {
1333            $query .= ' AND ' . $subquery;
1334        }
1335        $query .= ' ORDER BY it.id';
1336
1337        return $DB->get_records_sql($query, $params, $limitfrom, $limitnum);
1338    }
1339
1340    /**
1341     * Count how many items are tagged with a specific tag.
1342     *
1343     * @param    string   $component component responsible for tagging. For BC it can be empty but in this case the
1344     *                    query will be slow because DB index will not be used.
1345     * @param    string   $itemtype  type to restrict search to
1346     * @param    string   $subquery additional query to be appended to WHERE clause, refer to the itemtable as 'it'
1347     * @param    array    $params additional parameters for the DB query
1348     * @return   int      number of mathing tags.
1349     */
1350    public function count_tagged_items($component, $itemtype, $subquery = '', $params = array()) {
1351        global $DB;
1352
1353        if (empty($itemtype) || !$DB->get_manager()->table_exists($itemtype)) {
1354            return 0;
1355        }
1356        $params = $params ? $params : array();
1357
1358        $query = "SELECT COUNT(it.id)
1359                    FROM {".$itemtype."} it INNER JOIN {tag_instance} tt ON it.id = tt.itemid
1360                   WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid";
1361        $params['itemtype'] = $itemtype;
1362        $params['tagid'] = $this->id;
1363        if ($component) {
1364            $query .= ' AND tt.component = :component';
1365            $params['component'] = $component;
1366        }
1367        if ($subquery) {
1368            $query .= ' AND ' . $subquery;
1369        }
1370
1371        return $DB->get_field_sql($query, $params);
1372    }
1373
1374    /**
1375     * Determine if an item is tagged with a specific tag
1376     *
1377     * Note that this is a static method and not a method of core_tag object because the tag might not exist yet,
1378     * for example user searches for "php" and we offer him to add "php" to his interests.
1379     *
1380     * @param   string   $component component responsible for tagging. For BC it can be empty but in this case the
1381     *                   query will be slow because DB index will not be used.
1382     * @param   string   $itemtype    the record type to look for
1383     * @param   int      $itemid      the record id to look for
1384     * @param   string   $tagname     a tag name
1385     * @return  int                   1 if it is tagged, 0 otherwise
1386     */
1387    public static function is_item_tagged_with($component, $itemtype, $itemid, $tagname) {
1388        global $DB;
1389        $tagcollid = core_tag_area::get_collection($component, $itemtype);
1390        $query = 'SELECT 1 FROM {tag} t
1391                    JOIN {tag_instance} ti ON ti.tagid = t.id
1392                    WHERE t.name = ? AND t.tagcollid = ? AND ti.itemtype = ? AND ti.itemid = ?';
1393        $cleanname = core_text::strtolower(clean_param($tagname, PARAM_TAG));
1394        $params = array($cleanname, $tagcollid, $itemtype, $itemid);
1395        if ($component) {
1396            $query .= ' AND ti.component = ?';
1397            $params[] = $component;
1398        }
1399        return $DB->record_exists_sql($query, $params) ? 1 : 0;
1400    }
1401
1402    /**
1403     * Returns whether the tag area is enabled
1404     *
1405     * @param string $component component responsible for tagging
1406     * @param string $itemtype what is being tagged, for example, 'post', 'course', 'user', etc.
1407     * @return bool|null
1408     */
1409    public static function is_enabled($component, $itemtype) {
1410        return core_tag_area::is_enabled($component, $itemtype);
1411    }
1412
1413    /**
1414     * Retrieves contents of tag area for the tag/index.php page
1415     *
1416     * @param stdClass $tagarea
1417     * @param bool $exclusivemode if set to true it means that no other entities tagged with this tag
1418     *             are displayed on the page and the per-page limit may be bigger
1419     * @param int $fromctx context id where the link was displayed, may be used by callbacks
1420     *            to display items in the same context first
1421     * @param int $ctx context id where to search for records
1422     * @param bool $rec search in subcontexts as well
1423     * @param int $page 0-based number of page being displayed
1424     * @return \core_tag\output\tagindex
1425     */
1426    public function get_tag_index($tagarea, $exclusivemode, $fromctx, $ctx, $rec, $page = 0) {
1427        global $CFG;
1428        if (!empty($tagarea->callback)) {
1429            if (!empty($tagarea->callbackfile)) {
1430                require_once($CFG->dirroot . '/' . ltrim($tagarea->callbackfile, '/'));
1431            }
1432            $callback = $tagarea->callback;
1433            return call_user_func_array($callback, [$this, $exclusivemode, $fromctx, $ctx, $rec, $page]);
1434        }
1435        return null;
1436    }
1437
1438    /**
1439     * Returns formatted description of the tag
1440     *
1441     * @param array $options
1442     * @return string
1443     */
1444    public function get_formatted_description($options = array()) {
1445        $options = empty($options) ? array() : (array)$options;
1446        $options += array('para' => false, 'overflowdiv' => true);
1447        $description = file_rewrite_pluginfile_urls($this->description, 'pluginfile.php',
1448                context_system::instance()->id, 'tag', 'description', $this->id);
1449        return format_text($description, $this->descriptionformat, $options);
1450    }
1451
1452    /**
1453     * Returns the list of tag links available for the current user (edit, flag, etc.)
1454     *
1455     * @return array
1456     */
1457    public function get_links() {
1458        global $USER;
1459        $links = array();
1460
1461        if (!isloggedin() || isguestuser()) {
1462            return $links;
1463        }
1464
1465        $tagname = $this->get_display_name();
1466        $systemcontext = context_system::instance();
1467
1468        // Add a link for users to add/remove this from their interests.
1469        if (static::is_enabled('core', 'user') && core_tag_area::get_collection('core', 'user') == $this->tagcollid) {
1470            if (static::is_item_tagged_with('core', 'user', $USER->id, $this->name)) {
1471                $url = new moodle_url('/tag/user.php', array('action' => 'removeinterest',
1472                    'sesskey' => sesskey(), 'tag' => $this->rawname));
1473                $links[] = html_writer::link($url, get_string('removetagfrommyinterests', 'tag', $tagname),
1474                        array('class' => 'removefrommyinterests'));
1475            } else {
1476                $url = new moodle_url('/tag/user.php', array('action' => 'addinterest',
1477                    'sesskey' => sesskey(), 'tag' => $this->rawname));
1478                $links[] = html_writer::link($url, get_string('addtagtomyinterests', 'tag', $tagname),
1479                        array('class' => 'addtomyinterests'));
1480            }
1481        }
1482
1483        // Flag as inappropriate link.  Only people with moodle/tag:flag capability.
1484        if (has_capability('moodle/tag:flag', $systemcontext)) {
1485            $url = new moodle_url('/tag/user.php', array('action' => 'flaginappropriate',
1486                'sesskey' => sesskey(), 'id' => $this->id));
1487            $links[] = html_writer::link($url, get_string('flagasinappropriate', 'tag', $tagname),
1488                        array('class' => 'flagasinappropriate'));
1489        }
1490
1491        // Edit tag: Only people with moodle/tag:edit capability who either have it as an interest or can manage tags.
1492        if (has_capability('moodle/tag:edit', $systemcontext) ||
1493                has_capability('moodle/tag:manage', $systemcontext)) {
1494            $url = new moodle_url('/tag/edit.php', array('id' => $this->id));
1495            $links[] = html_writer::link($url, get_string('edittag', 'tag'),
1496                        array('class' => 'edittag'));
1497        }
1498
1499        return $links;
1500    }
1501
1502    /**
1503     * Delete one or more tag, and all their instances if there are any left.
1504     *
1505     * @param    int|array    $tagids one tagid (int), or one array of tagids to delete
1506     * @return   bool     true on success, false otherwise
1507     */
1508    public static function delete_tags($tagids) {
1509        global $DB;
1510
1511        if (!is_array($tagids)) {
1512            $tagids = array($tagids);
1513        }
1514        if (empty($tagids)) {
1515            return;
1516        }
1517
1518        // Use the tagids to create a select statement to be used later.
1519        list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids);
1520
1521        // Store the tags and tag instances we are going to delete.
1522        $tags = $DB->get_records_select('tag', 'id ' . $tagsql, $tagparams);
1523        $taginstances = $DB->get_records_select('tag_instance', 'tagid ' . $tagsql, $tagparams);
1524
1525        // Delete all the tag instances.
1526        $select = 'WHERE tagid ' . $tagsql;
1527        $sql = "DELETE FROM {tag_instance} $select";
1528        $DB->execute($sql, $tagparams);
1529
1530        // Delete all the tag correlations.
1531        $sql = "DELETE FROM {tag_correlation} $select";
1532        $DB->execute($sql, $tagparams);
1533
1534        // Delete all the tags.
1535        $select = 'WHERE id ' . $tagsql;
1536        $sql = "DELETE FROM {tag} $select";
1537        $DB->execute($sql, $tagparams);
1538
1539        // Fire an event that these items were untagged.
1540        if ($taginstances) {
1541            // Save the system context in case the 'contextid' column in the 'tag_instance' table is null.
1542            $syscontextid = context_system::instance()->id;
1543            // Loop through the tag instances and fire a 'tag_removed'' event.
1544            foreach ($taginstances as $taginstance) {
1545                // We can not fire an event with 'null' as the contextid.
1546                if (is_null($taginstance->contextid)) {
1547                    $taginstance->contextid = $syscontextid;
1548                }
1549
1550                // Trigger tag removed event.
1551                \core\event\tag_removed::create_from_tag_instance($taginstance,
1552                    $tags[$taginstance->tagid]->name, $tags[$taginstance->tagid]->rawname,
1553                    true)->trigger();
1554            }
1555        }
1556
1557        // Fire an event that these tags were deleted.
1558        if ($tags) {
1559            $context = context_system::instance();
1560            foreach ($tags as $tag) {
1561                // Delete all files associated with this tag.
1562                $fs = get_file_storage();
1563                $files = $fs->get_area_files($context->id, 'tag', 'description', $tag->id);
1564                foreach ($files as $file) {
1565                    $file->delete();
1566                }
1567
1568                // Trigger an event for deleting this tag.
1569                $event = \core\event\tag_deleted::create(array(
1570                    'objectid' => $tag->id,
1571                    'relateduserid' => $tag->userid,
1572                    'context' => $context,
1573                    'other' => array(
1574                        'name' => $tag->name,
1575                        'rawname' => $tag->rawname
1576                    )
1577                ));
1578                $event->add_record_snapshot('tag', $tag);
1579                $event->trigger();
1580            }
1581        }
1582
1583        return true;
1584    }
1585
1586    /**
1587     * Combine together correlated tags of several tags
1588     *
1589     * This is a help method for method combine_tags()
1590     *
1591     * @param core_tag_tag[] $tags
1592     */
1593    protected function combine_correlated_tags($tags) {
1594        global $DB;
1595        $ids = array_map(function($t) {
1596            return $t->id;
1597        }, $tags);
1598
1599        // Retrieve the correlated tags of this tag and correlated tags of all tags to be merged in one query
1600        // but store them separately. Calculate the list of correlated tags that need to be added to the current.
1601        list($sql, $params) = $DB->get_in_or_equal($ids);
1602        $params[] = $this->id;
1603        $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql.' OR tagid = ?',
1604            $params, '', 'tagid, id, correlatedtags');
1605        $correlated = array();
1606        $mycorrelated = array();
1607        foreach ($records as $record) {
1608            $taglist = preg_split('/\s*,\s*/', trim($record->correlatedtags), -1, PREG_SPLIT_NO_EMPTY);
1609            if ($record->tagid == $this->id) {
1610                $mycorrelated = $taglist;
1611            } else {
1612                $correlated = array_merge($correlated, $taglist);
1613            }
1614        }
1615        array_unique($correlated);
1616        // Strip out from $correlated the ids of the tags that are already in $mycorrelated
1617        // or are one of the tags that are going to be combined.
1618        $correlated = array_diff($correlated, [$this->id], $ids, $mycorrelated);
1619
1620        if (empty($correlated)) {
1621            // Nothing to do, ignore situation when current tag is correlated to one of the merged tags - they will
1622            // be deleted later and get_tag_correlation() will not return them. Next cron will clean everything up.
1623            return;
1624        }
1625
1626        // Update correlated tags of this tag.
1627        $newcorrelatedlist = join(',', array_merge($mycorrelated, $correlated));
1628        if (isset($records[$this->id])) {
1629            $DB->update_record('tag_correlation', array('id' => $records[$this->id]->id, 'correlatedtags' => $newcorrelatedlist));
1630        } else {
1631            $DB->insert_record('tag_correlation', array('tagid' => $this->id, 'correlatedtags' => $newcorrelatedlist));
1632        }
1633
1634        // Add this tag to the list of correlated tags of each tag in $correlated.
1635        list($sql, $params) = $DB->get_in_or_equal($correlated);
1636        $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql, $params, '', 'tagid, id, correlatedtags');
1637        foreach ($correlated as $tagid) {
1638            if (isset($records[$tagid])) {
1639                $newcorrelatedlist = $records[$tagid]->correlatedtags . ',' . $this->id;
1640                $DB->update_record('tag_correlation', array('id' => $records[$tagid]->id, 'correlatedtags' => $newcorrelatedlist));
1641            } else {
1642                $DB->insert_record('tag_correlation', array('tagid' => $tagid, 'correlatedtags' => '' . $this->id));
1643            }
1644        }
1645    }
1646
1647    /**
1648     * Combines several other tags into this one
1649     *
1650     * Combining rules:
1651     * - current tag becomes the "main" one, all instances
1652     *   pointing to other tags are changed to point to it.
1653     * - if any of the tags is standard, the "main" tag becomes standard too
1654     * - all tags except for the current ("main") are deleted, even when they are standard
1655     *
1656     * @param core_tag_tag[] $tags tags to combine into this one
1657     */
1658    public function combine_tags($tags) {
1659        global $DB;
1660
1661        $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags');
1662
1663        // Retrieve all tag objects, find if there are any standard tags in the set.
1664        $isstandard = false;
1665        $tagstocombine = array();
1666        $ids = array();
1667        $relatedtags = $this->get_manual_related_tags();
1668        foreach ($tags as $tag) {
1669            $tag->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'tagcollid', 'name', 'rawname'), 'combine_tags');
1670            if ($tag && $tag->id != $this->id && $tag->tagcollid == $this->tagcollid) {
1671                $isstandard = $isstandard || $tag->isstandard;
1672                $tagstocombine[$tag->name] = $tag;
1673                $ids[] = $tag->id;
1674                $relatedtags = array_merge($relatedtags, $tag->get_manual_related_tags());
1675            }
1676        }
1677
1678        if (empty($tagstocombine)) {
1679            // Nothing to do.
1680            return;
1681        }
1682
1683        // Combine all manually set related tags, exclude itself all the tags it is about to be combined with.
1684        if ($relatedtags) {
1685            $relatedtags = array_map(function($t) {
1686                return $t->name;
1687            }, $relatedtags);
1688            array_unique($relatedtags);
1689            $relatedtags = array_diff($relatedtags, [$this->name], array_keys($tagstocombine));
1690        }
1691        $this->set_related_tags($relatedtags);
1692
1693        // Combine all correlated tags, exclude itself all the tags it is about to be combined with.
1694        $this->combine_correlated_tags($tagstocombine);
1695
1696        // If any of the duplicate tags are standard, mark this one as standard too.
1697        if ($isstandard && !$this->isstandard) {
1698            $this->update(array('isstandard' => 1));
1699        }
1700
1701        // Go through all instances of each tag that needs to be combined and make them point to this tag instead.
1702        // We go though the list one by one because otherwise looking-for-duplicates logic would be too complicated.
1703        foreach ($tagstocombine as $tag) {
1704            $params = array('tagid' => $tag->id, 'mainid' => $this->id);
1705            $mainsql = 'SELECT ti.*, t.name, t.rawname, tim.id AS alreadyhasmaintag '
1706                    . 'FROM {tag_instance} ti '
1707                    . 'LEFT JOIN {tag} t ON t.id = ti.tagid '
1708                    . 'LEFT JOIN {tag_instance} tim ON ti.component = tim.component AND '
1709                    . '    ti.itemtype = tim.itemtype AND ti.itemid = tim.itemid AND '
1710                    . '    ti.tiuserid = tim.tiuserid AND tim.tagid = :mainid '
1711                    . 'WHERE ti.tagid = :tagid';
1712
1713            $records = $DB->get_records_sql($mainsql, $params);
1714            foreach ($records as $record) {
1715                if ($record->alreadyhasmaintag) {
1716                    // Item is tagged with both main tag and the duplicate tag.
1717                    // Remove instance pointing to the duplicate tag.
1718                    $tag->delete_instance_as_record($record, false);
1719                    $sql = "UPDATE {tag_instance} SET ordering = ordering - 1
1720                            WHERE itemtype = :itemtype
1721                        AND itemid = :itemid AND component = :component AND tiuserid = :tiuserid
1722                        AND ordering > :ordering";
1723                    $DB->execute($sql, (array)$record);
1724                } else {
1725                    // Item is tagged only with duplicate tag but not the main tag.
1726                    // Replace tagid in the instance pointing to the duplicate tag with this tag.
1727                    $DB->update_record('tag_instance', array('id' => $record->id, 'tagid' => $this->id));
1728                    \core\event\tag_removed::create_from_tag_instance($record, $record->name, $record->rawname)->trigger();
1729                    $record->tagid = $this->id;
1730                    \core\event\tag_added::create_from_tag_instance($record, $this->name, $this->rawname)->trigger();
1731                }
1732            }
1733        }
1734
1735        // Finally delete all tags that we combined into the current one.
1736        self::delete_tags($ids);
1737    }
1738
1739    /**
1740     * Retrieve a list of tags that have been used to tag the given $component
1741     * and $itemtype in the provided $contexts.
1742     *
1743     * @param string $component The tag instance component
1744     * @param string $itemtype The tag instance item type
1745     * @param context[] $contexts The list of contexts to look for tag instances in
1746     * @return core_tag_tag[]
1747     */
1748    public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) {
1749        global $DB;
1750
1751        $params = [$component, $itemtype];
1752        $contextids = array_map(function($context) {
1753            return $context->id;
1754        }, $contexts);
1755        list($contextsql, $contextsqlparams) = $DB->get_in_or_equal($contextids);
1756        $params = array_merge($params, $contextsqlparams);
1757
1758        $subsql = "SELECT DISTINCT t.id
1759                    FROM {tag} t
1760                    JOIN {tag_instance} ti ON t.id = ti.tagid
1761                   WHERE component = ?
1762                   AND itemtype = ?
1763                   AND contextid {$contextsql}";
1764
1765        $sql = "SELECT tt.*
1766                FROM ($subsql) tv
1767                JOIN {tag} tt ON tt.id = tv.id";
1768
1769        return array_map(function($record) {
1770            return new core_tag_tag($record);
1771        }, $DB->get_records_sql($sql, $params));
1772    }
1773}
1774