2019-05-02 18:17:27 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2016-05-04 14:02:47 -04:00
|
|
|
class Tag < ActiveRecord::Base
|
2017-08-25 11:52:18 -04:00
|
|
|
include Searchable
|
2018-10-05 04:53:59 -04:00
|
|
|
include HasDestroyedWebHook
|
2023-08-16 11:43:54 -04:00
|
|
|
include HasSanitizableFields
|
2017-08-25 11:52:18 -04:00
|
|
|
|
2024-05-06 14:18:53 -04:00
|
|
|
self.ignored_columns = [
|
2024-05-06 23:06:31 -04:00
|
|
|
"topic_count", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
|
2024-05-06 14:18:53 -04:00
|
|
|
]
|
|
|
|
|
2019-12-12 06:26:41 -05:00
|
|
|
RESERVED_TAGS = [
|
2020-08-17 11:40:34 -04:00
|
|
|
"none",
|
|
|
|
"constructor", # prevents issues with javascript's constructor of objects
|
2019-12-12 06:26:41 -05:00
|
|
|
]
|
|
|
|
|
2020-05-25 22:45:45 -04:00
|
|
|
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
2019-12-12 06:26:41 -05:00
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
validate :target_tag_validator,
|
|
|
|
if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? }
|
2020-05-25 22:45:45 -04:00
|
|
|
validate :name_validator
|
2023-11-27 16:45:40 -05:00
|
|
|
validates :description, length: { maximum: 1000 }
|
2018-10-05 05:23:52 -04:00
|
|
|
|
|
|
|
scope :where_name,
|
2023-11-29 00:38:07 -05:00
|
|
|
->(name) do
|
2018-10-05 05:23:52 -04:00
|
|
|
name = Array(name).map(&:downcase)
|
2019-12-04 13:33:51 -05:00
|
|
|
where("lower(tags.name) IN (?)", name)
|
2023-11-29 00:38:07 -05:00
|
|
|
end
|
2016-05-30 16:37:06 -04:00
|
|
|
|
2020-08-19 12:17:33 -04:00
|
|
|
# tags that have never been used and don't belong to a tag group
|
|
|
|
scope :unused,
|
2023-11-29 00:38:07 -05:00
|
|
|
-> do
|
2023-10-16 19:53:02 -04:00
|
|
|
where(staff_topic_count: 0, pm_topic_count: 0, target_tag_id: nil).joins(
|
2020-08-19 12:17:33 -04:00
|
|
|
"LEFT JOIN tag_group_memberships tgm ON tags.id = tgm.tag_id",
|
|
|
|
).where("tgm.tag_id IS NULL")
|
2023-11-29 00:38:07 -05:00
|
|
|
end
|
2020-08-19 12:17:33 -04:00
|
|
|
|
2023-01-19 20:50:24 -05:00
|
|
|
scope :used_tags_in_regular_topics,
|
|
|
|
->(guardian) { where("tags.#{Tag.topic_count_column(guardian)} > 0") }
|
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
scope :base_tags, -> { where(target_tag_id: nil) }
|
2023-02-22 10:01:32 -05:00
|
|
|
scope :visible, ->(guardian = nil) { merge(DiscourseTagging.visible_tags(guardian)) }
|
2018-11-12 11:24:34 -05:00
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
has_many :tag_users, dependent: :destroy # notification settings
|
2016-05-30 16:37:06 -04:00
|
|
|
|
2016-05-04 14:02:47 -04:00
|
|
|
has_many :topic_tags, dependent: :destroy
|
|
|
|
has_many :topics, through: :topic_tags
|
2016-05-30 16:37:06 -04:00
|
|
|
|
2020-04-27 13:42:13 -04:00
|
|
|
has_many :category_tag_stats, dependent: :destroy
|
2016-05-30 16:37:06 -04:00
|
|
|
has_many :category_tags, dependent: :destroy
|
|
|
|
has_many :categories, through: :category_tags
|
2016-05-04 14:02:47 -04:00
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
has_many :tag_group_memberships, dependent: :destroy
|
2016-06-06 14:18:15 -04:00
|
|
|
has_many :tag_groups, through: :tag_group_memberships
|
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
belongs_to :target_tag, class_name: "Tag", optional: true
|
|
|
|
has_many :synonyms, class_name: "Tag", foreign_key: "target_tag_id", dependent: :destroy
|
2022-06-30 02:54:20 -04:00
|
|
|
has_many :sidebar_section_links, as: :linkable, dependent: :delete_all
|
2019-12-04 13:33:51 -05:00
|
|
|
|
2024-05-16 15:47:01 -04:00
|
|
|
has_many :embeddable_host_tags
|
|
|
|
has_many :embeddable_hosts, through: :embeddable_host_tags
|
|
|
|
|
2023-08-16 11:43:54 -04:00
|
|
|
before_save :sanitize_description
|
|
|
|
|
2017-08-25 11:52:18 -04:00
|
|
|
after_save :index_search
|
2019-12-04 13:33:51 -05:00
|
|
|
after_save :update_synonym_associations
|
2017-08-25 11:52:18 -04:00
|
|
|
|
2018-04-04 08:19:20 -04:00
|
|
|
after_commit :trigger_tag_created_event, on: :create
|
2018-05-21 05:29:19 -04:00
|
|
|
after_commit :trigger_tag_updated_event, on: :update
|
2018-04-04 08:19:20 -04:00
|
|
|
after_commit :trigger_tag_destroyed_event, on: :destroy
|
|
|
|
|
2018-01-11 11:59:51 -05:00
|
|
|
def self.ensure_consistency!
|
2018-05-14 13:02:00 -04:00
|
|
|
update_topic_counts
|
2018-01-11 11:59:51 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.update_topic_counts
|
2018-06-19 02:13:14 -04:00
|
|
|
DB.exec <<~SQL
|
2018-01-11 11:59:51 -05:00
|
|
|
UPDATE tags t
|
2023-01-19 20:50:24 -05:00
|
|
|
SET staff_topic_count = x.topic_count
|
2018-05-14 13:02:00 -04:00
|
|
|
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
|
2023-01-19 20:50:24 -05:00
|
|
|
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;
|
2018-05-14 13:02:00 -04:00
|
|
|
SQL
|
|
|
|
|
2018-06-19 02:13:14 -04:00
|
|
|
DB.exec <<~SQL
|
2018-05-14 13:02:00 -04:00
|
|
|
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
|
2018-01-11 11:59:51 -05:00
|
|
|
SQL
|
2016-05-31 16:27:13 -04:00
|
|
|
end
|
|
|
|
|
2018-10-05 05:23:52 -04:00
|
|
|
def self.find_by_name(name)
|
|
|
|
self.find_by("lower(name) = ?", name.downcase)
|
|
|
|
end
|
|
|
|
|
2023-01-19 20:50:24 -05:00
|
|
|
def self.top_tags(limit_arg: nil, category: nil, guardian: Guardian.new)
|
2021-04-08 09:51:31 -04:00
|
|
|
# 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)
|
2023-01-19 20:50:24 -05:00
|
|
|
scope_category_ids = guardian.allowed_category_ids
|
2016-09-22 15:23:10 -04:00
|
|
|
scope_category_ids &= ([category.id] + category.subcategories.pluck(:id)) if category
|
|
|
|
|
2018-02-08 16:36:39 -05:00
|
|
|
return [] if scope_category_ids.empty?
|
|
|
|
|
2022-11-22 13:55:57 -05:00
|
|
|
filter_sql =
|
2023-01-09 07:20:10 -05:00
|
|
|
(
|
2023-01-19 20:50:24 -05:00
|
|
|
if guardian.is_staff?
|
2023-01-09 07:20:10 -05:00
|
|
|
""
|
|
|
|
else
|
2022-11-22 13:55:57 -05:00
|
|
|
" AND tags.id IN (#{DiscourseTagging.visible_tags(guardian).select(:id).to_sql})"
|
2023-01-09 07:20:10 -05:00
|
|
|
end
|
|
|
|
)
|
2018-03-26 17:04:55 -04:00
|
|
|
|
2018-06-19 02:13:14 -04:00
|
|
|
tag_names_with_counts = DB.query <<~SQL
|
2018-02-08 16:36:39 -05:00
|
|
|
SELECT tags.name as tag_name, SUM(stats.topic_count) AS sum_topic_count
|
|
|
|
FROM category_tag_stats stats
|
2018-05-14 13:02:00 -04:00
|
|
|
JOIN tags ON stats.tag_id = tags.id AND stats.topic_count > 0
|
2018-02-08 16:36:39 -05:00
|
|
|
WHERE stats.category_id in (#{scope_category_ids.join(",")})
|
2018-03-26 17:04:55 -04:00
|
|
|
#{filter_sql}
|
2018-02-08 16:36:39 -05:00
|
|
|
GROUP BY tags.name
|
|
|
|
ORDER BY sum_topic_count DESC, tag_name ASC
|
|
|
|
LIMIT #{limit}
|
|
|
|
SQL
|
2016-07-07 09:17:56 -04:00
|
|
|
|
2018-06-19 02:13:14 -04:00
|
|
|
tag_names_with_counts.map { |row| row.tag_name }
|
2016-07-07 09:17:56 -04:00
|
|
|
end
|
|
|
|
|
2023-01-19 20:50:24 -05:00
|
|
|
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
|
|
|
|
|
2020-11-09 16:09:59 -05:00
|
|
|
def self.pm_tags(limit: 1000, guardian: nil, allowed_user: nil)
|
2018-03-15 08:33:08 -04:00
|
|
|
return [] if allowed_user.blank? || !(guardian || Guardian.new).can_tag_pms?
|
|
|
|
user_id = allowed_user.id
|
2018-02-27 11:51:40 -05:00
|
|
|
|
2018-06-19 02:13:14 -04:00
|
|
|
DB.query_hash(<<~SQL).map!(&:symbolize_keys!)
|
|
|
|
SELECT tags.name as id, tags.name as text, COUNT(topics.id) AS count
|
2018-05-14 13:02:00 -04:00
|
|
|
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
|
2021-08-02 00:41:41 -04:00
|
|
|
WHERE user_id = #{user_id.to_i}
|
2018-05-14 13:02:00 -04:00
|
|
|
UNION
|
|
|
|
SELECT tg.topic_id
|
|
|
|
FROM topic_allowed_groups tg
|
2021-08-02 00:41:41 -04:00
|
|
|
JOIN group_users gu ON gu.user_id = #{user_id.to_i}
|
2018-05-14 13:02:00 -04:00
|
|
|
AND gu.group_id = tg.group_id
|
|
|
|
)
|
|
|
|
GROUP BY tags.name
|
2020-11-09 16:09:59 -05:00
|
|
|
ORDER BY count DESC
|
2021-08-02 00:41:41 -04:00
|
|
|
LIMIT #{limit.to_i}
|
2018-02-27 11:51:40 -05:00
|
|
|
SQL
|
|
|
|
end
|
|
|
|
|
2016-07-07 09:17:56 -04:00
|
|
|
def self.include_tags?
|
2021-03-10 21:13:15 -05:00
|
|
|
SiteSetting.tagging_enabled
|
2016-05-04 14:02:47 -04:00
|
|
|
end
|
2016-08-01 22:54:32 -04:00
|
|
|
|
2021-04-14 03:27:07 -04:00
|
|
|
def url
|
|
|
|
"#{Discourse.base_path}/tag/#{UrlHelper.encode_component(self.name)}"
|
|
|
|
end
|
|
|
|
|
2024-05-27 15:30:19 -04:00
|
|
|
alias_method :relative_url, :url
|
|
|
|
|
2016-08-01 22:54:32 -04:00
|
|
|
def full_url
|
2020-12-02 02:06:41 -05:00
|
|
|
"#{Discourse.base_url}/tag/#{UrlHelper.encode_component(self.name)}"
|
2016-08-01 22:54:32 -04:00
|
|
|
end
|
2017-08-25 11:52:18 -04:00
|
|
|
|
|
|
|
def index_search
|
|
|
|
SearchIndexer.index(self)
|
|
|
|
end
|
2018-04-04 08:19:20 -04:00
|
|
|
|
2019-12-04 13:33:51 -05:00
|
|
|
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 do |tag_group|
|
2024-05-27 06:27:13 -04:00
|
|
|
tag_group.tags << self if tag_group.tags.exclude?(self)
|
2023-01-09 07:20:10 -05:00
|
|
|
end
|
2019-12-04 13:33:51 -05:00
|
|
|
target_tag.categories.each do |category|
|
2024-05-27 06:27:13 -04:00
|
|
|
category.tags << self if category.tags.exclude?(self)
|
2023-01-09 07:20:10 -05:00
|
|
|
end
|
2019-12-04 13:33:51 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-07-11 12:59:57 -04:00
|
|
|
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
|
|
|
|
|
2018-05-21 05:29:19 -04:00
|
|
|
%i[tag_created tag_updated tag_destroyed].each do |event|
|
|
|
|
define_method("trigger_#{event}_event") do
|
|
|
|
DiscourseEvent.trigger(event, self)
|
|
|
|
true
|
|
|
|
end
|
2018-04-04 08:19:20 -04:00
|
|
|
end
|
2020-05-25 22:45:45 -04:00
|
|
|
|
|
|
|
private
|
|
|
|
|
2023-08-16 11:43:54 -04:00
|
|
|
def sanitize_description
|
|
|
|
self.description = sanitize_field(self.description) if description_changed?
|
|
|
|
end
|
2023-12-15 10:46:04 -05:00
|
|
|
|
2020-05-25 22:45:45 -04:00
|
|
|
def name_validator
|
|
|
|
errors.add(:name, :invalid) if name.present? && RESERVED_TAGS.include?(self.name.strip.downcase)
|
|
|
|
end
|
2016-05-04 14:02:47 -04:00
|
|
|
end
|
2016-05-29 20:45:32 -04:00
|
|
|
|
|
|
|
# == Schema Information
|
|
|
|
#
|
|
|
|
# Table name: tags
|
|
|
|
#
|
2023-01-19 20:50:24 -05:00
|
|
|
# id :integer not null, primary key
|
|
|
|
# name :string not null
|
|
|
|
# created_at :datetime not null
|
|
|
|
# updated_at :datetime not null
|
|
|
|
# pm_topic_count :integer default(0), not null
|
|
|
|
# target_tag_id :integer
|
2023-11-27 16:45:40 -05:00
|
|
|
# description :string(1000)
|
2023-01-19 20:50:24 -05:00
|
|
|
# public_topic_count :integer default(0), not null
|
|
|
|
# staff_topic_count :integer default(0), not null
|
2016-05-29 20:45:32 -04:00
|
|
|
#
|
|
|
|
# Indexes
|
|
|
|
#
|
2018-10-08 10:40:05 -04:00
|
|
|
# index_tags_on_lower_name (lower((name)::text)) UNIQUE
|
|
|
|
# index_tags_on_name (name) UNIQUE
|
2016-05-29 20:45:32 -04:00
|
|
|
#
|