mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 10:40:48 +08:00
Related: https://meta.discourse.org/t/multi-language-preferences-for-displaying-localized-content/381116 Allow anon and logged in users to set more than one language that they understand, when content localization is enabled. ### User preferences In user preferences, we now include a Content Languages section when the site has Content Localization enabled. ### Topic menu In topics where there are any languages that's not the user's language, the option will appear to set their preferred content languages. This appears for anon and logged in users, but anon users will be asked to logged in to set the languages. __________ These preferences apply only to topic titles and posts. They do not affect navigational features like categories, tags, or sidebar content. The per-post option to show the original remains independent of the user's preferences. This PR temporarily retains `show_original_content` as a compatibility field computed from `automatically_translate`, keeping cached clients and rolling deployments safe. A follow-up PR will remove the legacy API and frontend compatibility code, then retire the old database column through the staged column-removal process.
368 lines
12 KiB
Ruby
Vendored
368 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)
|
|
tag_data.filter_map do |row|
|
|
tag = tags_by_id[row.tag_id]
|
|
next unless tag
|
|
|
|
name = tag.get_localization&.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)
|
|
#
|