0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 20:29:55 +08:00
discourse/app/models/tag.rb
David Taylor 37b64504c9
FEATURE: Support markdown in tag descriptions (#41996)
We previously supported a subset of HTML in tag descriptions, but the
escaping/unescaping of characters wasn't perfectly consistent, so
results could be surprising.

This commit runs tag descriptions through our standard markdown
pipeline, which supports the same subset of HTML, plus real markdown. It
also adds the standard DEditor in tag editing forms. This has parity
with group/user bios, and with category descriptions.

Now the character support is clearly defined, and perfectly matches
other parts of Discourse.

---

<img width="527" height="545" alt="SCR-20260723-rdpm"
src="https://github.com/user-attachments/assets/812a9a3a-ece7-4594-8d9b-65e7e30649c8"
/>

---

<img width="564" height="213" alt="SCR-20260723-rduy"
src="https://github.com/user-attachments/assets/4ea4a6ff-81d3-4489-95b3-bf5f9b1e5fa0"
/>
---
2026-07-29 14:43:47 +01:00

370 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
class Tag < ActiveRecord::Base
include Searchable
include HasDestroyedWebHook
include HasCookedTagDescription
include Localizable
RESERVED_TAGS = [
"none",
"constructor", # prevents issues with javascript's constructor of objects
]
validates :name, presence: true, uniqueness: { case_sensitive: false }
validates :slug, uniqueness: { case_sensitive: false }, allow_blank: true
validate :target_tag_validator,
if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? }
validate :name_validator
validates :description, length: { maximum: 1000 }
before_validation :ensure_slug
scope :where_name,
->(name) do
name = Array(name).map(&:downcase)
where("lower(tags.name) IN (?)", name)
end
# tags that have never been used and don't belong to a tag group
scope :unused,
-> do
where(staff_topic_count: 0, pm_topic_count: 0, target_tag_id: nil).joins(
"LEFT JOIN tag_group_memberships tgm ON tags.id = tgm.tag_id",
).where("tgm.tag_id IS NULL")
end
scope :used_tags_in_regular_topics,
->(guardian) { where("tags.#{Tag.topic_count_column(guardian)} > 0") }
scope :base_tags, -> { where(target_tag_id: nil) }
scope :visible, ->(guardian = nil) { merge(DiscourseTagging.visible_tags(guardian)) }
has_many :tag_users, dependent: :destroy # notification settings
has_many :topic_tags, dependent: :destroy
has_many :topics, through: :topic_tags
has_many :category_tag_stats, dependent: :destroy
has_many :category_tags, dependent: :destroy
has_many :categories, through: :category_tags
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
has_many :sidebar_section_links, as: :linkable, dependent: :delete_all
has_many :embeddable_host_tags
has_many :embeddable_hosts, through: :embeddable_host_tags
before_save :cook_description
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
after_commit :trigger_tag_destroyed_event, on: :destroy
def self.ensure_consistency!
update_topic_counts
end
def self.update_topic_counts
DB.exec <<~SQL
UPDATE tags t
SET staff_topic_count = x.topic_count
FROM (
SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id
FROM tags
LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id
LEFT JOIN topics ON topics.id = topic_tags.topic_id
AND topics.deleted_at IS NULL
AND topics.archetype != 'private_message'
GROUP BY tags.id
) x
WHERE x.tag_id = t.id
AND x.topic_count <> t.staff_topic_count
SQL
DB.exec <<~SQL
UPDATE tags t
SET public_topic_count = x.topic_count
FROM (
WITH tags_with_public_topics AS (
SELECT
COUNT(topics.id) AS topic_count,
tags.id AS tag_id
FROM tags
INNER JOIN topic_tags ON tags.id = topic_tags.tag_id
INNER JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message'
INNER JOIN categories ON categories.id = topics.category_id AND NOT categories.read_restricted
GROUP BY tags.id
)
SELECT
COALESCE(tags_with_public_topics.topic_count, 0 ) AS topic_count,
tags.id AS tag_id
FROM tags
LEFT JOIN tags_with_public_topics ON tags_with_public_topics.tag_id = tags.id
) x
WHERE x.tag_id = t.id
AND x.topic_count <> t.public_topic_count;
SQL
DB.exec <<~SQL
UPDATE tags t
SET pm_topic_count = x.pm_topic_count
FROM (
SELECT COUNT(topics.id) AS pm_topic_count, tags.id AS tag_id
FROM tags
LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id
LEFT JOIN topics ON topics.id = topic_tags.topic_id
AND topics.deleted_at IS NULL
AND topics.archetype = 'private_message'
GROUP BY tags.id
) x
WHERE x.tag_id = t.id
AND x.pm_topic_count <> t.pm_topic_count
SQL
end
def self.find_by_name(name)
find_by("lower(name) = ?", name.downcase)
end
def self.top_tags(limit_arg: nil, category: nil, guardian: Guardian.new)
# we add 1 to max_tags_in_filter_list to efficiently know we have more tags
# than the limit. Frontend is responsible to enforce limit.
limit = limit_arg || (SiteSetting.max_tags_in_filter_list + 1)
scope_category_ids = guardian.allowed_category_ids
scope_category_ids &= ([category.id] + category.subcategories.pluck(:id)) if category
return [] if scope_category_ids.empty?
filter_sql =
(
if guardian.is_staff?
""
else
" AND tags.id IN (#{DiscourseTagging.visible_tags(guardian).select(:id).to_sql})"
end
)
tag_data = DB.query <<~SQL
SELECT tags.id as tag_id, tags.name as tag_name, tags.slug as tag_slug, SUM(stats.topic_count) AS sum_topic_count
FROM category_tag_stats stats
JOIN tags ON stats.tag_id = tags.id AND stats.topic_count > 0
WHERE stats.category_id in (#{scope_category_ids.join(",")})
#{filter_sql}
GROUP BY tags.id
ORDER BY sum_topic_count DESC, tag_name ASC
LIMIT #{limit}
SQL
return [] if tag_data.empty?
unless SiteSetting.content_localization_enabled
return(
tag_data.map do |row|
slug = row.tag_slug.presence || "#{row.tag_id}-tag"
{ id: row.tag_id, name: row.tag_name, slug: }
end
)
end
tags_by_id = Tag.where(id: tag_data.map(&:tag_id)).includes(:localizations).index_by(&:id)
show_localized = !ContentLocalization.show_original?(guardian)
tag_data.filter_map do |row|
tag = tags_by_id[row.tag_id]
next unless tag
name = show_localized ? (tag.get_localization&.name || tag.name) : tag.name
slug = row.tag_slug.presence || "#{row.tag_id}-tag"
{ id: tag.id, name:, slug: }
end
end
def self.topic_count_column(guardian)
if guardian&.is_staff? || SiteSetting.include_secure_categories_in_tag_counts
"staff_topic_count"
else
"public_topic_count"
end
end
def self.with_localizations(tags)
return tags unless SiteSetting.content_localization_enabled && tags.present?
tag_ids = tags.map(&:id)
tags_by_id = where(id: tag_ids).includes(:localizations).index_by(&:id)
tag_ids.filter_map { |id| tags_by_id[id] }
end
def self.pm_tags(limit: 1000, guardian: nil, allowed_user: nil)
return [] if allowed_user.blank? || !(guardian || Guardian.new).can_tag_pms?
user_id = allowed_user.id
DB.query_hash(<<~SQL).map!(&:symbolize_keys!)
SELECT tags.id as id, tags.name as name, COUNT(topics.id) AS count
FROM tags
JOIN topic_tags ON tags.id = topic_tags.tag_id
JOIN topics ON topics.id = topic_tags.topic_id
AND topics.deleted_at IS NULL
AND topics.archetype = 'private_message'
WHERE topic_tags.topic_id IN (
SELECT topic_id
FROM topic_allowed_users
WHERE user_id = #{user_id.to_i}
UNION
SELECT tg.topic_id
FROM topic_allowed_groups tg
JOIN group_users gu ON gu.user_id = #{user_id.to_i}
AND gu.group_id = tg.group_id
)
GROUP BY tags.id, tags.name
ORDER BY count DESC
LIMIT #{limit.to_i}
SQL
end
def self.recently_used_by(user, limit: 10)
return [] if user.blank?
recent_topic_ids =
Topic
.where(user:, archetype: Archetype.default)
.order(created_at: :desc, id: :desc)
.limit(limit)
.select(:id)
TopicTag
.joins(:topic)
.where(topic_id: recent_topic_ids)
.group(:tag_id)
.order(Arel.sql("MAX(topics.created_at) DESC, MAX(topics.id) DESC"))
.pluck(:tag_id)
end
def self.include_tags?
SiteSetting.tagging_enabled
end
def url
"#{Discourse.base_path}/tag/#{slug_for_url}/#{id}"
end
alias_method :relative_url, :url
def full_url
"#{Discourse.base_url}/tag/#{slug_for_url}/#{id}"
end
def slug_for_url
slug.presence || "#{id}-tag"
end
def index_search
SearchIndexer.index(self)
end
def synonym?
!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 do |tag_group|
tag_group.tags << self if tag_group.tags.exclude?(self)
end
target_tag.categories.each do |category|
category.tags << self if category.tags.exclude?(self)
end
end
end
def all_category_ids
@all_category_ids ||=
categories.pluck(:id) +
tag_groups.includes(:categories).flat_map { |tg| tg.categories.map(&:id) }
end
def all_categories(guardian)
categories = Category.secured(guardian).where(id: all_category_ids)
Category.preload_user_fields!(guardian, categories)
categories
end
%i[tag_created tag_updated tag_destroyed].each do |event|
define_method("trigger_#{event}_event") do
DiscourseEvent.trigger(event, self)
true
end
end
private
def ensure_slug
self.slug ||= ""
return if name.blank?
if self.slug.present? && will_save_change_to_slug? && slug != slugified_custom_slug
errors.add(:slug, :invalid)
elsif self.slug.blank? || (will_save_change_to_name? && !will_save_change_to_slug?)
self.slug = Slug.for(name, "")
self.slug = "" if self.slug.blank? || duplicate_slug?
end
end
def slugified_custom_slug
slug.parameterize
end
def duplicate_slug?
return false if slug.blank?
scope = Tag.where("lower(slug) = ?", slug.downcase)
scope = scope.where.not(id: id) if id.present?
scope.exists?
end
def name_validator
errors.add(:name, :invalid) if name.present? && RESERVED_TAGS.include?(name.strip.downcase)
end
end
# == Schema Information
#
# Table name: tags
#
# id :integer not null, primary key
# description :string(1000)
# description_cooked :string(2000)
# description_cooked_version :integer
# locale :string(20)
# name :string not null
# pm_topic_count :integer default(0), not null
# public_topic_count :integer default(0), not null
# slug :string default(""), not null
# staff_topic_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# target_tag_id :integer
#
# Indexes
#
# index_tags_on_description_cooked_version (description_cooked_version)
# index_tags_on_lower_name (lower((name)::text)) UNIQUE
# index_tags_on_name (name) UNIQUE
# index_tags_on_slug (slug) WHERE ((slug)::text <> ''::text)
# index_tags_on_target_tag_id (target_tag_id) WHERE (target_tag_id IS NOT NULL)
#