From e6fecc1c2110310f2118990c47d77c90846939fb Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 17 Apr 2025 18:19:56 +0200 Subject: [PATCH 1/6] Add model for rule translations --- app/models/rule.rb | 2 ++ app/models/rule_translation.rb | 19 ++++++++++++ ...20250417154643_create_rule_translations.rb | 16 ++++++++++ db/schema.rb | 13 +++++++- .../rule_translation_fabricator.rb | 8 +++++ spec/models/rule_translation_spec.rb | 30 +++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 app/models/rule_translation.rb create mode 100644 db/migrate/20250417154643_create_rule_translations.rb create mode 100644 spec/fabricators/rule_translation_fabricator.rb create mode 100644 spec/models/rule_translation_spec.rb diff --git a/app/models/rule.rb b/app/models/rule.rb index 99a36397aa..f4fbbee0d0 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -19,6 +19,8 @@ class Rule < ApplicationRecord self.discard_column = :deleted_at + has_many :translations, inverse_of: :rule, class_name: 'RuleTranslation', dependent: :destroy + validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT } scope :ordered, -> { kept.order(priority: :asc, id: :asc) } diff --git a/app/models/rule_translation.rb b/app/models/rule_translation.rb new file mode 100644 index 0000000000..89d915cfb9 --- /dev/null +++ b/app/models/rule_translation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: rule_translations +# +# id :bigint(8) not null, primary key +# hint :text default(""), not null +# language :string not null +# text :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# rule_id :bigint(8) not null +# +class RuleTranslation < ApplicationRecord + belongs_to :rule + + validates :language, uniqueness: { scope: :rule_id } +end diff --git a/db/migrate/20250417154643_create_rule_translations.rb b/db/migrate/20250417154643_create_rule_translations.rb new file mode 100644 index 0000000000..cf696e769d --- /dev/null +++ b/db/migrate/20250417154643_create_rule_translations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateRuleTranslations < ActiveRecord::Migration[8.0] + def change + create_table :rule_translations do |t| + t.text :text, null: false, default: '' + t.text :hint, null: false, default: '' + t.string :language, null: false + t.references :rule, null: false, foreign_key: { on_delete: :cascade }, index: false + + t.timestamps + end + + add_index :rule_translations, [:rule_id, :language], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6b1aa81bd0..0f99a162f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do +ActiveRecord::Schema[8.0].define(version: 2025_04_17_154643) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -937,6 +937,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do t.index ["target_account_id"], name: "index_reports_on_target_account_id" end + create_table "rule_translations", force: :cascade do |t| + t.text "text", default: "", null: false + t.text "hint", default: "", null: false + t.string "language", null: false + t.bigint "rule_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rule_id", "language"], name: "index_rule_translations_on_rule_id_and_language", unique: true + end + create_table "rules", force: :cascade do |t| t.integer "priority", default: 0, null: false t.datetime "deleted_at", precision: nil @@ -1380,6 +1390,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade add_foreign_key "reports", "oauth_applications", column: "application_id", on_delete: :nullify + add_foreign_key "rule_translations", "rules", on_delete: :cascade add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade diff --git a/spec/fabricators/rule_translation_fabricator.rb b/spec/fabricators/rule_translation_fabricator.rb new file mode 100644 index 0000000000..de29e47e7e --- /dev/null +++ b/spec/fabricators/rule_translation_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:rule_translation) do + text 'MyText' + hint 'MyText' + language 'en' + rule { Fabricate.build(:rule) } +end diff --git a/spec/models/rule_translation_spec.rb b/spec/models/rule_translation_spec.rb new file mode 100644 index 0000000000..056ecd906a --- /dev/null +++ b/spec/models/rule_translation_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RuleTranslation do + describe '#possibly_stale?' do + let!(:rule) { Fabricate(:rule) } + let!(:translation) { Fabricate(:rule_translation, rule: rule) } + + context 'with a translation edited after the rule' do + before do + translation.touch + end + + it 'returns false' do + expect(translation.possibly_stale?).to be false + end + end + + context 'with a rule edited after the translation' do + before do + rule.touch + end + + it 'returns true' do + expect(translation.possibly_stale?).to be true + end + end + end +end From 7281b5b331c7b67c3403da1a2b98f5139043d342 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Apr 2025 15:27:10 +0200 Subject: [PATCH 2/6] fixup! Add model for rule translations --- app/models/rule_translation.rb | 3 ++- spec/models/rule_translation_spec.rb | 30 ---------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 spec/models/rule_translation_spec.rb diff --git a/app/models/rule_translation.rb b/app/models/rule_translation.rb index 89d915cfb9..99991b2ee1 100644 --- a/app/models/rule_translation.rb +++ b/app/models/rule_translation.rb @@ -15,5 +15,6 @@ class RuleTranslation < ApplicationRecord belongs_to :rule - validates :language, uniqueness: { scope: :rule_id } + validates :language, presence: true, uniqueness: { scope: :rule_id } + validates :text, presence: true, length: { maximum: Rule::TEXT_SIZE_LIMIT } end diff --git a/spec/models/rule_translation_spec.rb b/spec/models/rule_translation_spec.rb deleted file mode 100644 index 056ecd906a..0000000000 --- a/spec/models/rule_translation_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe RuleTranslation do - describe '#possibly_stale?' do - let!(:rule) { Fabricate(:rule) } - let!(:translation) { Fabricate(:rule_translation, rule: rule) } - - context 'with a translation edited after the rule' do - before do - translation.touch - end - - it 'returns false' do - expect(translation.possibly_stale?).to be false - end - end - - context 'with a rule edited after the translation' do - before do - rule.touch - end - - it 'returns true' do - expect(translation.possibly_stale?).to be true - end - end - end -end From 6423595d5689ae72ae04a8399f31b95b5ca8fbff Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Apr 2025 09:56:41 +0200 Subject: [PATCH 3/6] Serialize rule translations --- app/controllers/admin/rules_controller.rb | 4 ++-- app/controllers/api/v1/instances/rules_controller.rb | 2 +- app/controllers/auth/registrations_controller.rb | 2 +- app/presenters/instance_presenter.rb | 2 +- app/serializers/rest/rule_serializer.rb | 8 +++++++- spec/serializers/rest/rule_serializer_spec.rb | 3 ++- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index 289b6a98c3..cfa900c019 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -7,7 +7,7 @@ module Admin def index authorize :rule, :index? - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) @rule = Rule.new end @@ -23,7 +23,7 @@ module Admin if @rule.save redirect_to admin_rules_path else - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) render :index end end diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb index 3930eec0dd..2b6e534875 100644 --- a/app/controllers/api/v1/instances/rules_controller.rb +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController private def set_rules - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 0b6f5b3af4..973724cf7c 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_rules - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) end def require_rules_acceptance! diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 92415a6903..6923f565ef 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model end def rules - Rule.ordered + Rule.ordered.includes(:translations) end def user_count diff --git a/app/serializers/rest/rule_serializer.rb b/app/serializers/rest/rule_serializer.rb index 9e2bcda15e..3ce2d02e66 100644 --- a/app/serializers/rest/rule_serializer.rb +++ b/app/serializers/rest/rule_serializer.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true class REST::RuleSerializer < ActiveModel::Serializer - attributes :id, :text, :hint + attributes :id, :text, :hint, :translations def id object.id.to_s end + + def translations + object.translations.to_h do |translation| + [translation.language, { text: translation.text, hint: translation.hint }] + end + end end diff --git a/spec/serializers/rest/rule_serializer_spec.rb b/spec/serializers/rest/rule_serializer_spec.rb index 4d801e77d3..9d2889c9fc 100644 --- a/spec/serializers/rest/rule_serializer_spec.rb +++ b/spec/serializers/rest/rule_serializer_spec.rb @@ -11,7 +11,8 @@ RSpec.describe REST::RuleSerializer do it 'returns expected values' do expect(subject) .to include( - 'id' => be_a(String).and(eq('123')) + 'id' => be_a(String).and(eq('123')), + 'translations' => be_a(Hash) ) end end From bdc3a733aaa4218774c461b92e445791ec4af852 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Apr 2025 10:32:04 +0200 Subject: [PATCH 4/6] Display localized rules if available --- .../mastodon/features/about/index.jsx | 19 ++++++++++++------- .../mastodon/features/report/rules.jsx | 6 ++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 34e84506f0..f2ea16a952 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -44,6 +44,7 @@ const severityMessages = { const mapStateToProps = state => ({ server: state.getIn(['server', 'server']), + locale: state.getIn(['meta', 'locale']), extendedDescription: state.getIn(['server', 'extendedDescription']), domainBlocks: state.getIn(['server', 'domainBlocks']), }); @@ -91,6 +92,7 @@ class About extends PureComponent { static propTypes = { server: ImmutablePropTypes.map, + locale: ImmutablePropTypes.string, extendedDescription: ImmutablePropTypes.map, domainBlocks: ImmutablePropTypes.contains({ isLoading: PropTypes.bool, @@ -114,7 +116,7 @@ class About extends PureComponent { }; render () { - const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; + const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props; const isLoading = server.get('isLoading'); return ( @@ -168,12 +170,15 @@ class About extends PureComponent {

) : (
    - {server.get('rules').map(rule => ( -
  1. -
    {rule.get('text')}
    - {rule.get('hint').length > 0 && (
    {rule.get('hint')}
    )} -
  2. - ))} + {server.get('rules').map(rule => { + const text = rule.getIn(['translations', locale, 'text']) || rule.get('text'); + const hint = rule.getIn(['translations', locale, 'hint']) || rule.get('hint'); + return ( +
  3. +
    {text}
    + {hint.length > 0 && (
    {hint}
    )} +
  4. + )})}
))} diff --git a/app/javascript/mastodon/features/report/rules.jsx b/app/javascript/mastodon/features/report/rules.jsx index 621f140adb..dff3769379 100644 --- a/app/javascript/mastodon/features/report/rules.jsx +++ b/app/javascript/mastodon/features/report/rules.jsx @@ -12,6 +12,7 @@ import Option from './components/option'; const mapStateToProps = state => ({ rules: state.getIn(['server', 'server', 'rules']), + locale: state.getIn(['meta', 'locale']), }); class Rules extends PureComponent { @@ -19,6 +20,7 @@ class Rules extends PureComponent { static propTypes = { onNextStep: PropTypes.func.isRequired, rules: ImmutablePropTypes.list, + locale: PropTypes.string, selectedRuleIds: ImmutablePropTypes.set.isRequired, onToggle: PropTypes.func.isRequired, }; @@ -34,7 +36,7 @@ class Rules extends PureComponent { }; render () { - const { rules, selectedRuleIds } = this.props; + const { rules, locale, selectedRuleIds } = this.props; return ( <> @@ -49,7 +51,7 @@ class Rules extends PureComponent { value={item.get('id')} checked={selectedRuleIds.includes(item.get('id'))} onToggle={this.handleRulesToggle} - label={item.get('text')} + label={item.getIn(['translations', locale, 'text']) || item.get('text')} multiple /> ))} From 7d58c23c0e54f4e2554987f9a6c0886d69289ddd Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Apr 2025 11:23:43 +0200 Subject: [PATCH 5/6] Add rule translation on registration screen --- app/views/auth/registrations/rules.html.haml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml index 4b0159e862..0e44bfb6d9 100644 --- a/app/views/auth/registrations/rules.html.haml +++ b/app/views/auth/registrations/rules.html.haml @@ -18,10 +18,11 @@ %ol.rules-list - @rules.each do |rule| + - translation = rule.translations.find { |translation| translation.language == I18n.locale.to_s } %li %button{ type: 'button', aria: { expanded: 'false' } } - .rules-list__text= rule.text - .rules-list__hint= rule.hint + .rules-list__text= translation&.text || rule.text + .rules-list__hint= translation&.hint || rule.hint .stacked-actions - accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token) From 7f0b5478e62f1299024d2833115198c12745dfff Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Apr 2025 15:37:23 +0200 Subject: [PATCH 6/6] [WiP] Add admin interface to edit rule translations --- app/controllers/admin/rules_controller.rb | 2 +- app/models/rule.rb | 1 + .../admin/rules/_translation_fields.html.haml | 27 +++++++++++++++++++ app/views/admin/rules/edit.html.haml | 21 +++++++++++++++ config/locales/en.yml | 4 +++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/views/admin/rules/_translation_fields.html.haml diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index cfa900c019..9e1ac47f79 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -54,7 +54,7 @@ module Admin def resource_params params - .expect(rule: [:text, :hint, :priority]) + .expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]]) end end end diff --git a/app/models/rule.rb b/app/models/rule.rb index f4fbbee0d0..a59106bc64 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -20,6 +20,7 @@ class Rule < ApplicationRecord self.discard_column = :deleted_at has_many :translations, inverse_of: :rule, class_name: 'RuleTranslation', dependent: :destroy + accepts_nested_attributes_for :translations, reject_if: :all_blank, allow_destroy: true validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT } diff --git a/app/views/admin/rules/_translation_fields.html.haml b/app/views/admin/rules/_translation_fields.html.haml new file mode 100644 index 0000000000..bf8d817224 --- /dev/null +++ b/app/views/admin/rules/_translation_fields.html.haml @@ -0,0 +1,27 @@ +%tr.nested-fields + %td + .fields-row + .fields-row__column.fields-group + = f.input :language, + collection: ui_languages, + include_blank: false, + label_method: ->(locale) { native_locale_name(locale) } + + .fields-row__column.fields-group + = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the + = link_to_remove_association(f, class: 'table-action-link') do + = safe_join([material_symbol('close'), t('filters.index.delete')]) + + .fields-group + = f.input :text, + label: I18n.t('simple_form.labels.rule.text'), + hint: I18n.t('simple_form.hints.rule.text'), + input_html: { lang: f.object&.language }, + wrapper: :with_block_label + + .fields-group + = f.input :hint, + label: I18n.t('simple_form.labels.rule.hint'), + hint: I18n.t('simple_form.hints.rule.hint'), + input_html: { lang: f.object&.language }, + wrapper: :with_block_label diff --git a/app/views/admin/rules/edit.html.haml b/app/views/admin/rules/edit.html.haml index 9e3c915812..b64a27d751 100644 --- a/app/views/admin/rules/edit.html.haml +++ b/app/views/admin/rules/edit.html.haml @@ -6,5 +6,26 @@ = render form + %hr.spacer/ + + %h4= t('admin.rules.translations') + + %p.hint= t('admin.rules.translations_explanation') + + .table-wrapper + %table.table.keywords-table + %thead + %tr + %th= t('admin.rules.translation') + %th + %tbody + = form.simple_fields_for :translations do |translation| + = render 'translation_fields', f: translation + %tfoot + %tr + %td{ colspan: 3 } + = link_to_add_association form, :translations, class: 'table-action-link', partial: 'translation_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do + = safe_join([material_symbol('add'), t('admin.rules.add_translation')]) + .actions = form.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index f0e1f86c4e..af416fd9ec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -786,11 +786,15 @@ en: title: Roles rules: add_new: Add rule + add_translation: Add translation delete: Delete description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. Try to keep individual rules short and simple, but try not to split them up into many separate items either. edit: Edit rule empty: No server rules have been defined yet. title: Server rules + translation: Translation + translations: Translations + translations_explanation: You can optionally add translations for the rules. The default value will be shown if no translated version is available. Please always ensure any provided translation is in sync with the default value. settings: about: manage_rules: Manage server rules