FEATURE: Tag synonyms

This feature adds the ability to define synonyms for tags, and the ability to merge one tag into another while keeping it as a synonym. For example, tags named "js" and "java-script" can be synonyms of "javascript". When searching and creating topics using synonyms, they will be mapped to the base tag.

Along with this change is a new UI found on each tag's page (for example, `/tags/javascript`) where more information about the tag can be shown. It will list the synonyms, which categories it's restricted to (if any), and which tag groups it belongs to (if tag group names are public on the `/tags` page by enabling the "tags listed by group" setting). Staff users will be able to manage tags in this UI, merge tags, and add/remove synonyms.
This commit is contained in:
Neil Lalonde 2019-12-04 13:33:51 -05:00 committed by GitHub
parent 15c2755b7b
commit 875f0d8fd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1246 additions and 105 deletions

View File

@ -54,7 +54,7 @@
{{#if showTagsFilter}}
<div class="filter">
<label>{{d-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.tags_filter'}}</label>
{{tag-chooser tags=model.tag_names everyTag=true}}
{{tag-chooser tags=model.tag_names everyTag=true excludeSynonyms=true}}
<div class="instructions">{{i18n 'admin.web_hooks.tags_filter_instructions'}}</div>
</div>
{{/if}}

View File

@ -0,0 +1,7 @@
import RESTAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({
pathFor(store, type, id) {
return "/tags/" + id + "/info";
}
});

View File

@ -0,0 +1,133 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
import {
default as discourseComputed,
observes
} from "discourse-common/utils/decorators";
import Component from "@ember/component";
import { reads, and } from "@ember/object/computed";
import { isEmpty } from "@ember/utils";
import Category from "discourse/models/category";
export default Component.extend({
tagName: "",
loading: false,
tagInfo: null,
newSynonyms: null,
showEditControls: false,
canAdminTag: reads("currentUser.staff"),
editSynonymsMode: and("canAdminTag", "showEditControls"),
@discourseComputed("tagInfo.tag_group_names")
tagGroupsInfo(tagGroupNames) {
return I18n.t("tagging.tag_groups_info", {
count: tagGroupNames.length,
tag_groups: tagGroupNames.join(", ")
});
},
@discourseComputed("tagInfo.categories")
categoriesInfo(categories) {
return I18n.t("tagging.category_restrictions", {
count: categories.length
});
},
@discourseComputed(
"tagInfo.tag_group_names",
"tagInfo.categories",
"tagInfo.synonyms"
)
nothingToShow(tagGroupNames, categories, synonyms) {
return isEmpty(tagGroupNames) && isEmpty(categories) && isEmpty(synonyms);
},
@observes("expanded")
toggleExpanded() {
if (this.expanded && !this.tagInfo) {
this.loadTagInfo();
}
},
loadTagInfo() {
if (this.loading) {
return;
}
this.set("loading", true);
return this.store
.find("tag-info", this.tag.id)
.then(result => {
this.set("tagInfo", result);
this.set(
"tagInfo.synonyms",
result.synonyms.map(s => this.store.createRecord("tag", s))
);
this.set(
"tagInfo.categories",
result.category_ids.map(id => Category.findById(id))
);
})
.finally(() => this.set("loading", false));
},
actions: {
toggleEditControls() {
this.toggleProperty("showEditControls");
},
renameTag() {
showModal("rename-tag", { model: this.tag });
},
deleteTag() {
this.sendAction("deleteAction", this.tagInfo);
},
unlinkSynonym(tag) {
ajax(`/tags/${this.tagInfo.name}/synonyms/${tag.id}`, {
type: "DELETE"
})
.then(() => this.tagInfo.synonyms.removeObject(tag))
.catch(() => bootbox.alert(I18n.t("generic_error")));
},
deleteSynonym(tag) {
bootbox.confirm(
I18n.t("tagging.delete_synonym_confirm", { tag_name: tag.text }),
result => {
if (!result) return;
tag
.destroyRecord()
.then(() => this.tagInfo.synonyms.removeObject(tag))
.catch(() => bootbox.alert(I18n.t("generic_error")));
}
);
},
addSynonyms() {
ajax(`/tags/${this.tagInfo.name}/synonyms`, {
type: "POST",
data: {
synonyms: this.newSynonyms
}
})
.then(result => {
if (result.success) {
this.set("newSynonyms", null);
this.loadTagInfo();
} else if (result.failed_tags) {
bootbox.alert(
I18n.t("tagging.add_synonyms_failed", {
tag_names: Object.keys(result.failed_tags).join(", ")
})
);
} else {
bootbox.alert(I18n.t("generic_error"));
}
})
.catch(popupAjaxError);
}
}
});

View File

@ -26,6 +26,7 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
search: null,
max_posts: null,
q: null,
showInfo: false,
categories: alias("site.categoriesList"),
@ -79,9 +80,9 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
return Discourse.SiteSettings.show_filter_by_tag;
},
@discourseComputed("additionalTags", "canAdminTag", "category")
showAdminControls(additionalTags, canAdminTag, category) {
return !additionalTags && canAdminTag && !category;
@discourseComputed("additionalTags", "category", "tag.id")
showToggleInfo(additionalTags, category, tagId) {
return !additionalTags && !category && tagId !== "none";
},
loadMoreTopics() {
@ -121,6 +122,10 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
this.send("invalidateModel");
},
toggleInfo() {
this.toggleProperty("showInfo");
},
refresh() {
// TODO: this probably doesn't work anymore
return this.store
@ -131,15 +136,23 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
});
},
deleteTag() {
deleteTag(tagInfo) {
const numTopics =
this.get("list.topic_list.tags.firstObject.topic_count") || 0;
const confirmText =
let confirmText =
numTopics === 0
? I18n.t("tagging.delete_confirm_no_topics")
: I18n.t("tagging.delete_confirm", { count: numTopics });
if (tagInfo.synonyms.length > 0) {
confirmText +=
" " +
I18n.t("tagging.delete_confirm_synonyms", {
count: tagInfo.synonyms.length
});
}
bootbox.confirm(confirmText, result => {
if (!result) return;

View File

@ -28,6 +28,9 @@ function defaultRenderTag(tag, params) {
if (Discourse.SiteSettings.tag_style || params.style) {
classes.push(params.style || Discourse.SiteSettings.tag_style);
}
if (params.size) {
classes.push(params.size);
}
let val =
"<" +

View File

@ -56,7 +56,10 @@ export default DiscourseRoute.extend(FilterModeMixin, {
afterModel(tag, transition) {
const controller = this.controllerFor("tags.show");
controller.set("loading", true);
controller.setProperties({
loading: true,
showInfo: false
});
const params = filterQueryParams(transition.to.queryParams, {});
const category = this.categorySlugPathWithID

View File

@ -5,6 +5,7 @@
filterPlaceholder="category.tags_placeholder"
tags=category.allowed_tags
everyTag=true
excludeSynonyms=true
unlimitedTagCount=true}}
</section>

View File

@ -9,7 +9,8 @@
everyTag=true
allowCreate=true
filterPlaceholder="tagging.groups.tags_placeholder"
unlimitedTagCount=true}}
unlimitedTagCount=true
excludeSynonyms=true}}
</section>
<section class="parent-tag-section">
@ -19,6 +20,7 @@
everyTag=true
maximum=1
allowCreate=true
excludeSynonyms=true
filterPlaceholder="tagging.groups.parent_tag_placeholder"}}
<span class="description">{{i18n 'tagging.groups.parent_tag_description'}}</span>
</section>

View File

@ -0,0 +1,73 @@
{{#if expanded}}
<section class="tag-info">
{{#if tagInfo}}
<div class="tag-name">
{{discourse-tag tagInfo.name tagName="div" size="large"}}
{{#if canAdminTag}}
{{d-button class="btn-default" action=(action "renameTag") icon="pencil-alt" label="tagging.rename_tag" id="rename-tag"}}
{{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}}
{{#if deleteAction}}
{{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}}
{{/if}}
{{/if}}
</div>
<div class="tag-associations">
{{#if tagInfo.tag_group_names}}
{{tagGroupsInfo}}
{{/if}}
{{#if tagInfo.categories}}
{{categoriesInfo}}
<br/>
{{#each tagInfo.categories as |category|}}
{{category-link category}}
{{/each}}
{{/if}}
{{#if nothingToShow}}
{{i18n "tagging.default_info"}}
{{/if}}
</div>
{{#if tagInfo.synonyms}}
<div class="synonyms-list">
<h3>{{i18n "tagging.synonyms"}}</h3>
<div>{{{i18n "tagging.synonyms_description" base_tag_name=tagInfo.name}}}</div>
<div class="tag-list">
{{#each tagInfo.synonyms as |tag|}}
<div class='tag-box'>
{{discourse-tag tag.id pmOnly=tag.pmOnly tagName="div"}}
{{#if editSynonymsMode}}
<a {{action "unlinkSynonym" tag}} class="unlink-synonym">
{{d-icon "unlink" title="tagging.remove_synonym"}}
</a>
<a {{action "deleteSynonym" tag}} class="delete-synonym">
{{d-icon "far-trash-alt" title="tagging.delete_tag"}}
</a>
{{/if}}
</div>
{{/each}}
</div>
<div class="clearfix" />
</div>
{{/if}}
{{#if editSynonymsMode}}
<section class="add-synonyms field">
<label for="add-synonyms">{{i18n 'tagging.add_synonyms_label'}}</label>
{{tag-chooser
id="add-synonyms"
tags=newSynonyms
everyTag=true
excludeSynonyms=true
excludeHasSynonyms=true
unlimitedTagCount=true}}
</section>
{{d-button
class="btn-default"
action=(action "addSynonyms")
disabled=addSynonymsDisabled
label="tagging.add_synonyms"}}
{{/if}}
{{/if}}
{{#if loading}}
<div>{{i18n 'loading'}}</div>
{{/if}}
</section>
{{/if}}

View File

@ -13,4 +13,3 @@
</div>
{{/each}}
<div class="clearfix" />
<hr/>

View File

@ -39,14 +39,17 @@
label=createTopicLabel
action=(route-action "createTopic")}}
{{#if showAdminControls}}
{{d-button action=(route-action "renameTag") actionParam=tag icon="pencil-alt" class="admin-tag"}}
{{d-button action=(action "deleteTag") icon="far-trash-alt" class="admin-tag btn-danger"}}
{{#if showToggleInfo}}
{{d-button icon="tag" label="tagging.info" action=(action "toggleInfo") id="show-tag-info"}}
{{/if}}
</section>
</div>
</div>
{{#if showToggleInfo}}
{{tag-info tag=tag expanded=showInfo list=list deleteAction=(action "deleteTag")}}
{{/if}}
{{plugin-outlet name="discovery-list-container-top"}}
<div class="container list-container">
<div class="row">

View File

@ -285,7 +285,11 @@ export default SelectKitComponent.extend({
this
);
this._boundaryActionHandler("onSelect", computedContentItem.value);
this._boundaryActionHandler(
"onSelect",
computedContentItem.value,
computedContentItem.originalContent
);
this._boundaryActionHandler("onSelectAny", computedContentItem);
this.autoHighlight();

View File

@ -17,6 +17,8 @@ export default MultiSelectComponent.extend(TagsMixin, {
attributeBindings: ["categoryId"],
allowCreate: null,
allowAny: alias("allowCreate"),
excludeSynonyms: false,
excludeHasSynonyms: false,
init() {
this._super(...arguments);
@ -118,6 +120,8 @@ export default MultiSelectComponent.extend(TagsMixin, {
}
if (!this.everyTag) data.filterForInput = true;
if (this.excludeSynonyms) data.excludeSynonyms = true;
if (this.excludeHasSynonyms) data.excludeHasSynonyms = true;
this.searchTags("/tags/filter/search", data, this._transformJson);
},

View File

@ -168,12 +168,16 @@ export default ComboBoxComponent.extend(TagsMixin, {
results = results.sort((a, b) => a.id > b.id);
return results.map(r => {
return { id: r.id, name: r.text };
return {
id: r.id,
name: r.text,
targetTagId: r.target_tag || r.id
};
});
},
actions: {
onSelect(tagId) {
onSelect(tagId, tag) {
let url;
if (tagId === "all-tags") {
@ -189,7 +193,12 @@ export default ComboBoxComponent.extend(TagsMixin, {
}`;
}
url = Discourse.getURL(`${url}/${tagId.toLowerCase()}`);
if (tag && tag.targetTagId) {
url += `/${tag.targetTagId.toLowerCase()}`;
} else {
url += `/${tagId.toLowerCase()}`;
}
url = Discourse.getURL(url);
}
DiscourseURL.routeTo(url);

View File

@ -5,6 +5,8 @@
.tag-list {
margin-top: 2em;
padding-bottom: 1em;
border-bottom: 1px solid $primary-low;
}
#list-area .tag-list h3 {
@ -88,6 +90,10 @@ $tag-color: $primary-medium;
color: $header-primary_high !important;
}
&.large {
font-size: $font-up-2;
}
&.box {
background-color: $primary-low;
color: $primary-high;
@ -104,6 +110,25 @@ $tag-color: $primary-medium;
margin-right: 0;
color: $primary-high;
}
&.bullet {
margin-right: 0.5em;
display: inline-flex;
align-items: center;
&:before {
background: $primary-low-mid;
margin-right: 5px;
position: relative;
width: 9px;
height: 9px;
display: inline-block;
content: "";
}
&.large:before {
width: 13px;
height: 13px;
}
}
}
.discourse-tags,
@ -152,21 +177,6 @@ $tag-color: $primary-medium;
}
}
.discourse-tag.bullet {
margin-right: 0.5em;
display: inline-flex;
align-items: center;
&:before {
background: $primary-low-mid;
margin-right: 5px;
position: relative;
width: 9px;
height: 9px;
display: inline-block;
content: "";
}
}
header .discourse-tag {
color: $tag-color;
}
@ -258,3 +268,26 @@ header .discourse-tag {
}
}
}
.tag-info {
margin-top: 1em;
margin-bottom: 1em;
.delete-tag {
float: right;
}
.synonyms-list,
.add-synonyms,
.tag-associations {
margin-top: 1em;
}
.tag-list {
border: none;
.d-icon {
color: $primary-medium;
}
}
.field {
margin-bottom: 5px;
}
}

View File

@ -5,6 +5,7 @@ class TagsController < ::ApplicationController
include TopicQueryParams
before_action :ensure_tags_enabled
before_action :ensure_visible, only: [:show, :info]
requires_login except: [
:index,
@ -12,13 +13,16 @@ class TagsController < ::ApplicationController
:tag_feed,
:search,
:check_hashtag,
:info,
Discourse.anonymous_filters.map { |f| :"show_#{f}" }
].flatten
skip_before_action :check_xhr, only: [:tag_feed, :show, :index]
before_action :set_category_from_params, except: [:index, :update, :destroy,
:tag_feed, :search, :notifications, :update_notifications, :personal_messages]
:tag_feed, :search, :notifications, :update_notifications, :personal_messages, :info]
before_action :fetch_tag, only: [:info, :create_synonyms, :destroy_synonym]
def index
@description_meta = I18n.t("tags.title")
@ -31,21 +35,21 @@ class TagsController < ::ApplicationController
ungrouped_tags = ungrouped_tags.where("tags.topic_count > 0") unless show_all_tags
grouped_tag_counts = TagGroup.visible(guardian).order('name ASC').includes(:tags).map do |tag_group|
{ id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json(tag_group.tags) }
{ id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json(tag_group.tags.where(target_tag_id: nil)) }
end
@tags = self.class.tag_counts_json(ungrouped_tags)
@extras = { tag_groups: grouped_tag_counts }
else
tags = show_all_tags ? Tag.all : Tag.where("tags.topic_count > 0")
unrestricted_tags = DiscourseTagging.filter_visible(tags, guardian)
unrestricted_tags = DiscourseTagging.filter_visible(tags.where(target_tag_id: nil), guardian)
categories = Category.where("id IN (SELECT category_id FROM category_tags)")
.where("id IN (?)", guardian.allowed_category_ids)
.includes(:tags)
category_tag_counts = categories.map do |c|
{ id: c.id, tags: self.class.tag_counts_json(c.tags) }
{ id: c.id, tags: self.class.tag_counts_json(c.tags.where(target_tag_id: nil)) }
end
@tags = self.class.tag_counts_json(unrestricted_tags)
@ -98,11 +102,13 @@ class TagsController < ::ApplicationController
end
def show
raise Discourse::NotFound if DiscourseTagging.hidden_tag_names(guardian).include?(params[:tag_id])
show_latest
end
def info
render_serialized(@tag, DetailedTagSerializer, root: :tag_info)
end
def update
guardian.ensure_can_admin_tags!
@ -196,7 +202,9 @@ class TagsController < ::ApplicationController
filter_params = {
for_input: params[:filterForInput],
selected_tags: params[:selected_tags],
limit: params[:limit]
limit: params[:limit],
exclude_synonyms: params[:excludeSynonyms],
exclude_has_synonyms: params[:excludeHasSynonyms]
}
if params[:categoryId]
@ -224,6 +232,11 @@ class TagsController < ::ApplicationController
# filter_allowed_tags determined that the tag entered is not allowed
json_response[:forbidden] = params[:q]
if filter_params[:exclude_synonyms] && tag.synonym?
json_response[:forbidden_message] = I18n.t("tags.forbidden.synonym", tag_name: tag.target_tag.name)
elsif filter_params[:exclude_has_synonyms] && tag.synonyms.exists?
json_response[:forbidden_message] = I18n.t("tags.forbidden.has_synonyms", tag_name: tag.name)
else
category_names = tag.categories.where(id: guardian.allowed_category_ids).pluck(:name)
category_names += Category.joins(tag_groups: :tags).where(id: guardian.allowed_category_ids, "tags.id": tag.id).pluck(:name)
@ -239,6 +252,7 @@ class TagsController < ::ApplicationController
json_response[:forbidden_message] = I18n.t("tags.forbidden.in_this_category", tag_name: tag.name)
end
end
end
render json: json_response
end
@ -276,14 +290,56 @@ class TagsController < ::ApplicationController
render json: { tags: pm_tags }
end
def create_synonyms
guardian.ensure_can_admin_tags!
value = DiscourseTagging.add_or_create_synonyms_by_name(@tag, params[:synonyms])
if value.is_a?(Array)
render json: failed_json.merge(
failed_tags: value.inject({}) { |h, t| h[t.name] = t.errors.full_messages.first; h }
)
else
render json: success_json
end
end
def destroy_synonym
guardian.ensure_can_admin_tags!
synonym = Tag.where_name(params[:synonym_id]).first
raise Discourse::NotFound unless synonym
if synonym.target_tag == @tag
synonym.update!(target_tag: nil)
render json: success_json
else
render json: failed_json, status: 400
end
end
private
def fetch_tag
@tag = Tag.find_by_name(params[:tag_id].force_encoding("UTF-8"))
raise Discourse::NotFound unless @tag
end
def ensure_tags_enabled
raise Discourse::NotFound unless SiteSetting.tagging_enabled?
end
def ensure_visible
raise Discourse::NotFound if DiscourseTagging.hidden_tag_names(guardian).include?(params[:tag_id])
end
def self.tag_counts_json(tags)
tags.map { |t| { id: t.name, text: t.name, count: t.topic_count, pm_count: t.pm_topic_count } }
target_tags = Tag.where(id: tags.map(&:target_tag_id).compact.uniq).select(:id, :name)
tags.map do |t|
{
id: t.name,
text: t.name,
count: t.topic_count,
pm_count: t.pm_topic_count,
target_tag: t.target_tag_id ? target_tags.find { |x| x.id == t.target_tag_id }&.name : nil
}
end
end
def set_category_from_params

View File

@ -5,15 +5,17 @@ class Tag < ActiveRecord::Base
include HasDestroyedWebHook
validates :name, presence: true, uniqueness: { case_sensitive: false }
validate :target_tag_validator, if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? }
scope :where_name, ->(name) do
name = Array(name).map(&:downcase)
where("lower(name) IN (?)", name)
where("lower(tags.name) IN (?)", name)
end
scope :unused, -> { where(topic_count: 0, pm_topic_count: 0) }
scope :base_tags, -> { where(target_tag_id: nil) }
has_many :tag_users # notification settings
has_many :tag_users, dependent: :destroy # notification settings
has_many :topic_tags, dependent: :destroy
has_many :topics, through: :topic_tags
@ -21,10 +23,14 @@ class Tag < ActiveRecord::Base
has_many :category_tags, dependent: :destroy
has_many :categories, through: :category_tags
has_many :tag_group_memberships
has_many :tag_group_memberships, dependent: :destroy
has_many :tag_groups, through: :tag_group_memberships
belongs_to :target_tag, class_name: "Tag", optional: true
has_many :synonyms, class_name: "Tag", foreign_key: "target_tag_id", dependent: :destroy
after_save :index_search
after_save :update_synonym_associations
after_commit :trigger_tag_created_event, on: :create
after_commit :trigger_tag_updated_event, on: :update
@ -137,6 +143,25 @@ class Tag < ActiveRecord::Base
SearchIndexer.index(self)
end
def synonym?
!self.target_tag_id.nil?
end
def target_tag_validator
if synonyms.exists?
errors.add(:target_tag_id, I18n.t("tags.synonyms_exist"))
elsif target_tag&.synonym?
errors.add(:target_tag_id, I18n.t("tags.invalid_target_tag"))
end
end
def update_synonym_associations
if target_tag_id && saved_change_to_target_tag_id?
target_tag.tag_groups.each { |tag_group| tag_group.tags << self unless tag_group.tags.include?(self) }
target_tag.categories.each { |category| category.tags << self unless category.tags.include?(self) }
end
end
%i{
tag_created
tag_updated

View File

@ -21,6 +21,12 @@ class TagUser < ActiveRecord::Base
tag_ids = tags.empty? ? [] : Tag.where_name(tags).pluck(:id)
Tag.where_name(tags).joins(:target_tag).each do |tag|
tag_ids[tag_ids.index(tag.id)] = tag.target_tag_id
end
tag_ids.uniq!
remove = (old_ids - tag_ids)
if remove.present?
records.where('tag_id in (?)', remove).destroy_all
@ -41,7 +47,17 @@ class TagUser < ActiveRecord::Base
end
def self.change(user_id, tag_id, level)
tag_id = tag_id.id if tag_id.is_a?(::Tag)
if tag_id.is_a?(::Tag)
tag = tag_id
tag_id = tag.id
else
tag = Tag.find_by_id(tag_id)
end
if tag.synonym?
tag_id = tag.target_tag_id
end
user_id = user_id.id if user_id.is_a?(::User)
tag_id = tag_id.to_i

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
class DetailedTagSerializer < TagSerializer
attributes :synonyms, :tag_group_names
has_many :categories, serializer: BasicCategorySerializer
def synonyms
TagsController.tag_counts_json(object.synonyms)
end
def categories
Category.secured(scope).where(
id: object.categories.pluck(:id) +
object.tag_groups.includes(:categories).map do |tg|
tg.categories.map(&:id)
end.flatten
)
end
def include_tag_group_names?
scope.is_admin? || SiteSetting.tags_listed_by_group == true
end
def tag_group_names
object.tag_groups.map(&:name)
end
end

View File

@ -4,7 +4,7 @@ class TagGroupSerializer < ApplicationSerializer
attributes :id, :name, :tag_names, :parent_tag_name, :one_per_topic, :permissions
def tag_names
object.tags.map(&:name).sort
object.tags.base_tags.map(&:name).sort
end
def parent_tag_name

View File

@ -137,7 +137,12 @@ class SearchIndexer
end
category_name = topic.category&.name if topic
tag_names = topic.tags.pluck(:name).join(' ') if topic
if topic
tags = topic.tags.select(:id, :name)
unless tags.empty?
tag_names = (tags.map(&:name) + Tag.where(target_tag_id: tags.map(&:id)).pluck(:name)).join(' ')
end
end
if Post === obj && obj.raw.present? &&
(

View File

@ -3043,11 +3043,30 @@ en:
changed: "tags changed:"
tags: "Tags"
choose_for_topic: "optional tags"
info: "Info"
default_info: "This tag isn't restricted to any categories, and has no synonyms."
synonyms: "Synonyms"
synonyms_description: "When the following tags are used, they will be replaced with <b>%{base_tag_name}</b>."
tag_groups_info:
one: 'This tag belongs to the group "{{tag_groups}}".'
other: "This tag belongs to these groups: {{tag_groups}}."
category_restrictions:
one: "It can only be used in this category:"
other: "It can only be used in these categories:"
edit_synonyms: "Manage Synonyms"
add_synonyms_label: "Add synonyms:"
add_synonyms: "Add"
add_synonyms_failed: "The following tags couldn't be added as synonyms: <b>%{tag_names}</b>. Ensure they don't have synonyms and aren't synonyms of another tag."
remove_synonym: "Remove Synonym"
delete_synonym_confirm: 'Are you sure you want to delete the synonym "%{tag_name}"?'
delete_tag: "Delete Tag"
delete_confirm:
one: "Are you sure you want to delete this tag and remove it from %{count} topic it is assigned to?"
other: "Are you sure you want to delete this tag and remove it from {{count}} topics it is assigned to?"
delete_confirm_no_topics: "Are you sure you want to delete this tag?"
delete_confirm_synonyms:
one: "Its synonym will also be deleted."
other: "Its {{count}} synonyms will also be deleted."
rename_tag: "Rename Tag"
rename_instructions: "Choose a new name for the tag:"
sort_by: "Sort by:"

View File

@ -4378,9 +4378,13 @@ en:
restricted_to:
one: '"%{tag_name}" is restricted to the "%{category_names}" category'
other: '"%{tag_name}" is restricted to the following categories: %{category_names}'
synonym: 'Synonyms are not allowed. Use "%{tag_name}" instead.'
has_synonyms: '"%{tag_name}" cannot be used because it has synonyms.'
required_tags_from_group:
one: "You must include at least %{count} %{tag_group_name} tag."
other: "You must include at least %{count} %{tag_group_name} tags."
invalid_target_tag: "cannot be a synonym of a synonym"
synonyms_exist: "is not allowed while synonyms exist"
rss_by_tag: "Topics tagged %{tag}"
finish_installation:

View File

@ -862,10 +862,13 @@ Discourse::Application.routes.draw do
get '/:tag_id.rss' => 'tags#tag_feed'
get '/:tag_id' => 'tags#show', as: 'tag_show'
get '/intersection/:tag_id/*additional_tag_ids' => 'tags#show', as: 'tag_intersection'
get '/:tag_id/info' => 'tags#info'
get '/:tag_id/notifications' => 'tags#notifications'
put '/:tag_id/notifications' => 'tags#update_notifications'
put '/:tag_id' => 'tags#update'
delete '/:tag_id' => 'tags#destroy'
post '/:tag_id/synonyms' => 'tags#create_synonyms'
delete '/:tag_id/synonyms/:synonym_id' => 'tags#destroy_synonym'
Discourse.filters.each do |filter|
get "/:tag_id/l/#{filter}" => "tags#show_#{filter}", as: "tag_show_#{filter}"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTargetTagIdToTags < ActiveRecord::Migration[6.0]
def change
add_column :tags, :target_tag_id, :integer
end
end

View File

@ -17,6 +17,12 @@ module DiscourseTagging
if guardian.can_tag?(topic)
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || []
if !tag_names.empty?
Tag.where_name(tag_names).joins(:target_tag).includes(:target_tag).each do |tag|
tag_names[tag_names.index(tag.name)] = tag.target_tag.name
end
end
old_tag_names = topic.tags.pluck(:name) || []
new_tag_names = tag_names - old_tag_names
removed_tag_names = old_tag_names - tag_names
@ -190,6 +196,7 @@ module DiscourseTagging
# for_topic: results are for tagging a topic
# selected_tags: an array of tag names that are in the current selection
# only_tag_names: limit results to tags with these names
# exclude_synonyms: exclude synonyms from results
def self.filter_allowed_tags(guardian, opts = {})
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
category = opts[:category]
@ -215,7 +222,7 @@ module DiscourseTagging
sql << <<~SQL
SELECT t.id, t.name, t.topic_count, t.pm_topic_count,
tgr.tgm_id as tgm_id, tgr.tag_group_id as tag_group_id, tgr.parent_tag_id as parent_tag_id,
tgr.one_per_topic as one_per_topic
tgr.one_per_topic as one_per_topic, t.target_tag_id
FROM tags t
INNER JOIN tag_group_restrictions tgr ON tgr.tag_id = t.id
#{outer_join ? "LEFT OUTER" : "INNER"}
@ -307,6 +314,14 @@ module DiscourseTagging
end
end
if opts[:exclude_synonyms]
builder.where("target_tag_id IS NULL")
end
if opts[:exclude_has_synonyms]
builder.where("id NOT IN (SELECT target_tag_id FROM tags WHERE target_tag_id IS NOT NULL)")
end
builder.limit(opts[:limit]) if opts[:limit]
if opts[:order]
builder.order_by(opts[:order])
@ -383,13 +398,26 @@ module DiscourseTagging
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || []
if taggable.tags.pluck(:name).sort != tag_names.sort
taggable.tags = Tag.where_name(tag_names).all
if taggable.tags.size < tag_names.size
new_tag_names = tag_names - taggable.tags.map(&:name)
new_tag_names = taggable.tags.size < tag_names.size ? tag_names - taggable.tags.map(&:name) : []
taggable.tags << Tag.where(target_tag_id: taggable.tags.map(&:id)).all
new_tag_names.each do |name|
taggable.tags << Tag.create(name: name)
end
end
end
# Returns true if all were added successfully, or an Array of the
# tags that failed to be added, with errors on each Tag.
def self.add_or_create_synonyms_by_name(target_tag, synonym_names)
tag_names = DiscourseTagging.tags_for_saving(synonym_names, Guardian.new(Discourse.system_user)) || []
existing = Tag.where_name(tag_names).all
target_tag.synonyms << existing
(tag_names - target_tag.synonyms.map(&:name)).each do |name|
target_tag.synonyms << Tag.create(name: name)
end
successful = existing.select { |t| !t.errors.present? }
TopicTag.where(tag_id: successful.map(&:id)).update_all(tag_id: target_tag.id)
(existing - successful).presence || true
end
def self.muted_tags(user)

View File

@ -699,18 +699,15 @@ class TopicQuery
end
end
# ALL TAGS: something like this?
# Topic.joins(:tags).where('tags.name in (?)', @options[:tags]).group('topic_id').having('count(*)=?', @options[:tags].size).select('topic_id')
if SiteSetting.tagging_enabled
result = result.preload(:tags)
tags = @options[:tags]
tags_arg = @options[:tags]
if tags && tags.size > 0
tags = tags.split if String === tags
if tags_arg && tags_arg.size > 0
tags_arg = tags_arg.split if String === tags_arg
tags = tags.map do |t|
tags_arg = tags_arg.map do |t|
if String === t
t.downcase
else
@ -718,12 +715,12 @@ class TopicQuery
end
end
tags_query = tags_arg[0].is_a?(String) ? Tag.where_name(tags_arg) : Tag.where(id: tags_arg)
tags = tags_query.select(:id, :target_tag_id).map { |t| t.target_tag_id || t.id }.uniq
if @options[:match_all_tags]
# ALL of the given tags:
tags_count = tags.length
tags = Tag.where_name(tags).pluck(:id) unless Integer === tags[0]
if tags_count == tags.length
if tags_arg.length == tags.length
tags.each_with_index do |tag, index|
sql_alias = ['t', index].join
result = result.joins("INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag}")
@ -733,12 +730,7 @@ class TopicQuery
end
else
# ANY of the given tags:
result = result.joins(:tags)
if Integer === tags[0]
result = result.where("tags.id in (?)", tags)
else
result = result.where("lower(tags.name) in (?)", tags)
end
result = result.joins(:tags).where("tags.id in (?)", tags)
end
# TODO: this is very side-effecty and should be changed

View File

@ -8,10 +8,6 @@ require 'discourse_tagging'
describe DiscourseTagging do
def sorted_tag_names(tag_records)
tag_records.map(&:name).sort
end
fab!(:admin) { Fabricate(:admin) }
fab!(:user) { Fabricate(:user) }
let(:guardian) { Guardian.new(user) }
@ -132,6 +128,46 @@ describe DiscourseTagging do
expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag1, tag2, tag3]))
end
end
context 'tag synonyms' do
fab!(:base_tag) { Fabricate(:tag, name: 'discourse') }
fab!(:synonym) { Fabricate(:tag, name: 'discource', target_tag: base_tag) }
it 'returns synonyms by default' do
tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user),
for_input: true,
term: 'disc'
).map(&:name)
expect(tags).to contain_exactly(base_tag.name, synonym.name)
end
it 'excludes synonyms with exclude_synonyms param' do
tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user),
for_input: true,
exclude_synonyms: true,
term: 'disc'
).map(&:name)
expect(tags).to contain_exactly(base_tag.name)
end
it 'excludes tags with synonyms with exclude_has_synonyms params' do
tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user),
for_input: true,
exclude_has_synonyms: true,
term: 'disc'
).map(&:name)
expect(tags).to contain_exactly(synonym.name)
end
it 'can exclude synonyms and tags with synonyms' do
expect(DiscourseTagging.filter_allowed_tags(Guardian.new(user),
for_input: true,
exclude_has_synonyms: true,
exclude_synonyms: true,
term: 'disc'
)).to be_empty
end
end
end
end
@ -357,6 +393,27 @@ describe DiscourseTagging do
expect(valid).to eq(true)
end
end
context 'tag synonyms' do
fab!(:topic) { Fabricate(:topic) }
fab!(:syn1) { Fabricate(:tag, name: 'synonym1', target_tag: tag1) }
fab!(:syn2) { Fabricate(:tag, name: 'synonym2', target_tag: tag1) }
it "uses the base tag when a synonym is given" do
valid = DiscourseTagging.tag_topic_by_names(topic, Guardian.new(user), [syn1.name])
expect(valid).to eq(true)
expect(topic.errors[:base]).to be_empty
expect_same_tag_names(topic.reload.tags, [tag1])
end
it "handles multiple synonyms for the same tag" do
valid = DiscourseTagging.tag_topic_by_names(topic, Guardian.new(user), [tag1.name, syn1.name, syn2.name])
expect(valid).to eq(true)
expect(topic.errors[:base]).to be_empty
expect_same_tag_names(topic.reload.tags, [tag1])
end
end
end
describe '#tags_for_saving' do
@ -440,4 +497,67 @@ describe DiscourseTagging do
expect(DiscourseTagging.staff_tag_names).to contain_exactly(other_staff_tag.name)
end
end
describe '#add_or_create_synonyms_by_name' do
it "can add an existing tag" do
expect {
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])).to eq(true)
}.to_not change { Tag.count }
expect_same_tag_names(tag1.reload.synonyms, [tag2])
expect(tag2.reload.target_tag).to eq(tag1)
end
it "can add existing tag with wrong case" do
expect {
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name.upcase])).to eq(true)
}.to_not change { Tag.count }
expect_same_tag_names(tag1.reload.synonyms, [tag2])
expect(tag2.reload.target_tag).to eq(tag1)
end
it "can create new tags" do
expect {
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, ['synonym1'])).to eq(true)
}.to change { Tag.count }.by(1)
s = Tag.where_name('synonym1').first
expect_same_tag_names(tag1.reload.synonyms, [s])
expect(s.target_tag).to eq(tag1)
end
it "can add existing and new tags" do
expect {
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name, 'synonym1'])).to eq(true)
}.to change { Tag.count }.by(1)
s = Tag.where_name('synonym1').first
expect_same_tag_names(tag1.reload.synonyms, [tag2, s])
expect(s.target_tag).to eq(tag1)
expect(tag2.reload.target_tag).to eq(tag1)
end
it "can change a synonym's target tag" do
synonym = Fabricate(:tag, name: 'synonym1', target_tag: tag1)
expect {
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag2, [synonym.name])).to eq(true)
}.to_not change { Tag.count }
expect_same_tag_names(tag2.reload.synonyms, [synonym])
expect(tag1.reload.synonyms.count).to eq(0)
expect(synonym.reload.target_tag).to eq(tag2)
end
it "doesn't allow tags that have synonyms to become synonyms" do
tag2.synonyms << Fabricate(:tag)
value = DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])
expect(value).to be_a(Array)
expect(value.size).to eq(1)
expect(value.first.errors[:target_tag_id]).to be_present
expect(tag1.reload.synonyms.count).to eq(0)
expect(tag2.reload.synonyms.count).to eq(1)
end
it "changes tag of topics" do
topic = Fabricate(:topic, tags: [tag2])
expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])).to eq(true)
expect_same_tag_names(topic.reload.tags, [tag1])
end
end
end

View File

@ -669,13 +669,15 @@ describe Search do
let(:category) { Fabricate(:category_with_definition) }
context 'post searching' do
it 'can find posts with tags' do
before do
SiteSetting.tagging_enabled = true
post = Fabricate(:post, raw: 'I am special post')
DiscourseTagging.tag_topic_by_names(post.topic, Guardian.new(Fabricate.build(:admin)), [tag.name, uppercase_tag.name])
post.topic.save
end
let(:post) { Fabricate(:post, raw: 'I am special post') }
it 'can find posts with tags' do
# we got to make this index (it is deferred)
Jobs::ReindexSearch.new.rebuild_problem_posts
@ -690,6 +692,13 @@ describe Search do
result = Search.execute(tag.name)
expect(result.posts.length).to eq(0)
end
it 'can find posts with tag synonyms' do
synonym = Fabricate(:tag, name: 'synonym', target_tag: tag)
Jobs::ReindexSearch.new.rebuild_problem_posts
result = Search.execute(synonym.name)
expect(result.posts.length).to eq(1)
end
end
context 'tagging is disabled' do

View File

@ -178,6 +178,7 @@ describe TopicQuery do
fab!(:tagged_topic3) { Fabricate(:topic, tags: [tag, other_tag]) }
fab!(:tagged_topic4) { Fabricate(:topic, tags: [uppercase_tag]) }
fab!(:no_tags_topic) { Fabricate(:topic) }
let(:synonym) { Fabricate(:tag, target_tag: tag, name: 'synonym') }
it "returns topics with the tag when filtered to it" do
expect(TopicQuery.new(moderator, tags: tag.name).list_latest.topics)
@ -210,6 +211,26 @@ describe TopicQuery do
it "can return topics with no tags" do
expect(TopicQuery.new(moderator, no_tags: true).list_latest.topics.map(&:id)).to eq([no_tags_topic.id])
end
it "can filter using a synonym" do
expect(TopicQuery.new(moderator, tags: synonym.name).list_latest.topics)
.to contain_exactly(tagged_topic1, tagged_topic3)
expect(TopicQuery.new(moderator, tags: [synonym.id]).list_latest.topics)
.to contain_exactly(tagged_topic1, tagged_topic3)
expect(TopicQuery.new(
moderator, tags: [synonym.name, other_tag.name]
).list_latest.topics).to contain_exactly(
tagged_topic1, tagged_topic2, tagged_topic3
)
expect(TopicQuery.new(moderator, tags: [synonym.id, other_tag.id]).list_latest.topics)
.to contain_exactly(tagged_topic1, tagged_topic2, tagged_topic3)
expect(TopicQuery.new(moderator, tags: ["SYnonYM"]).list_latest.topics)
.to contain_exactly(tagged_topic1, tagged_topic3)
end
end
context "and categories too" do

View File

@ -5,14 +5,6 @@ require 'rails_helper'
describe "category tag restrictions" do
def sorted_tag_names(tag_records)
tag_records.map { |t| t.is_a?(String) ? t : t.name }.sort
end
def expect_same_tag_names(a, b)
expect(sorted_tag_names(a)).to eq(sorted_tag_names(b))
end
def filter_allowed_tags(opts = {})
DiscourseTagging.filter_allowed_tags(Guardian.new(user), opts)
end

View File

@ -87,4 +87,35 @@ describe TagGroup do
include_examples "correct visible tag groups"
end
end
describe 'tag_names=' do
let(:tag_group) { Fabricate(:tag_group) }
fab!(:tag) { Fabricate(:tag) }
before { SiteSetting.tagging_enabled = true }
it "can use existing tags and create new ones" do
expect {
tag_group.tag_names = [tag.name, 'new-tag']
}.to change { Tag.count }.by(1)
expect_same_tag_names(tag_group.reload.tags, [tag, 'new-tag'])
end
context 'with synonyms' do
fab!(:synonym) { Fabricate(:tag, name: 'synonym', target_tag: tag) }
it "adds synonyms from base tags too" do
expect {
tag_group.tag_names = [tag.name, 'new-tag']
}.to change { Tag.count }.by(1)
expect_same_tag_names(tag_group.reload.tags, [tag, 'new-tag', synonym])
end
it "removes tags correctly" do
tag_group.update!(tag_names: [tag.name])
tag_group.tag_names = ['new-tag']
expect_same_tag_names(tag_group.reload.tags, ['new-tag'])
end
end
end
end

View File

@ -13,6 +13,7 @@ describe Tag do
end
let(:tag) { Fabricate(:tag) }
let(:tag2) { Fabricate(:tag) }
let(:topic) { Fabricate(:topic, tags: [tag]) }
before do
@ -46,6 +47,12 @@ describe Tag do
expect(event[:event_name]).to eq(:tag_destroyed)
expect(event[:params].first).to eq(subject)
end
it 'removes it from its tag group' do
tag_group = Fabricate(:tag_group, tags: [tag])
expect { tag.destroy }.to change { TagGroupMembership.count }.by(-1)
expect(tag_group.reload.tags).to be_empty
end
end
it "can delete tags on deleted topics" do
@ -188,4 +195,56 @@ describe Tag do
expect(Tag.unused.pluck(:name)).to contain_exactly("unused1", "unused2")
end
end
context "synonyms" do
let(:synonym) { Fabricate(:tag, target_tag: tag) }
it "can be a synonym for another tag" do
expect(synonym).to be_synonym
expect(synonym.target_tag).to eq(tag)
end
it "cannot have a synonym of a synonym" do
synonym2 = Fabricate.build(:tag, target_tag: synonym)
expect(synonym2).to_not be_valid
expect(synonym2.errors[:target_tag_id]).to be_present
end
it "a tag with synonyms cannot become a synonym" do
synonym
tag.target_tag = Fabricate(:tag)
expect(tag).to_not be_valid
expect(tag.errors[:target_tag_id]).to be_present
end
it "can be added to a tag group" do
tag_group = Fabricate(:tag_group, tags: [tag])
synonym
expect(tag_group.reload.tags).to include(synonym)
end
it "can be added to a category" do
category = Fabricate(:category, tags: [tag])
synonym
expect(category.reload.tags).to include(synonym)
end
it "destroying a tag destroys its synonyms" do
synonym
expect { tag.destroy }.to change { Tag.count }.by(-2)
expect(Tag.find_by_id(synonym.id)).to be_nil
end
it "can add a tag from the same tag group as a synonym" do
tag_group = Fabricate(:tag_group, tags: [tag, tag2])
tag2.update!(target_tag: tag)
expect(tag_group.reload.tags).to include(tag2)
end
it "can add a tag restricted to the same category as a synonym" do
category = Fabricate(:category, tags: [tag, tag2])
tag2.update!(target_tag: tag)
expect(category.reload.tags).to include(tag2)
end
end
end

View File

@ -40,6 +40,28 @@ describe TagUser do
TagUser.change(user.id, tag.id, regular)
expect(TopicUser.get(topic, user).notification_level).to eq tracking
end
it "watches or tracks on change using a synonym" do
user = Fabricate(:user)
tag = Fabricate(:tag)
synonym = Fabricate(:tag, target_tag: tag)
post = create_post(tags: [tag.name])
topic = post.topic
TopicUser.change(user.id, topic.id, total_msecs_viewed: 1)
TagUser.change(user.id, synonym.id, tracking)
expect(TopicUser.get(topic, user).notification_level).to eq tracking
TagUser.change(user.id, synonym.id, watching)
expect(TopicUser.get(topic, user).notification_level).to eq watching
TagUser.change(user.id, synonym.id, regular)
expect(TopicUser.get(topic, user).notification_level).to eq tracking
expect(TagUser.where(user_id: user.id, tag_id: synonym.id).first).to be_nil
expect(TagUser.where(user_id: user.id, tag_id: tag.id).first).to be_present
end
end
context "batch_set" do
@ -65,6 +87,30 @@ describe TagUser do
expect(TopicUser.get(topic, user).notification_level).to eq tracking
end
it "watches and unwatches tags correctly using tag synonym" do
user = Fabricate(:user)
tag = Fabricate(:tag)
synonym = Fabricate(:tag, target_tag: tag)
post = create_post(tags: [tag.name])
topic = post.topic
# we need topic user record to ensure watch picks up other wise it is implicit
TopicUser.change(user.id, topic.id, total_msecs_viewed: 1)
TagUser.batch_set(user, :tracking, [synonym.name])
expect(TopicUser.get(topic, user).notification_level).to eq tracking
TagUser.batch_set(user, :watching, [synonym.name])
expect(TopicUser.get(topic, user).notification_level).to eq watching
TagUser.batch_set(user, :watching, [])
expect(TopicUser.get(topic, user).notification_level).to eq tracking
end
end
context "integration" do

View File

@ -23,10 +23,9 @@ describe TagsController do
describe '#index' do
before do
Fabricate(:tag, name: 'test')
Fabricate(:tag, name: 'topic-test', topic_count: 1)
end
fab!(:test_tag) { Fabricate(:tag, name: 'test') }
fab!(:topic_tag) { Fabricate(:tag, name: 'topic-test', topic_count: 1) }
fab!(:synonym) { Fabricate(:tag, name: 'synonym', target_tag: topic_tag) }
shared_examples "successfully retrieve tags with topic_count > 0" do
it "should return the right response" do
@ -43,6 +42,19 @@ describe TagsController do
context "with tags_listed_by_group enabled" do
before { SiteSetting.tags_listed_by_group = true }
include_examples "successfully retrieve tags with topic_count > 0"
it "works for tags in groups" do
tag_group = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym])
get "/tags.json"
expect(response.status).to eq(200)
tags = json["tags"]
expect(tags.length).to eq(0)
group = json.dig('extras', 'tag_groups')&.first
expect(group).to be_present
expect(group['tags'].length).to eq(2)
expect(group['tags'].map { |t| t['id'] }).to contain_exactly(test_tag.name, topic_tag.name)
end
end
context "with tags_listed_by_group disabled" do
@ -79,6 +91,12 @@ describe TagsController do
expect(response.status).to eq(404)
end
it "should handle synonyms" do
synonym = Fabricate(:tag, target_tag: tag)
get "/tags/#{synonym.name}"
expect(response.status).to eq(200)
end
it "does not show staff-only tags" do
tag_group = Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["test"])
@ -158,6 +176,79 @@ describe TagsController do
end
end
describe '#info' do
fab!(:tag) { Fabricate(:tag, name: 'test') }
let(:synonym) { Fabricate(:tag, name: 'synonym', target_tag: tag) }
it "returns 404 if tag not found" do
get "/tags/nope/info.json"
expect(response.status).to eq(404)
end
it "can handle tag with no synonyms" do
get "/tags/#{tag.name}/info.json"
expect(response.status).to eq(200)
expect(json.dig('tag_info', 'name')).to eq(tag.name)
expect(json.dig('tag_info', 'synonyms')).to be_empty
expect(json.dig('tag_info', 'category_ids')).to be_empty
end
it "can handle a synonym" do
get "/tags/#{synonym.name}/info.json"
expect(response.status).to eq(200)
expect(json.dig('tag_info', 'name')).to eq(synonym.name)
expect(json.dig('tag_info', 'synonyms')).to be_empty
expect(json.dig('tag_info', 'category_ids')).to be_empty
end
it "can return a tag's synonyms" do
synonym
get "/tags/#{tag.name}/info.json"
expect(response.status).to eq(200)
expect(json.dig('tag_info', 'synonyms').map { |t| t['text'] }).to eq([synonym.name])
end
it "returns 404 if tag is staff-only" do
tag_group = Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["test"])
get "/tags/test/info.json"
expect(response.status).to eq(404)
end
it "staff-only tags can be retrieved for staff user" do
sign_in(admin)
tag_group = Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["test"])
get "/tags/test/info.json"
expect(response.status).to eq(200)
end
it "can return category restrictions" do
category.update!(tags: [tag])
category2 = Fabricate(:category)
tag_group = Fabricate(:tag_group, tags: [tag])
category2.update!(tag_groups: [tag_group])
staff_category = Fabricate(:private_category, group: Fabricate(:group), tags: [tag])
get "/tags/#{tag.name}/info.json"
expect(json.dig('tag_info', 'category_ids')).to contain_exactly(category.id, category2.id)
expect(json['categories']).to be_present
end
context 'tag belongs to a tag group' do
fab!(:tag_group) { Fabricate(:tag_group, tags: [tag]) }
it "returns tag groups if tag groups are visible" do
SiteSetting.tags_listed_by_group = true
get "/tags/#{tag.name}/info.json"
expect(json.dig('tag_info', 'tag_group_names')).to eq([tag_group.name])
end
it "doesn't return tag groups if tag groups aren't visible" do
SiteSetting.tags_listed_by_group = false
get "/tags/#{tag.name}/info.json"
expect(json['tag_info'].has_key?('tag_group_names')).to eq(false)
end
end
end
describe '#check_hashtag' do
fab!(:tag) { Fabricate(:tag) }
@ -472,6 +563,31 @@ describe TagsController do
end
end
context 'with synonyms' do
fab!(:tag) { Fabricate(:tag, name: 'plant') }
fab!(:synonym) { Fabricate(:tag, name: 'plants', target_tag: tag) }
it "can return synonyms" do
get "/tags/filter/search.json", params: { q: 'plant' }
expect(response.status).to eq(200)
expect(json['results'].map { |j| j['id'] }).to contain_exactly('plant', 'plants')
end
it "can omit synonyms" do
get "/tags/filter/search.json", params: { q: 'plant', excludeSynonyms: 'true' }
expect(response.status).to eq(200)
expect(json['results'].map { |j| j['id'] }).to contain_exactly('plant')
end
it "can return a message about synonyms not being allowed" do
get "/tags/filter/search.json", params: { q: 'plants', excludeSynonyms: 'true' }
expect(response.status).to eq(200)
expect(json["results"].map { |j| j["id"] }.sort).to eq([])
expect(json["forbidden"]).to be_present
expect(json["forbidden_message"]).to eq(I18n.t("tags.forbidden.synonym", tag_name: tag.name))
end
end
it "matches tags after sanitizing input" do
yup, nope = Fabricate(:tag, name: 'yup'), Fabricate(:tag, name: 'nope')
get "/tags/filter/search.json", params: { q: 'N/ope' }
@ -612,4 +728,90 @@ describe TagsController do
end
end
end
describe '#create_synonyms' do
fab!(:tag) { Fabricate(:tag) }
it 'fails if not logged in' do
post "/tags/#{tag.name}/synonyms.json", params: { synonyms: ['synonym1'] }
expect(response.status).to eq(403)
end
it 'fails if not staff user' do
sign_in(user)
post "/tags/#{tag.name}/synonyms.json", params: { synonyms: ['synonym1'] }
expect(response.status).to eq(403)
end
context 'signed in as admin' do
before { sign_in(admin) }
it 'can make a tag a synonym of another tag' do
tag2 = Fabricate(:tag)
expect {
post "/tags/#{tag.name}/synonyms.json", params: { synonyms: [tag2.name] }
}.to_not change { Tag.count }
expect(response.status).to eq(200)
expect(tag2.reload.target_tag).to eq(tag)
end
it 'can create new tags at the same time' do
expect {
post "/tags/#{tag.name}/synonyms.json", params: { synonyms: ['synonym'] }
}.to change { Tag.count }.by(1)
expect(response.status).to eq(200)
expect(Tag.find_by_name('synonym')&.target_tag).to eq(tag)
end
it 'can return errors' do
tag2 = Fabricate(:tag, target_tag: tag)
tag3 = Fabricate(:tag)
post "/tags/#{tag3.name}/synonyms.json", params: { synonyms: [tag.name] }
expect(response.status).to eq(200)
json = JSON.parse(response.body)
expect(json['failed']).to be_present
expect(json.dig('failed_tags', tag.name)).to be_present
end
end
end
describe '#destroy_synonym' do
fab!(:tag) { Fabricate(:tag) }
fab!(:synonym) { Fabricate(:tag, target_tag: tag, name: 'synonym') }
subject { delete("/tags/#{tag.name}/synonyms/#{synonym.name}.json") }
it 'fails if not logged in' do
subject
expect(response.status).to eq(403)
end
it 'fails if not staff user' do
sign_in(user)
subject
expect(response.status).to eq(403)
end
context 'signed in as admin' do
before { sign_in(admin) }
it "can remove a synonym from a tag" do
synonym2 = Fabricate(:tag, target_tag: tag, name: 'synonym2')
expect { subject }.to_not change { Tag.count }
expect_same_tag_names(tag.reload.synonyms, [synonym2])
expect(synonym.reload).to_not be_synonym
end
it "returns error if tag isn't a synonym" do
delete "/tags/#{Fabricate(:tag).name}/synonyms/#{synonym.name}.json"
expect(response.status).to eq(400)
expect_same_tag_names(tag.reload.synonyms, [synonym])
end
it "returns error if synonym not found" do
delete "/tags/#{Fabricate(:tag).name}/synonyms/nope.json"
expect(response.status).to eq(404)
expect_same_tag_names(tag.reload.synonyms, [synonym])
end
end
end
end

View File

@ -1701,14 +1701,16 @@ describe UsersController do
let!(:user) { sign_in(Fabricate(:user)) }
it 'allows the update' do
SiteSetting.tagging_enabled = true
user2 = Fabricate(:user)
user3 = Fabricate(:user)
tags = [Fabricate(:tag), Fabricate(:tag)]
tag_synonym = Fabricate(:tag, target_tag: tags[1])
put "/u/#{user.username}.json", params: {
name: 'Jim Tom',
muted_usernames: "#{user2.username},#{user3.username}",
watched_tags: "#{tags[0].name},#{tags[1].name}",
watched_tags: "#{tags[0].name},#{tag_synonym.name}",
card_background_upload_url: upload.url,
profile_background_upload_url: upload.url
}

View File

@ -20,4 +20,12 @@ describe TagGroupSerializer do
expect(serialized[:permissions].keys).to contain_exactly("staff")
end
it "doesn't return tag synonyms" do
tag = Fabricate(:tag)
synonym = Fabricate(:tag, target_tag: tag)
tag_group = Fabricate(:tag_group, tags: [tag, synonym])
serialized = TagGroupSerializer.new(tag_group, root: false).as_json
expect(serialized[:tag_names]).to eq([tag.name])
end
end

View File

@ -120,6 +120,14 @@ module Helpers
end
end
def sorted_tag_names(tag_records)
tag_records.map { |t| t.is_a?(String) ? t : t.name }.sort
end
def expect_same_tag_names(a, b)
expect(sorted_tag_names(a)).to eq(sorted_tag_names(b))
end
def capture_stdout
old_stdout = $stdout
io = StringIO.new

View File

@ -180,3 +180,140 @@ test("new topic button is not available for staff-only tags", async assert => {
await visit("/tags/staff-only-tag");
assert.ok(find("#create-topic:disabled").length === 0);
});
acceptance("Tag info", {
loggedIn: true,
settings: {
tags_listed_by_group: true
},
pretend(server, helper) {
server.get("/tags/planters/notifications", () => {
return helper.response({
tag_notification: { id: "planters", notification_level: 1 }
});
});
server.get("/tags/planters/l/latest.json", () => {
return helper.response({
users: [],
primary_groups: [],
topic_list: {
can_create_topic: true,
draft: null,
draft_key: "new_topic",
draft_sequence: 1,
per_page: 30,
tags: [
{
id: 1,
name: "planters",
topic_count: 1
}
],
topics: []
}
});
});
server.get("/tags/planters/info", () => {
return helper.response({
tag_info: {
id: 12,
name: "planters",
topic_count: 1,
staff: false,
synonyms: [
{
id: "containers",
text: "containers"
},
{
id: "planter",
text: "planter"
}
],
tag_group_names: ["Gardening"],
category_ids: [7]
},
categories: [
{
id: 7,
name: "Outdoors",
color: "000",
text_color: "FFFFFF",
slug: "outdoors",
topic_count: 701,
post_count: 5320,
description: "Talk about the outdoors.",
description_text: "Talk about the outdoors.",
topic_url: "/t/category-definition-for-outdoors/1026",
read_restricted: false,
permission: null,
notification_level: null
}
]
});
});
}
});
test("tag info can show synonyms", async assert => {
updateCurrentUser({ moderator: false, admin: false });
await visit("/tags/planters");
assert.ok(find("#show-tag-info").length === 1);
await click("#show-tag-info");
assert.ok(exists(".tag-info .tag-name"), "show tag");
assert.ok(
find(".tag-info .tag-associations")
.text()
.indexOf("Gardening") >= 0,
"show tag group names"
);
assert.ok(
find(".tag-info .synonyms-list .tag-box").length === 2,
"shows the synonyms"
);
assert.ok(
find(".tag-info .badge-category").length === 1,
"show the category"
);
assert.ok(!exists("#rename-tag"), "can't rename tag");
assert.ok(!exists("#edit-synonyms"), "can't edit synonyms");
assert.ok(!exists("#delete-tag"), "can't delete tag");
});
test("admin can manage tags", async assert => {
server.delete("/tags/planters/synonyms/containers", () => [
200,
{ "Content-Type": "application/json" },
{ success: true }
]);
updateCurrentUser({ moderator: false, admin: true });
await visit("/tags/planters");
assert.ok(find("#show-tag-info").length === 1);
await click("#show-tag-info");
assert.ok(exists("#rename-tag"), "can rename tag");
assert.ok(exists("#edit-synonyms"), "can edit synonyms");
assert.ok(exists("#delete-tag"), "can delete tag");
await click("#edit-synonyms");
assert.ok(
find(".unlink-synonym:visible").length === 2,
"unlink UI is visible"
);
assert.ok(
find(".delete-synonym:visible").length === 2,
"delete UI is visible"
);
await click(".unlink-synonym:first");
assert.ok(
find(".tag-info .synonyms-list .tag-box").length === 1,
"removed a synonym"
);
});

View File

@ -25,13 +25,13 @@ componentTest("default", {
if (params.queryParams.q === "rég") {
return response({
"results": [
{ "id": "régis", "text": "régis", "count": 2, "pm_count": 0 }
{ "id": "régis", "text": "régis", "count": 2, "pm_count": 0, target_tag: null }
]
});
} else if (params.queryParams.q === "dav") {
return response({
"results": [
{ "id": "David", "text": "David", "count": 2, "pm_count": 0 }
{ "id": "David", "text": "David", "count": 2, "pm_count": 0, target_tag: null }
]
});
}
@ -77,6 +77,42 @@ componentTest("default", {
}
});
componentTest("synonym", {
template: "{{tag-drop}}",
beforeEach() {
this.site.set("can_create_tag", true);
this.set("site.top_tags", ["jeff", "neil", "arpit", "régis"]);
const response = object => {
return [200, { "Content-Type": "application/json" }, object];
};
// prettier-ignore
server.get("/tags/filter/search", (params) => { //eslint-disable-line
if (params.queryParams.q === "robin") {
return response({
"results": [
{ "id": "Robin", "text": "Robin", "count": 2, "pm_count": 0, target_tag: 'EvilTrout' }
]
});
}
});
},
async test(assert) {
await this.subject.expand();
sandbox.stub(DiscourseURL, "routeTo");
await this.subject.fillInFilter("robin");
await this.subject.keyboard("enter");
assert.ok(
DiscourseURL.routeTo.calledWith("/tags/eviltrout"),
"it routes to the target tag"
);
}
});
componentTest("no tags", {
template: "{{tag-drop}}",