module DiscourseTagging TAGS_FIELD_NAME = "tags" TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> 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? staff_tags = DiscourseTagging.staff_only_tags(new_tag_names) if staff_tags.present? topic.errors[:base] << I18n.t("tags.staff_tag_disallowed", tag: staff_tags.join(" ")) return false end staff_tags = DiscourseTagging.staff_only_tags(removed_tag_names) if staff_tags.present? topic.errors[:base] << I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" ")) return false end 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.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 # validate minimum required tags for a category if !guardian.is_staff? && category && category.minimum_required_tags > 0 && tags.length < category.minimum_required_tags topic.errors[:base] << I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags) return false end topic.tags = tags else # validate minimum required tags for a category if !guardian.is_staff? && category && category.minimum_required_tags > 0 topic.errors[:base] << I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags) return false end topic.tags = [] end topic.tags_changed = true end true 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.gsub!("_", "\\_") term = clean_tag(term) query = query.where('tags.name like ?', "%#{term}%") end # Filters for category-specific tags: category = opts[:category] if category && (category.tags.count > 0 || category.tag_groups.count > 0) if category.tags.count > 0 && category.tag_groups.count > 0 tag_group_ids = category.tag_groups.pluck(:id) query = query.where( "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 (?))", category.id, tag_group_ids ) elsif category.tags.count > 0 query = query.where("tags.id IN (SELECT tag_id FROM category_tags WHERE category_id = ?)", category.id) else # category.tag_groups.count > 0 tag_group_ids = category.tag_groups.pluck(:id) query = query.where("tags.id IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))", tag_group_ids) 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? staff_tag_names = SiteSetting.staff_tags.split("|") query = query.where('tags.name NOT IN (?)', staff_tag_names) if staff_tag_names.present? end # exclude tag groups that have a parent tag which is missing from selected_tags select_sql = <<-SQL SELECT tag_id FROM tag_group_memberships tgm INNER JOIN tag_groups tg ON tgm.tag_group_id = tg.id SQL if selected_tag_ids.empty? sql = "tags.id NOT IN (#{select_sql} WHERE tg.parent_tag_id IS NOT NULL)" query = query.where(sql) else # One tag per group restriction exclude_group_ids = TagGroup.where(one_per_topic: true) .joins(:tag_group_memberships) .where('tag_group_memberships.tag_id in (?)', selected_tag_ids) .pluck(:id) if exclude_group_ids.empty? sql = "tags.id NOT IN (#{select_sql} WHERE tg.parent_tag_id NOT IN (?))" query = query.where(sql, selected_tag_ids) 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 (#{select_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 end if guardian.nil? || guardian.is_staff? query else filter_visible(query, guardian) end end def self.filter_visible(query, guardian = nil) if !guardian&.is_staff? && TagGroupPermission.where(group_id: Group::AUTO_GROUPS[:staff]).exists? query.where(filter_visible_sql) else query end end def self.filter_visible_sql @filter_visible_sql ||= <<~SQL tags.id NOT IN ( SELECT tgm.tag_id FROM tag_group_memberships tgm INNER JOIN tag_group_permissions tgp ON tgp.tag_group_id = tgm.tag_group_id AND tgp.group_id = #{Group::AUTO_GROUPS[:staff]}) SQL end def self.hidden_tag_names(guardian = nil) return [] if guardian&.is_staff? || !TagGroupPermission.where(group_id: Group::AUTO_GROUPS[:staff]).exists? tag_group_ids = TagGroupPermission.where(group_id: Group::AUTO_GROUPS[:staff]).pluck(:tag_group_id) Tag.includes(:tag_groups).where('tag_group_id in (?)', tag_group_ids).pluck(:name) end def self.clean_tag(tag) tag.downcase.strip .gsub(/\s+/, '-').squeeze('-') .gsub(TAGS_FILTER_REGEXP, '')[0...SiteSetting.max_tag_length] end def self.staff_only_tags(tags) return nil if tags.nil? staff_tags = SiteSetting.staff_tags.split("|") tag_diff = tags - staff_tags tag_diff = tags - tag_diff tag_diff.present? ? tag_diff : nil 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