MDL-52707 core_tag: allow to combine tags
authorMarina Glancy <marina@moodle.com>
Sun, 10 Jan 2016 14:32:02 +0000 (22:32 +0800)
committerMarina Glancy <marina@moodle.com>
Mon, 11 Apr 2016 01:49:56 +0000 (09:49 +0800)
lang/en/tag.php
lib/amd/build/tag.min.js
lib/amd/src/tag.js
tag/classes/tag.php
tag/manage.php
tag/tests/behat/edit_tag.feature
tag/tests/taglib_test.php

index ccc6836..a575ce6 100644 (file)
@@ -70,6 +70,8 @@ $string['flagasinappropriate'] = 'Flag as inappropriate';
 $string['helprelatedtags'] = 'Comma separated related tags';
 $string['changename'] = 'Change tag name';
 $string['changetype'] = 'Change tag type';
+$string['combined'] = 'Tags are combined';
+$string['combineselected'] = 'Combine selected';
 $string['id'] = 'id';
 $string['inalltagcoll'] = 'Everywhere';
 $string['itemstaggedwith'] = '{$a->tagarea} tagged with "{$a->tag}"';
@@ -80,6 +82,7 @@ $string['managetagcolls'] = 'Manage tag collections';
 $string['moretags'] = 'more...';
 $string['name'] = 'Tag name';
 $string['namesalreadybeeingused'] = 'Tag names already being used';
+$string['nameuseddocombine'] = 'This tag name is already used, do you want to combine these tags?';
 $string['newcollnamefor'] = 'New name for tag collection {$a}';
 $string['newnamefor'] = 'New name for tag {$a}';
 $string['nextpage'] = 'More';
@@ -107,6 +110,8 @@ $string['searchtags'] = 'Search tags';
 $string['seeallblogs'] = 'See all blog entries tagged with "{$a}"';
 $string['select'] = 'Select';
 $string['selectcoll'] = 'Select tag collection';
+$string['selectmaintag'] = 'Select the tag that will be used after combining';
+$string['selectmultipletags'] = 'Please select more than one tag';
 $string['selecttag'] = 'Select tag {$a}';
 $string['settypedefault'] = 'Remove from standard tags';
 $string['settypestandard'] = 'Make standard';
index e5cfa40..fa12f30 100644 (file)
Binary files a/lib/amd/build/tag.min.js and b/lib/amd/build/tag.min.js differ
index 65af5f5..01cad68 100644 (file)
@@ -100,6 +100,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                 if (!cnt) {
                     return false;
                 }
+                var tempElement = $("<input type='hidden'/>").attr('name', this.name);
                 e.preventDefault();
                 str.get_strings([
                         {key : 'delete'},
@@ -108,11 +109,93 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                         {key : 'no'},
                     ]).done(function(s) {
                         notification.confirm(s[0], s[1], s[2], s[3], function() {
+                            tempElement.appendTo(form);
                             form.submit();
                         });
                     }
                 );
             });
+
+            // Confirmation for bulk tag combine button.
+            $("#tag-management-combine").click(function(e){
+                e.preventDefault();
+                var form = $(this).closest('form').get(0),
+                    tags = $(form).find("input[type=checkbox]:checked");
+                if (tags.length <= 1) {
+                    str.get_strings([
+                        {key : 'combineselected', component : 'tag'},
+                        {key : 'selectmultipletags', component : 'tag'},
+                        {key : 'ok'},
+                    ]).done(function(s) {
+                            notification.alert(s[0], s[1], s[2]);
+                        }
+                    );
+                    return false;
+                }
+                var tempElement = $("<input type='hidden'/>").attr('name', this.name);
+                str.get_strings([
+                    {key : 'combineselected', component : 'tag'},
+                    {key : 'selectmaintag', component : 'tag'},
+                    {key : 'continue'},
+                    {key : 'cancel'},
+                ]).done(function(s) {
+                    var el = $('<div><form id="combinetags_form" class="form-inline">'+
+                        '<p class="description"></p><p class="options"></p>' +
+                        '<p class="mdl-align"><input type="submit" id="combinetags_submit"/>'+
+                        '<input type="button" id="combinetags_cancel"/></p>' +
+                        '</form></div>');
+                    el.find('.description').html(s[1]);
+                    el.find('#combinetags_submit').attr('value', s[2]);
+                    el.find('#combinetags_cancel').attr('value', s[3]);
+                    var fldset = el.find('.options');
+                    tags.each(function() {
+                        var tagid = $(this).val(),
+                            tagname = $('.inplaceeditable[data-itemtype=tagname][data-itemid='+tagid+']').attr('data-value');
+                        fldset.append($('<input type="radio" name="maintag" id="combinetags_maintag_'+tagid+'" value="'+tagid+
+                            '"/><label for="combinetags_maintag_'+tagid+'">'+tagname+'</label><br>'));
+                    });
+                    var panel = new M.core.dialogue ({
+                        draggable: true,
+                        modal: true,
+                        closeButton: true,
+                        headerContent: s[0],
+                        bodyContent: el.html()
+                    });
+                    panel.show();
+                    $('#combinetags_form input[type=radio]').first().focus().prop('checked', true);
+                    $('#combinetags_form #combinetags_cancel').on('click', function() {
+                        panel.destroy();
+                    });
+                    $('#combinetags_form').on('submit', function() {
+                        tempElement.appendTo(form);
+                        var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
+                        $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
+                        form.submit();
+                        return false;
+                    });
+                });
+            });
+
+            // When user changes tag name to some name that already exists suggest to combine the tags.
+            $('body').on('updatefailed', '[data-inplaceeditable][data-itemtype=tagname]', function(e) {
+                var exception = e.exception; // The exception object returned by the callback.
+                var newvalue = e.newvalue; // The value that user tried to udpated the element to.
+                var tagid = $(e.target).attr('data-itemid');
+                if (exception.errorcode === 'namesalreadybeeingused') {
+                    e.preventDefault(); // This will prevent default error dialogue.
+                    str.get_strings([
+                        {key : 'nameuseddocombine', component : 'tag'},
+                        {key : 'yes'},
+                        {key : 'cancel'},
+                    ]).done(function(s) {
+                        notification.confirm(e.message, s[0], s[1], s[2], function() {
+                            window.location.href = window.location.href + "&newname=" + encodeURIComponent(newvalue) +
+                                "&tagid=" + encodeURIComponent(tagid) +
+                                '&action=renamecombine&sesskey=' + M.cfg.sesskey;
+                        });
+                    });
+                }
+            });
         },
 
         /**
index e6df08f..27e66c5 100644 (file)
@@ -208,6 +208,29 @@ class core_tag_tag {
         return false;
     }
 
+    /**
+     * Simple function to just return a single tag object by its id
+     *
+     * @param    int[]  $ids
+     * @param    string $returnfields which fields do we want returned from table {tag}.
+     *                        Default value is 'id,name,rawname,tagcollid',
+     *                        specify '*' to include all fields.
+     * @return   core_tag_tag[] array of retrieved tags
+     */
+    public static function get_bulk($ids, $returnfields = 'id, name, rawname, tagcollid') {
+        global $DB;
+        $result = array();
+        if (empty($ids)) {
+            return $result;
+        }
+        list($sql, $params) = $DB->get_in_or_equal($ids);
+        $records = $DB->get_records_select('tag', 'id '.$sql, $params, '', $returnfields);
+        foreach ($records as $record) {
+            $result[$record->id] = new static($record);
+        }
+        return $result;
+    }
+
     /**
      * Simple function to just return a single tag object by tagcollid and name
      *
@@ -591,7 +614,7 @@ class core_tag_tag {
 
         $standardonly = (int)$standardonly; // In case somebody passed bool.
 
-        // Note: if the fields in this query are changed, you need to do the same changes in tag_get_correlated().
+        // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags().
         $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag,
                     tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid
                   FROM {tag_instance} ti
@@ -1091,8 +1114,10 @@ class core_tag_tag {
                 tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid
               FROM {tag} tg
         INNER JOIN {tag_instance} ti ON tg.id = ti.tagid
-             WHERE tg.id $query
+             WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ?
           ORDER BY ti.ordering ASC, ti.id";
+        $params[] = $this->id;
+        $params[] = $this->tagcollid;
         $records = $DB->get_records_sql($sql, $params);
         $seen = array();
         $result = array();
@@ -1424,4 +1449,157 @@ class core_tag_tag {
 
         return true;
     }
+
+    /**
+     * Combine together correlated tags of several tags
+     *
+     * This is a help method for method combine_tags()
+     *
+     * @param core_tag_tag[] $tags
+     */
+    protected function combine_correlated_tags($tags) {
+        global $DB;
+        $ids = array_map(function($t) {
+            return $t->id;
+        }, $tags);
+
+        // Retrieve the correlated tags of this tag and correlated tags of all tags to be merged in one query
+        // but store them separately. Calculate the list of correlated tags that need to be added to the current.
+        list($sql, $params) = $DB->get_in_or_equal($ids);
+        $params[] = $this->id;
+        $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql.' OR tagid = ?',
+            $params, '', 'tagid, id, correlatedtags');
+        $correlated = array();
+        $mycorrelated = array();
+        foreach ($records as $record) {
+            $taglist = preg_split('/\s*,\s*/', trim($record->correlatedtags), -1, PREG_SPLIT_NO_EMPTY);
+            if ($record->tagid == $this->id) {
+                $mycorrelated = $taglist;
+            } else {
+                $correlated = array_merge($correlated, $taglist);
+            }
+        }
+        array_unique($correlated);
+        // Strip out from $correlated the ids of the tags that are already in $mycorrelated
+        // or are one of the tags that are going to be combined.
+        $correlated = array_diff($correlated, [$this->id], $ids, $mycorrelated);
+
+        if (empty($correlated)) {
+            // Nothing to do, ignore situation when current tag is correlated to one of the merged tags - they will
+            // be deleted later and get_tag_correlation() will not return them. Next cron will clean everything up.
+            return;
+        }
+
+        // Update correlated tags of this tag.
+        $newcorrelatedlist = join(',', array_merge($mycorrelated, $correlated));
+        if (isset($records[$this->id])) {
+            $DB->update_record('tag_correlation', array('id' => $records[$this->id]->id, 'correlatedtags' => $newcorrelatedlist));
+        } else {
+            $DB->insert_record('tag_correlation', array('tagid' => $this->id, 'correlatedtags' => $newcorrelatedlist));
+        }
+
+        // Add this tag to the list of correlated tags of each tag in $correlated.
+        list($sql, $params) = $DB->get_in_or_equal($correlated);
+        $records = $DB->get_records_select('tag_correlation', 'tagid '.$sql, $params, '', 'tagid, id, correlatedtags');
+        foreach ($correlated as $tagid) {
+            if (isset($records[$tagid])) {
+                $newcorrelatedlist = $records[$tagid]->correlatedtags . ',' . $this->id;
+                $DB->update_record('tag_correlation', array('id' => $records[$tagid]->id, 'correlatedtags' => $newcorrelatedlist));
+            } else {
+                $DB->insert_record('tag_correlation', array('tagid' => $tagid, 'correlatedtags' => '' . $this->id));
+            }
+        }
+    }
+
+    /**
+     * Combines several other tags into this one
+     *
+     * Combining rules:
+     * - current tag becomes the "main" one, all instances
+     *   pointing to other tags are changed to point to it.
+     * - if any of the tags is standard, the "main" tag becomes standard too
+     * - all tags except for the current ("main") are deleted, even when they are standard
+     *
+     * @param core_tag_tag[] $tags tags to combine into this one
+     */
+    public function combine_tags($tags) {
+        global $DB;
+
+        $this->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'name', 'rawname'), 'combine_tags');
+
+        // Retrieve all tag objects, find if there are any standard tags in the set.
+        $isstandard = false;
+        $tagstocombine = array();
+        $ids = array();
+        $relatedtags = $this->get_manual_related_tags();
+        foreach ($tags as $tag) {
+            $tag->ensure_fields_exist(array('id', 'tagcollid', 'isstandard', 'tagcollid', 'name', 'rawname'), 'combine_tags');
+            if ($tag && $tag->id != $this->id && $tag->tagcollid == $this->tagcollid) {
+                $isstandard = $isstandard || $tag->isstandard;
+                $tagstocombine[$tag->name] = $tag;
+                $ids[] = $tag->id;
+                $relatedtags = array_merge($relatedtags, $tag->get_manual_related_tags());
+            }
+        }
+
+        if (empty($tagstocombine)) {
+            // Nothing to do.
+            return;
+        }
+
+        // Combine all manually set related tags, exclude itself all the tags it is about to be combined with.
+        if ($relatedtags) {
+            $relatedtags = array_map(function($t) {
+                return $t->name;
+            }, $relatedtags);
+            array_unique($relatedtags);
+            $relatedtags = array_diff($relatedtags, [$this->name], array_keys($tagstocombine));
+        }
+        $this->set_related_tags($relatedtags);
+
+        // Combine all correlated tags, exclude itself all the tags it is about to be combined with.
+        $this->combine_correlated_tags($tagstocombine);
+
+        // If any of the duplicate tags are standard, mark this one as standard too.
+        if ($isstandard && !$this->isstandard) {
+            $this->update(array('isstandard' => 1));
+        }
+
+        // Go through all instances of each tag that needs to be combined and make them point to this tag instead.
+        // We go though the list one by one because otherwise looking-for-duplicates logic would be too complicated.
+        foreach ($tagstocombine as $tag) {
+            $params = array('tagid' => $tag->id, 'mainid' => $this->id);
+            $mainsql = 'SELECT ti.*, t.name, t.rawname, tim.id AS alreadyhasmaintag '
+                    . 'FROM {tag_instance} ti '
+                    . 'LEFT JOIN {tag} t ON t.id = ti.tagid '
+                    . 'LEFT JOIN {tag_instance} tim ON ti.component = tim.component AND '
+                    . '    ti.itemtype = tim.itemtype AND ti.itemid = tim.itemid AND '
+                    . '    ti.tiuserid = tim.tiuserid AND tim.tagid = :mainid '
+                    . 'WHERE ti.tagid = :tagid';
+
+            $records = $DB->get_records_sql($mainsql, $params);
+            foreach ($records as $record) {
+                if ($record->alreadyhasmaintag) {
+                    // Item is tagged with both main tag and the duplicate tag.
+                    // Remove instance pointing to the duplicate tag.
+                    $tag->delete_instance_as_record($record, false);
+                    $sql = "UPDATE {tag_instance} ti SET ordering = ordering - 1
+                            WHERE ti.itemtype = :itemtype
+                        AND ti.itemid = :itemid AND ti.component = :component AND ti.tiuserid = :tiuserid
+                        AND ti.ordering > :ordering";
+                    $DB->execute($sql, (array)$record);
+                } else {
+                    // Item is tagged only with duplicate tag but not the main tag.
+                    // Replace tagid in the instance pointing to the duplicate tag with this tag.
+                    $DB->update_record('tag_instance', array('id' => $record->id, 'tagid' => $this->id));
+                    \core\event\tag_removed::create_from_tag_instance($record, $record->name, $record->rawname)->trigger();
+                    $record->tagid = $this->id;
+                    \core\event\tag_added::create_from_tag_instance($record, $this->name, $this->rawname)->trigger();
+                }
+            }
+        }
+
+        // Finally delete all tags that we combined into the current one.
+        self::delete_tags($ids);
+    }
 }
index bb016d2..c08a456 100644 (file)
@@ -46,6 +46,9 @@ if ($perpage != DEFAULT_PAGE_SIZE) {
 if ($page > 0) {
     $params['page'] = $page;
 }
+if ($tagcollid) {
+    $params['tc'] = $tagcollid;
+}
 
 admin_externalpage_setup('managetags', '', $params, '', array('pagelayout' => 'report'));
 
@@ -62,8 +65,7 @@ $tagcoll = core_tag_collection::get_by_id($tagcollid);
 $tagarea = core_tag_area::get_by_id($tagareaid);
 $manageurl = new moodle_url('/tag/manage.php');
 if ($tagcoll) {
-    // We are inside a tag collection - add it to the page url and the breadcrumb.
-    $PAGE->set_url(new moodle_url($PAGE->url, array('tc' => $tagcoll->id)));
+    // We are inside a tag collection - add it to the breadcrumb.
     $PAGE->navbar->add(core_tag_collection::display_name($tagcoll),
             new moodle_url($manageurl, array('tc' => $tagcoll->id)));
 }
@@ -108,16 +110,53 @@ switch($action) {
         break;
 
     case 'delete':
-        require_sesskey();
-        if (!$tagschecked && $tagid) {
-            $tagschecked = array($tagid);
+        if ($tagid) {
+            require_sesskey();
+            core_tag_tag::delete_tags(array($tagid));
+            \core\notification::success(get_string('deleted', 'core_tag'));
         }
-        core_tag_tag::delete_tags($tagschecked);
-        if ($tagschecked) {
-            redirect($PAGE->url, get_string('deleted', 'core_tag'), null, \core\output\notification::NOTIFY_SUCCESS);
-        } else {
+        redirect($PAGE->url);
+        break;
+
+    case 'bulk':
+        if (optional_param('bulkdelete', null, PARAM_RAW) !== null) {
+            if ($tagschecked) {
+                require_sesskey();
+                core_tag_tag::delete_tags($tagschecked);
+                \core\notification::success(get_string('deleted', 'core_tag'));
+            }
             redirect($PAGE->url);
+        } else if (optional_param('bulkcombine', null, PARAM_RAW) !== null) {
+            $tags = core_tag_tag::get_bulk($tagschecked, '*');
+            if (count($tags) > 1) {
+                require_sesskey();
+                if (($maintag = optional_param('maintag', 0, PARAM_INT)) && array_key_exists($maintag, $tags)) {
+                    $tag = $tags[$maintag];
+                } else {
+                    $tag = array_shift($tags);
+                }
+                $tag->combine_tags($tags);
+                \core\notification::success(get_string('combined', 'core_tag'));
+            }
+            redirect($PAGE->url);
+        }
+        break;
+
+    case 'renamecombine':
+        // Allows to rename the tag and if the tag with the new name already exists these tags will be combined.
+        if ($tagid && ($newname = required_param('newname', PARAM_TAG))) {
+            require_sesskey();
+            $tag = core_tag_tag::get($tagid, '*', MUST_EXIST);
+            $targettag = core_tag_tag::get_by_name($tag->tagcollid, $newname, '*');
+            if ($targettag) {
+                $targettag->combine_tags(array($tag));
+                \core\notification::success(get_string('combined', 'core_tag'));
+            } else {
+                $tag->update(array('rawname' => $newname));
+                \core\notification::success(get_string('changessaved', 'core_tag'));
+            }
         }
+        redirect($PAGE->url);
         break;
 
     case 'addstandardtag':
@@ -182,7 +221,7 @@ $table = new core_tag_manage_table($tagcollid);
 echo '<form class="tag-management-form" method="post" action="'.$CFG->wwwroot.'/tag/manage.php">';
 echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'tc', 'value' => $tagcollid));
 echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()));
-echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'action', 'value' => 'delete'));
+echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'action', 'value' => 'bulk'));
 echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'perpage', 'value' => $perpage));
 echo html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'page', 'value' => $page));
 echo $table->out($perpage, true);
@@ -190,7 +229,9 @@ echo $table->out($perpage, true);
 if ($table->rawdata) {
     echo html_writer::start_tag('p');
     echo html_writer::tag('button', get_string('deleteselected', 'tag'),
-            array('id' => 'tag-management-delete', 'type' => 'submit', 'class' => 'tagdeleteselected'));
+            array('id' => 'tag-management-delete', 'type' => 'submit', 'class' => 'tagdeleteselected', 'name' => 'bulkdelete'));
+    echo html_writer::tag('button', get_string('combineselected', 'tag'),
+        array('id' => 'tag-management-combine', 'type' => 'submit', 'class' => 'tagcombineselected', 'name' => 'bulkcombine'));
     echo html_writer::end_tag('p');
 }
 echo '</form>';
index 06cc204..7e75cdc 100644 (file)
@@ -162,8 +162,8 @@ Feature: Users can edit tags to add description or rename
     And I click on "Edit tag name" "link" in the "Turtle" "table_row"
     And I set the field "New name for tag Turtle" to "DOG"
     And I press key "13" in the field "New name for tag Turtle"
-    And I should see "Tag names already being used"
-    And I press "Close"
+    And I should see "This tag name is already used, do you want to combine these tags?"
+    And I press "Cancel"
     And "New name for tag" "field" should not exist
     And I should see "Turtle"
     And I should see "Dog"
@@ -183,3 +183,39 @@ Feature: Users can edit tags to add description or rename
     And I should see "Turtle"
     And I should not see "Penguin"
     And I log out
+
+  @javascript
+  Scenario: Combining tags when renaming
+    When I log in as "manager1"
+    And I navigate to "Manage tags" node in "Site administration > Appearance"
+    And I follow "Default collection"
+    And I click on "Edit tag name" "link" in the "Turtle" "table_row"
+    And I set the field "New name for tag Turtle" to "DOG"
+    And I press key "13" in the field "New name for tag Turtle"
+    And I should see "This tag name is already used, do you want to combine these tags?"
+    And I press "Yes"
+    Then I should not see "Turtle"
+    And I should not see "DOG"
+    And I should see "Dog"
+    And I log out
+
+  @javascript
+  Scenario: Combining multiple tags
+    When I log in as "manager1"
+    And I navigate to "Manage tags" node in "Site administration > Appearance"
+    And I follow "Default collection"
+    And I set the following fields to these values:
+      | Select tag Dog | 1 |
+      | Select tag Neverusedtag | 1 |
+      | Select tag Turtle | 1 |
+    And I press "Combine selected"
+    And I should see "Select the tag that will be used after combining"
+    And I click on "//form[@id='combinetags_form']//input[@type='radio'][3]" "xpath_element"
+    And I press "Continue"
+    Then I should see "Tags are combined"
+    And I should not see "Dog"
+    And I should not see "Neverusedtag"
+    And I should see "Turtle"
+    # Even though Turtle was not standard but at least one of combined tags was (Neverusedtag). Now Turtle is also standard.
+    And "Remove from standard tags" "link" should exist in the "Turtle" "table_row"
+    And I log out
index 09cea36..86cd213 100644 (file)
@@ -326,11 +326,11 @@ class core_tag_taglib_testcase extends advanced_testcase {
     }
 
     /**
-     * Test for function tag_compute_correlations() that is part of tag cron
+     * Prepares environment for testing tag correlations
+     * @return core_tag_tag[] list of used tags
      */
-    public function test_correlations() {
+    protected function prepare_correlated() {
         global $DB;
-        $task = new \core\task\tag_cron_task();
 
         $user = $this->getDataGenerator()->create_user();
         $this->setUser($user);
@@ -353,13 +353,24 @@ class core_tag_taglib_testcase extends advanced_testcase {
         core_tag_tag::set_item_tags('core', 'user', $user6->id, context_user::instance($user6->id), array('dog', 'dogs', 'puppy'));
 
         $tags = core_tag_tag::get_by_name_bulk(core_tag_collection::get_default(),
-            array('cat', 'cats', 'dog', 'dogs', 'kitten', 'puppy'));
-        $tags = array_map(function ($t) {
-            return $t->id;
-        }, $tags);
+            array('cat', 'cats', 'dog', 'dogs', 'kitten', 'puppy'), '*');
 
         // Add manual relation between tags 'cat' and 'kitten'.
-        core_tag_tag::get($tags['cat'])->set_related_tags(array('kitten'));
+        core_tag_tag::get($tags['cat']->id)->set_related_tags(array('kitten'));
+
+        return $tags;
+    }
+
+    /**
+     * Test for function tag_compute_correlations() that is part of tag cron
+     */
+    public function test_correlations() {
+        global $DB;
+        $task = new \core\task\tag_cron_task();
+
+        $tags = array_map(function ($t) {
+            return $t->id;
+        }, $this->prepare_correlated());
 
         $task->compute_correlations();
 
@@ -888,4 +899,140 @@ class core_tag_taglib_testcase extends advanced_testcase {
         $this->assertEquals('mouse', $tags3['mouse']->rawname);
 
     }
+
+    /**
+     * Testing function core_tag_tag::combine_tags()
+     */
+    public function test_combine_tags() {
+        $initialtags = array(
+            array('Cat', 'Dog'),
+            array('Dog', 'Cat'),
+            array('Cats', 'Hippo'),
+            array('Hippo', 'Cats'),
+            array('Cat', 'Mouse', 'Kitten'),
+            array('Cats', 'Mouse', 'Kitten'),
+            array('Kitten', 'Mouse', 'Cat'),
+            array('Kitten', 'Mouse', 'Cats'),
+            array('Cats', 'Mouse', 'Kitten'),
+            array('Mouse', 'Hippo')
+        );
+
+        $finaltags = array(
+            array('Cat', 'Dog'),
+            array('Dog', 'Cat'),
+            array('Cat', 'Hippo'),
+            array('Hippo', 'Cat'),
+            array('Cat', 'Mouse'),
+            array('Cat', 'Mouse'),
+            array('Mouse', 'Cat'),
+            array('Mouse', 'Cat'),
+            array('Cat', 'Mouse'),
+            array('Mouse', 'Hippo')
+        );
+
+        $collid = core_tag_collection::get_default();
+        $context = context_system::instance();
+        foreach ($initialtags as $id => $taglist) {
+            core_tag_tag::set_item_tags('core', 'course', $id + 10, $context, $initialtags[$id]);
+        }
+
+        core_tag_tag::get_by_name($collid, 'Cats', '*')->update(array('isstandard' => 1));
+
+        // Combine tags 'Cats' and 'Kitten' into 'Cat'.
+        $cat = core_tag_tag::get_by_name($collid, 'Cat', '*');
+        $cats = core_tag_tag::get_by_name($collid, 'Cats', '*');
+        $kitten = core_tag_tag::get_by_name($collid, 'Kitten', '*');
+        $cat->combine_tags(array($cats, $kitten));
+
+        foreach ($finaltags as $id => $taglist) {
+            $this->assertEquals($taglist,
+                array_values(core_tag_tag::get_item_tags_array('core', 'course', $id + 10)),
+                    'Original array ('.join(', ', $initialtags[$id]).')');
+        }
+
+        // Ensure combined tags are deleted and 'Cat' is now official (because 'Cats' was official).
+        $this->assertEmpty(core_tag_tag::get_by_name($collid, 'Cats'));
+        $this->assertEmpty(core_tag_tag::get_by_name($collid, 'Kitten'));
+        $cattag = core_tag_tag::get_by_name($collid, 'Cat', '*');
+        $this->assertEquals(1, $cattag->isstandard);
+    }
+
+    /**
+     * Testing function core_tag_tag::combine_tags() when related tags are present.
+     */
+    public function test_combine_tags_with_related() {
+        $collid = core_tag_collection::get_default();
+        $context = context_system::instance();
+        core_tag_tag::set_item_tags('core', 'course', 10, $context, array('Cat', 'Cats', 'Dog'));
+        core_tag_tag::get_by_name($collid, 'Cat', '*')->set_related_tags(array('Kitty'));
+        core_tag_tag::get_by_name($collid, 'Cats', '*')->set_related_tags(array('Cat', 'Kitten', 'Kitty'));
+
+        // Combine tags 'Cats' into 'Cat'.
+        $cat = core_tag_tag::get_by_name($collid, 'Cat', '*');
+        $cats = core_tag_tag::get_by_name($collid, 'Cats', '*');
+        $cat->combine_tags(array($cats));
+
+        // Ensure 'Cat' is now related to 'Kitten' and 'Kitty' (order of related tags may be random).
+        $relatedtags = array_map(function($t) {return $t->rawname;}, $cat->get_manual_related_tags());
+        sort($relatedtags);
+        $this->assertEquals(array('Kitten', 'Kitty'), array_values($relatedtags));
+    }
+
+    /**
+     * Testing function core_tag_tag::combine_tags() when correlated tags are present.
+     */
+    public function test_combine_tags_with_correlated() {
+        $task = new \core\task\tag_cron_task();
+
+        $tags = $this->prepare_correlated();
+
+        $task->compute_correlations();
+        // Now 'cat' is correlated with 'cats'.
+        // Also 'dog', 'dogs' and 'puppy' are correlated.
+        // There is a manual relation between 'cat' and 'kitten'.
+        // See function test_correlations() for assertions.
+
+        // Combine tags 'dog' and 'kitten' into 'cat' and make sure that cat is now correlated with dogs and puppy.
+        $tags['cat']->combine_tags(array($tags['dog'], $tags['kitten']));
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['cat']);
+        $this->assertEquals(['cats', 'dogs', 'puppy'], $correlatedtags);
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['dogs']);
+        $this->assertEquals(['cat', 'puppy'], $correlatedtags);
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['puppy']);
+        $this->assertEquals(['cat', 'dogs'], $correlatedtags);
+
+        // Add tag that does not have any correlations.
+        $user7 = $this->getDataGenerator()->create_user();
+        core_tag_tag::set_item_tags('core', 'user', $user7->id, context_user::instance($user7->id), array('hippo'));
+        $tags['hippo'] = core_tag_tag::get_by_name(core_tag_collection::get_default(), 'hippo', '*');
+
+        // Combine tag 'cat' into 'hippo'. Now 'hippo' should have the same correlations 'cat' used to have and also
+        // tags 'dogs' and 'puppy' should have 'hippo' in correlations.
+        $tags['hippo']->combine_tags(array($tags['cat']));
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['hippo']);
+        $this->assertEquals(['cats', 'dogs', 'puppy'], $correlatedtags);
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['dogs']);
+        $this->assertEquals(['hippo', 'puppy'], $correlatedtags);
+
+        $correlatedtags = $this->get_correlated_tags_names($tags['puppy']);
+        $this->assertEquals(['dogs', 'hippo'], $correlatedtags);
+    }
+
+    /**
+     * Help method to return sorted array of names of correlated tags to use for assertions
+     * @param core_tag $tag
+     * @return string
+     */
+    protected function get_correlated_tags_names($tag) {
+        $rv = array_map(function($t) {
+            return $t->rawname;
+        }, $tag->get_correlated_tags());
+        sort($rv);
+        return array_values($rv);
+    }
 }