Commit 21215376 authored by David Monllaó's avatar David Monllaó
Browse files

Merge branch 'wip-MDL-52707-master' of https://github.com/marinaglancy/moodle

parents 45c49f32 0d202789
......@@ -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';
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -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;
});
});
}
});
},
/**
......
......@@ -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);
}
}
......@@ -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>';
......
......@@ -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
......@@ -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'.