discourse/app/models/translation_override.rb
Krzysztof Kotlarek 094052c1ff
FIX: serialize Flags instead of PostActionType (#28259)
### Why?
Before, all flags were static. Therefore, they were stored in class variables and serialized by SiteSerializer. Recently, we added an option for admins to add their own flags or disable existing flags. Therefore, the class variable had to be dropped because it was unsafe for a multisite environment. However, it started causing performance problems. 

### Solution
When a new Flag system is used, instead of using PostActionType, we can serialize Flags and use fragment cache for performance reasons. 

At the same time, we are still supporting deprecated `replace_flags` API call. When it is used, we fall back to the old solution and the admin cannot add custom flags. In a couple of months, we will be able to drop that API function and clean that code properly. However, because it may still be used, redis cache was introduced to improve performance.

To test backward compatibility you can add this code to any plugin
```ruby
  replace_flags do |flag_settings|
    flag_settings.add(
      4,
      :inappropriate,
      topic_type: true,
      notify_type: true,
      auto_action_type: true,
    )
    flag_settings.add(1001, :trolling, topic_type: true, notify_type: true, auto_action_type: true)
  end
```
2024-08-13 11:22:37 +10:00

227 lines
6.7 KiB
Ruby

# frozen_string_literal: true
class TranslationOverride < ActiveRecord::Base
# Allowlist i18n interpolation keys that can be included when customizing translations
ALLOWED_CUSTOM_INTERPOLATION_KEYS = {
%w[
user_notifications.user_
user_notifications.only_reply_by_email
user_notifications.reply_by_email
user_notifications.visit_link_to_respond
user_notifications.header_instructions
user_notifications.pm_participants
unsubscribe_mailing_list
unsubscribe_link_and_mail
unsubscribe_link
] => %w[
topic_title
topic_title_url_encoded
message
url
post_id
topic_id
context
username
group_name
unsubscribe_url
subject_pm
participants
site_description
site_title
site_title_url_encoded
site_name
optional_re
optional_pm
optional_cat
optional_tags
],
%w[system_messages.welcome_user] => %w[username name name_or_username],
}
include HasSanitizableFields
validates_uniqueness_of :translation_key, scope: :locale
validates_presence_of :locale, :translation_key, :value
validate :check_interpolation_keys
validate :check_MF_string, if: :message_format?
attribute :status, :integer
enum status: { up_to_date: 0, outdated: 1, invalid_interpolation_keys: 2, deprecated: 3 }
scope :mf_locales,
->(locale) { not_deprecated.where(locale: locale).where("translation_key LIKE '%_MF'") }
scope :client_locales,
->(locale) do
not_deprecated
.where(locale: locale)
.where("translation_key LIKE 'js.%' OR translation_key LIKE 'admin_js.%'")
.where.not("translation_key LIKE '%_MF'")
end
def self.upsert!(locale, key, value)
params = { locale: locale, translation_key: key }
translation_override = find_or_initialize_by(params)
sanitized_value =
translation_override.sanitize_field(value, additional_attributes: ["data-auto-route"])
original_translation =
I18n.overrides_disabled { I18n.t(transform_pluralized_key(key), locale: :en) }
data = { value: sanitized_value, original_translation: original_translation }
params.merge!(data) if translation_override.new_record?
i18n_changed(locale, [key]) if translation_override.update(data)
translation_override
end
def self.revert!(locale, keys)
keys = Array.wrap(keys)
TranslationOverride.where(locale: locale, translation_key: keys).delete_all
i18n_changed(locale, keys)
end
def self.reload_all_overrides!
reload_locale!
overrides = TranslationOverride.pluck(:locale, :translation_key)
overrides = overrides.group_by(&:first).map { |k, a| [k, a.map(&:last)] }
overrides.each { |locale, keys| clear_cached_keys!(locale, keys) }
end
def self.reload_locale!
I18n.reload!
ExtraLocalesController.clear_cache!
MessageBus.publish("/i18n-flush", refresh: true)
end
def self.clear_cached_keys!(locale, keys)
should_clear_anon_cache = false
keys.each { |key| should_clear_anon_cache |= expire_cache(locale, key) }
Site.clear_anon_cache! if should_clear_anon_cache
end
def self.i18n_changed(locale, keys)
reload_locale!
clear_cached_keys!(locale, keys)
end
def self.expire_cache(locale, key)
if key.starts_with?("post_action_types.") || key.starts_with?("topic_flag_types.")
PostActionType.new.expire_cache
else
return false
end
true
end
# We use English as the source of truth when extracting interpolation keys,
# but some languages, like Arabic, have plural forms (zero, two, few, many)
# which don't exist in English (one, other), so we map that here in order to
# find the correct, English translation key in which to look.
def self.transform_pluralized_key(key)
match = key.match(/(.*)\.(zero|two|few|many)\z/)
match ? match.to_a.second + ".other" : key
end
def self.custom_interpolation_keys(translation_key)
ALLOWED_CUSTOM_INTERPOLATION_KEYS.find do |keys, value|
break value if keys.any? { |k| translation_key.start_with?(k) }
end || []
end
private_class_method :reload_locale!
private_class_method :clear_cached_keys!
private_class_method :i18n_changed
private_class_method :expire_cache
def original_translation_deleted?
!I18n.overrides_disabled { I18n.t!(transformed_key, locale: :en) }.is_a?(String)
rescue I18n::MissingTranslationData
true
end
def original_translation_updated?
return false if original_translation.blank?
original_translation != current_default
end
def invalid_interpolation_keys
return [] if current_default.blank?
original_interpolation_keys = I18nInterpolationKeysFinder.find(current_default)
new_interpolation_keys = I18nInterpolationKeysFinder.find(value)
custom_interpolation_keys = []
ALLOWED_CUSTOM_INTERPOLATION_KEYS.select do |keys, value|
custom_interpolation_keys = value if keys.any? { |key| transformed_key.start_with?(key) }
end
(original_interpolation_keys | new_interpolation_keys) - original_interpolation_keys -
custom_interpolation_keys
end
def current_default
I18n.overrides_disabled { I18n.t(transformed_key, locale: :en) }
end
def message_format?
translation_key.to_s.end_with?("_MF")
end
def make_up_to_date!
return unless outdated?
self.original_translation = current_default
update_attribute!(:status, :up_to_date)
end
private
def transformed_key
@transformed_key ||= self.class.transform_pluralized_key(translation_key)
end
def check_interpolation_keys
invalid_keys = invalid_interpolation_keys
return if invalid_keys.blank?
self.errors.add(
:base,
I18n.t(
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
keys: invalid_keys.join(I18n.t("word_connector.comma")),
count: invalid_keys.size,
),
)
end
def check_MF_string
require "messageformat"
MessageFormat.compile(locale, { key: value }, strict: true)
rescue MessageFormat::Compiler::CompileError => e
errors.add(:base, e.cause.message)
end
end
# == Schema Information
#
# Table name: translation_overrides
#
# id :integer not null, primary key
# locale :string not null
# translation_key :string not null
# value :string not null
# created_at :datetime not null
# updated_at :datetime not null
# compiled_js :text
# original_translation :text
# status :integer default("up_to_date"), not null
#
# Indexes
#
# index_translation_overrides_on_locale_and_translation_key (locale,translation_key) UNIQUE
#