Skip to content

Commit 49a6592

Browse files
committed
FEATURE: Translate categories
1 parent e42c0da commit 49a6592

File tree

7 files changed

+298
-0
lines changed

7 files changed

+298
-0
lines changed
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class TranslateCategories < ::Jobs::Base
5+
cluster_concurrency 1
6+
BATCH_SIZE = 50
7+
8+
def execute(args)
9+
return unless SiteSetting.translator_enabled
10+
return unless SiteSetting.experimental_category_translation
11+
12+
locales = SiteSetting.automatic_translation_target_languages.split("|")
13+
return if locales.blank?
14+
15+
cat_id = args[:from_category_id] || Category.order(:id).first&.id
16+
last_id = cat_id
17+
18+
# we're just gonna take all categories and keep it simple
19+
# instead of checking in the db which ones are absent
20+
categories = Category.where("id >= ?", cat_id).order(:id).limit(BATCH_SIZE)
21+
return if categories.empty?
22+
23+
categories.each do |category|
24+
CategoryLocalization.transaction do
25+
locales.each do |locale|
26+
next if CategoryLocalization.exists?(category_id: category.id, locale: locale)
27+
begin
28+
DiscourseTranslator::CategoryTranslator.translate(category, locale)
29+
rescue => e
30+
Rails.logger.error(
31+
"Discourse Translator: Failed to translate category #{category.id} to #{locale}: #{e.message}",
32+
)
33+
end
34+
end
35+
end
36+
last_id = category.id
37+
end
38+
39+
# from batch if needed
40+
if categories.size == BATCH_SIZE
41+
Jobs.enqueue_in(10.seconds, :translate_categories, from_category_id: last_id + 1)
42+
end
43+
end
44+
end
45+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class AutomaticCategoryTranslation < ::Jobs::Scheduled
5+
every 12.hours
6+
cluster_concurrency 1
7+
8+
def execute(args)
9+
return unless SiteSetting.translator_enabled
10+
return unless SiteSetting.experimental_category_translation
11+
12+
locales = SiteSetting.automatic_translation_target_languages.split("|")
13+
return if locales.blank?
14+
15+
Jobs.enqueue(:translate_categories)
16+
end
17+
end
18+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class CategoryTranslator
5+
# unlike post and topics, categories do not have a detected locale
6+
# and will translate two fields, name and description
7+
8+
def self.translate(category, target_locale = I18n.locale)
9+
return if category.blank? || target_locale.blank?
10+
11+
# locale can come in various forms
12+
# standardize it to a _ symbol
13+
target_locale_sym = target_locale.to_s.sub("-", "_").to_sym
14+
15+
translator = DiscourseTranslator::Provider::TranslatorProvider.get
16+
translated_name = translator.translate_text!(category.name, target_locale_sym)
17+
translated_description = translator.translate_text!(category.description, target_locale_sym)
18+
19+
category.update!(name: translated_name, description: translated_description)
20+
end
21+
end
22+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
# The canonical class for all your translation needs
5+
class Translator
6+
# this invokes the specific methods
7+
def translate(translatable, target_locale = I18n.locale)
8+
target_locale_sym = target_locale.to_s.sub("-", "_").to_sym
9+
10+
case translatable.class.name
11+
when "Post", "Topic"
12+
DiscourseTranslator::Provider.TranslatorProvider.get.translate(translatable, target_locale_sym)
13+
when "Category"
14+
CategoryTranslator.translate(translatable, target_locale)
15+
end
16+
end
17+
end
18+
end

config/settings.yml

+3
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ discourse_translator:
129129
experimental_inline_translation:
130130
default: false
131131
client: true
132+
experimental_category_translation:
133+
default: false
134+
hidden: true
132135
discourse_translator_verbose_logs:
133136
default: false
134137
client: false
+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe Jobs::TranslateCategories do
6+
let(:translator) { mock }
7+
8+
def localize_all_categories(*locales)
9+
Category.all.each do |category|
10+
locales.each { |locale| Fabricate(:category_localization, category:, locale:, name: "x") }
11+
end
12+
end
13+
14+
before do
15+
SiteSetting.translator_enabled = true
16+
SiteSetting.experimental_category_translation = true
17+
SiteSetting.automatic_translation_backfill_rate = 100
18+
SiteSetting.automatic_translation_target_languages = "pt|zh_CN"
19+
20+
DiscourseTranslator::Provider.stubs(:get).returns(translator)
21+
Jobs.run_immediately!
22+
end
23+
24+
it "does nothing when translator is disabled" do
25+
SiteSetting.translator_enabled = false
26+
27+
translator.expects(:translate_text!).never
28+
29+
subject.execute({})
30+
end
31+
32+
it "does nothing when experimental_category_translation is disabled" do
33+
SiteSetting.experimental_category_translation = false
34+
35+
translator.expects(:translate_text!).never
36+
37+
subject.execute({})
38+
end
39+
40+
it "does nothing when no target languages are configured" do
41+
SiteSetting.automatic_translation_target_languages = ""
42+
43+
translator.expects(:translate_text!).never
44+
45+
subject.execute({})
46+
end
47+
48+
it "does nothing when no categories exist" do
49+
Category.destroy_all
50+
51+
translator.expects(:translate_text!).never
52+
53+
subject.execute({})
54+
end
55+
56+
it "translates categories to the configured locales" do
57+
number_of_categories = Category.count
58+
DiscourseTranslator::CategoryTranslator
59+
.expects(:translate)
60+
.with(is_a(Category), "pt")
61+
.times(number_of_categories)
62+
DiscourseTranslator::CategoryTranslator
63+
.expects(:translate)
64+
.with(is_a(Category), "zh_CN")
65+
.times(number_of_categories)
66+
67+
subject.execute({})
68+
end
69+
70+
it "skips categories that already have localizations" do
71+
localize_all_categories("pt", "zh_CN")
72+
73+
category1 =
74+
Fabricate(:category, name: "First Category", description: "First category description")
75+
Fabricate(:category_localization, category: category1, locale: "pt", name: "Primeira Categoria")
76+
77+
# It should only translate to Chinese, not Portuguese
78+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "pt").never
79+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "zh_CN").once
80+
81+
subject.execute({})
82+
end
83+
84+
it "continues from a specified category ID" do
85+
category1 = Fabricate(:category, name: "First", description: "First description")
86+
category2 = Fabricate(:category, name: "Second", description: "Second description")
87+
88+
DiscourseTranslator::CategoryTranslator
89+
.expects(:translate)
90+
.with(category1, any_parameters)
91+
.never
92+
DiscourseTranslator::CategoryTranslator
93+
.expects(:translate)
94+
.with(category2, any_parameters)
95+
.twice
96+
97+
subject.execute(from_category_id: category2.id)
98+
end
99+
100+
it "handles translation errors gracefully" do
101+
localize_all_categories("pt", "zh_CN")
102+
103+
category1 = Fabricate(:category, name: "First", description: "First description")
104+
DiscourseTranslator::CategoryTranslator
105+
.expects(:translate)
106+
.with(category1, "pt")
107+
.raises(StandardError.new("API error"))
108+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "zh_CN").once
109+
110+
expect { subject.execute({}) }.not_to raise_error
111+
end
112+
113+
it "enqueues the next batch when there are more categories" do
114+
Jobs::TranslateCategories.const_set(:BATCH_SIZE, 1)
115+
116+
Jobs
117+
.expects(:enqueue_in)
118+
.with(10.seconds, :translate_categories, from_category_id: any_parameters)
119+
.times(Category.count)
120+
121+
subject.execute({})
122+
123+
# Reset the constant
124+
Jobs::TranslateCategories.send(:remove_const, :BATCH_SIZE)
125+
Jobs::TranslateCategories.const_set(:BATCH_SIZE, 50)
126+
end
127+
end
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
describe DiscourseTranslator::CategoryTranslator do
4+
fab!(:category) do
5+
Fabricate(:category, name: "Test Category", description: "This is a test category")
6+
end
7+
8+
describe ".translate" do
9+
let(:target_locale) { :fr }
10+
let(:translator) { mock }
11+
12+
before { DiscourseTranslator::Provider::TranslatorProvider.stubs(:get).returns(translator) }
13+
14+
it "translates the category name and description" do
15+
translator
16+
.expects(:translate_text!)
17+
.with(category.name, target_locale)
18+
.returns("Catégorie de Test")
19+
translator
20+
.expects(:translate_text!)
21+
.with(category.description, target_locale)
22+
.returns("C'est une catégorie de test")
23+
24+
DiscourseTranslator::CategoryTranslator.translate(category, target_locale)
25+
26+
expect(category.name).to eq("Catégorie de Test")
27+
expect(category.description).to eq("C'est une catégorie de test")
28+
end
29+
30+
it "handles locale format standardization" do
31+
translator.expects(:translate_text!).with(category.name, :fr_CA).returns("Catégorie de Test")
32+
translator
33+
.expects(:translate_text!)
34+
.with(category.description, :fr_CA)
35+
.returns("C'est une catégorie de test")
36+
37+
DiscourseTranslator::CategoryTranslator.translate(category, "fr-CA")
38+
39+
expect(category.name).to eq("Catégorie de Test")
40+
expect(category.description).to eq("C'est une catégorie de test")
41+
end
42+
43+
it "returns nil if category is blank" do
44+
expect(DiscourseTranslator::CategoryTranslator.translate(nil)).to be_nil
45+
end
46+
47+
it "returns nil if target locale is blank" do
48+
expect(DiscourseTranslator::CategoryTranslator.translate(category, nil)).to be_nil
49+
end
50+
51+
it "uses I18n.locale as default when no target locale is provided" do
52+
I18n.locale = :es
53+
translator.expects(:translate_text!).with(category.name, :es).returns("Categoría de Prueba")
54+
translator
55+
.expects(:translate_text!)
56+
.with(category.description, :es)
57+
.returns("Esta es una categoría de prueba")
58+
59+
DiscourseTranslator::CategoryTranslator.translate(category)
60+
61+
expect(category.name).to eq("Categoría de Prueba")
62+
expect(category.description).to eq("Esta es una categoría de prueba")
63+
end
64+
end
65+
end

0 commit comments

Comments
 (0)