discourse/lib/discourse_tagging.rb
Neil Lalonde b6d140e4bd UX: tag input suggests required tags if none have been selected
This is a follow-up to the new feature that allows a category to
require a certain number of tags from a tag group. The tag input will
shows results from the required group if none have been chosen yet.
Once a require tag is selected, the tag input will include other
results as usual. Staff users can ignore this restriction, so the input
behaviour is unchanged for them.
2019-11-04 16:51:54 -05:00

366 lines
13 KiB
Ruby

# frozen_string_literal: true
module DiscourseTagging
TAGS_FIELD_NAME = "tags"
TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<>
TAGS_STAFF_CACHE_KEY = "staff_tag_names"
TAG_GROUP_TAG_IDS_SQL = <<-SQL
SELECT tag_id
FROM tag_group_memberships tgm
INNER JOIN tag_groups tg
ON tgm.tag_group_id = tg.id
SQL
def self.tag_topic_by_names(topic, guardian, tag_names_arg, append: false)
if guardian.can_tag?(topic)
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || []
old_tag_names = topic.tags.pluck(:name) || []
new_tag_names = tag_names - old_tag_names
removed_tag_names = old_tag_names - tag_names
# Protect staff-only tags
unless guardian.is_staff?
all_staff_tags = DiscourseTagging.staff_tag_names
hidden_tags = DiscourseTagging.hidden_tag_names
staff_tags = new_tag_names & all_staff_tags
staff_tags += new_tag_names & hidden_tags
if staff_tags.present?
topic.errors.add(:base, I18n.t("tags.staff_tag_disallowed", tag: staff_tags.join(" ")))
return false
end
staff_tags = removed_tag_names & all_staff_tags
if staff_tags.present?
topic.errors.add(:base, I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" ")))
return false
end
tag_names += removed_tag_names & hidden_tags
end
category = topic.category
tag_names = tag_names + old_tag_names if append
if tag_names.present?
# guardian is explicitly nil cause we don't want to strip all
# staff tags that already passed validation
tags = filter_allowed_tags(
Tag.where_name(tag_names),
nil, # guardian
for_topic: true,
category: category,
selected_tags: tag_names
).to_a
if tags.size < tag_names.size && (category.nil? || category.allow_global_tags || (category.tags.count == 0 && category.tag_groups.count == 0))
tag_names.each do |name|
unless Tag.where_name(name).exists?
tags << Tag.create(name: name)
end
end
end
# add missing mandatory parent tags
tag_ids = tags.map(&:id)
parent_tags_map = DB.query("
SELECT tgm.tag_id, tg.parent_tag_id
FROM tag_groups tg
INNER JOIN tag_group_memberships tgm
ON tgm.tag_group_id = tg.id
WHERE tg.parent_tag_id IS NOT NULL
AND tgm.tag_id IN (?)
", tag_ids).inject({}) do |h, v|
h[v.tag_id] ||= []
h[v.tag_id] << v.parent_tag_id
h
end
missing_parent_tag_ids = parent_tags_map.map do |_, parent_tag_ids|
(tag_ids & parent_tag_ids).size == 0 ? parent_tag_ids.first : nil
end.compact.uniq
unless missing_parent_tag_ids.empty?
tags = tags + Tag.where(id: missing_parent_tag_ids).all
end
return false unless validate_min_required_tags_for_category(guardian, topic, category, tags)
return false unless validate_required_tags_from_group(guardian, topic, category, tags)
if tags.size == 0
topic.errors.add(:base, I18n.t("tags.forbidden.invalid", count: new_tag_names.size))
return false
end
topic.tags = tags
else
return false unless validate_min_required_tags_for_category(guardian, topic, category)
return false unless validate_required_tags_from_group(guardian, topic, category)
topic.tags = []
end
topic.tags_changed = true
end
true
end
def self.validate_min_required_tags_for_category(guardian, topic, category, tags = [])
if !guardian.is_staff? &&
category &&
category.minimum_required_tags > 0 &&
tags.length < category.minimum_required_tags
topic.errors.add(:base, I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags))
false
else
true
end
end
def self.validate_required_tags_from_group(guardian, topic, category, tags = [])
if !guardian.is_staff? &&
category &&
category.required_tag_group &&
(tags.length < category.min_tags_from_required_group ||
category.required_tag_group.tags.where("tags.id in (?)", tags.map(&:id)).count < category.min_tags_from_required_group)
topic.errors.add(:base,
I18n.t(
"tags.required_tags_from_group",
count: category.min_tags_from_required_group,
tag_group_name: category.required_tag_group.name
)
)
false
else
true
end
end
# Options:
# term: a search term to filter tags by name
# category: a Category to which the object being tagged belongs
# for_input: result is for an input field, so only show permitted tags
# for_topic: results are for tagging a topic
# selected_tags: an array of tag names that are in the current selection
def self.filter_allowed_tags(query, guardian, opts = {})
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
if !opts[:for_topic] && !selected_tag_ids.empty?
query = query.where('tags.id NOT IN (?)', selected_tag_ids)
end
term = opts[:term]
if term.present?
term = term.gsub("_", "\\_")
clean_tag(term)
term.downcase!
query = query.where('lower(tags.name) like ?', "%#{term}%")
end
# Filters for category-specific tags:
category = opts[:category]
if opts[:for_input] && !guardian.nil? && !guardian.is_staff? && category&.required_tag_group
required_tag_ids = category.required_tag_group.tags.pluck(:id)
if (required_tag_ids & selected_tag_ids).size < category.min_tags_from_required_group
query = query.where('tags.id IN (?)', required_tag_ids)
end
end
if category && (category.tags.count > 0 || category.tag_groups.count > 0)
if category.allow_global_tags
# Select tags that:
# * are restricted to the given category
# * belong to no tag groups and aren't restricted to other categories
# * belong to tag groups that are not restricted to any categories
query = query.where(<<~SQL, category.tag_groups.pluck(:id), category.id)
tags.id IN (
SELECT t.id FROM tags t
LEFT JOIN category_tags ct ON t.id = ct.tag_id
LEFT JOIN (
SELECT xtgm.tag_id, xtgm.tag_group_id
FROM tag_group_memberships xtgm
INNER JOIN category_tag_groups ctg
ON xtgm.tag_group_id = ctg.tag_group_id
) AS tgm ON t.id = tgm.tag_id
WHERE (tgm.tag_group_id IS NULL AND ct.category_id IS NULL)
OR tgm.tag_group_id IN (?)
OR ct.category_id = ?
)
SQL
else
# Select only tags that are restricted to the given category
query = query.where(<<~SQL, category.id, category.tag_groups.pluck(:id))
tags.id IN (
SELECT tag_id FROM category_tags WHERE category_id = ?
UNION
SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?)
)
SQL
end
elsif opts[:for_input] || opts[:for_topic] || category
# exclude tags that are restricted to other categories
if CategoryTag.exists?
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)")
end
if CategoryTagGroup.exists?
tag_group_ids = CategoryTagGroup.pluck(:tag_group_id).uniq
query = query.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))", tag_group_ids)
end
end
if opts[:for_input] || opts[:for_topic]
unless guardian.nil? || guardian.is_staff?
all_staff_tags = DiscourseTagging.staff_tag_names
query = query.where('tags.name NOT IN (?)', all_staff_tags) if all_staff_tags.present?
end
end
if opts[:for_input]
# exclude tag groups that have a parent tag which is missing from selected_tags
if selected_tag_ids.empty?
sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id IS NOT NULL)"
query = query.where(sql)
else
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
if exclude_group_ids.empty?
# tags that don't belong to groups that require a parent tag,
# and tags that belong to groups with parent tag selected
query = query.where(<<~SQL, selected_tag_ids, selected_tag_ids)
tags.id NOT IN (
#{TAG_GROUP_TAG_IDS_SQL}
WHERE tg.parent_tag_id NOT IN (?)
)
OR tags.id IN (
#{TAG_GROUP_TAG_IDS_SQL}
WHERE tg.parent_tag_id IN (?)
)
SQL
else
# It's possible that the selected tags violate some one-tag-per-group restrictions,
# so filter them out by picking one from each group.
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
.where(tag_id: selected_tag_ids)
.where(tag_group_id: exclude_group_ids)
.map(&:tag_id)
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.parent_tag_id NOT IN (?) OR tg.id in (?))) OR tags.id IN (?))"
query = query.where(sql, selected_tag_ids, exclude_group_ids, limit_tag_ids)
end
end
elsif opts[:for_topic] && !selected_tag_ids.empty?
# One tag per group restriction
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
unless exclude_group_ids.empty?
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
.where(tag_id: selected_tag_ids)
.where(tag_group_id: exclude_group_ids)
.map(&:tag_id)
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.id in (?))) OR tags.id IN (?))"
query = query.where(sql, exclude_group_ids, limit_tag_ids)
end
end
if guardian.nil? || guardian.is_staff?
query
else
filter_visible(query, guardian)
end
end
def self.one_per_topic_group_ids(selected_tag_ids)
TagGroup.where(one_per_topic: true)
.joins(:tag_group_memberships)
.where('tag_group_memberships.tag_id in (?)', selected_tag_ids)
.pluck(:id)
end
def self.filter_visible(query, guardian = nil)
guardian&.is_staff? ? query : query.where("tags.id NOT IN (#{hidden_tags_query.select(:id).to_sql})")
end
def self.hidden_tag_names(guardian = nil)
guardian&.is_staff? ? [] : hidden_tags_query.pluck(:name)
end
def self.hidden_tags_query
Tag.joins(:tag_groups)
.where('tag_groups.id NOT IN (
SELECT tag_group_id
FROM tag_group_permissions
WHERE group_id = ?)',
Group::AUTO_GROUPS[:everyone]
)
end
def self.staff_tag_names
tag_names = Discourse.cache.read(TAGS_STAFF_CACHE_KEY, tag_names)
if !tag_names
tag_names = Tag.joins(tag_groups: :tag_group_permissions)
.where('tag_group_permissions.group_id = ? AND tag_group_permissions.permission_type = ?',
Group::AUTO_GROUPS[:everyone],
TagGroupPermission.permission_types[:readonly]
).pluck(:name)
Discourse.cache.write(TAGS_STAFF_CACHE_KEY, tag_names, expires_in: 1.hour)
end
tag_names
end
def self.clear_cache!
Discourse.cache.delete(TAGS_STAFF_CACHE_KEY)
end
def self.clean_tag(tag)
tag = tag.dup
tag.downcase! if SiteSetting.force_lowercase_tags
tag.strip!
tag.gsub!(/\s+/, '-')
tag.squeeze!('-')
tag.gsub!(TAGS_FILTER_REGEXP, '')
tag[0...SiteSetting.max_tag_length]
end
def self.tags_for_saving(tags_arg, guardian, opts = {})
return [] unless guardian.can_tag_topics? && tags_arg.present?
tag_names = Tag.where_name(tags_arg).pluck(:name)
if guardian.can_create_tag?
tag_names += (tags_arg - tag_names).map { |t| clean_tag(t) }
tag_names.delete_if { |t| t.blank? }
tag_names.uniq!
end
return opts[:unlimited] ? tag_names : tag_names[0...SiteSetting.max_tags_per_topic]
end
def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {})
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.each do |name|
taggable.tags << Tag.create(name: name)
end
end
end
end
def self.muted_tags(user)
return [] unless user
TagUser.lookup(user, :muted).joins(:tag).pluck('tags.name')
end
end