Skip to content

Commit 926197c

Browse files
authored
FEATURE: Translate categories with selected provider (#282)
This feature introduces the ability to translate categories on core's CategoryLocalization from discourse/discourse#32380. This includes - the addition of a generic `Translator.translate` which takes in supported models - a `CategoryTranslator` specific to categories, which uses the basic text translator introduced in #281 - a 12-hourly scheduled job that invokes a regular job. Anyone can manually translate all categories using ``` Jobs.enqueue(:translate_categories) ```
1 parent e42c0da commit 926197c

File tree

7 files changed

+310
-0
lines changed

7 files changed

+310
-0
lines changed
Lines changed: 45 additions & 0 deletions
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 = nil
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
Lines changed: 18 additions & 0 deletions
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
Lines changed: 22 additions & 0 deletions
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
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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(
13+
translatable,
14+
target_locale_sym,
15+
)
16+
when "Category"
17+
CategoryTranslator.translate(translatable, target_locale)
18+
end
19+
end
20+
end
21+
end

config/settings.yml

Lines changed: 3 additions & 0 deletions
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
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe Jobs::TranslateCategories do
6+
subject(:job) { described_class.new }
7+
8+
let(:translator) { mock }
9+
10+
def localize_all_categories(*locales)
11+
Category.all.each do |category|
12+
locales.each { |locale| Fabricate(:category_localization, category:, locale:, name: "x") }
13+
end
14+
end
15+
16+
before do
17+
SiteSetting.translator_enabled = true
18+
SiteSetting.experimental_category_translation = true
19+
SiteSetting.automatic_translation_backfill_rate = 100
20+
SiteSetting.automatic_translation_target_languages = "pt|zh_CN"
21+
22+
DiscourseTranslator::Provider.stubs(:get).returns(translator)
23+
Jobs.run_immediately!
24+
end
25+
26+
it "does nothing when translator is disabled" do
27+
SiteSetting.translator_enabled = false
28+
29+
translator.expects(:translate_text!).never
30+
31+
job.execute({})
32+
end
33+
34+
it "does nothing when experimental_category_translation is disabled" do
35+
SiteSetting.experimental_category_translation = false
36+
37+
translator.expects(:translate_text!).never
38+
39+
job.execute({})
40+
end
41+
42+
it "does nothing when no target languages are configured" do
43+
SiteSetting.automatic_translation_target_languages = ""
44+
45+
translator.expects(:translate_text!).never
46+
47+
job.execute({})
48+
end
49+
50+
it "does nothing when no categories exist" do
51+
Category.destroy_all
52+
53+
translator.expects(:translate_text!).never
54+
55+
job.execute({})
56+
end
57+
58+
it "translates categories to the configured locales" do
59+
number_of_categories = Category.count
60+
DiscourseTranslator::CategoryTranslator
61+
.expects(:translate)
62+
.with(is_a(Category), "pt")
63+
.times(number_of_categories)
64+
DiscourseTranslator::CategoryTranslator
65+
.expects(:translate)
66+
.with(is_a(Category), "zh_CN")
67+
.times(number_of_categories)
68+
69+
job.execute({})
70+
end
71+
72+
it "skips categories that already have localizations" do
73+
localize_all_categories("pt", "zh_CN")
74+
75+
category1 =
76+
Fabricate(:category, name: "First Category", description: "First category description")
77+
Fabricate(:category_localization, category: category1, locale: "pt", name: "Primeira Categoria")
78+
79+
# It should only translate to Chinese, not Portuguese
80+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "pt").never
81+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "zh_CN").once
82+
83+
job.execute({})
84+
end
85+
86+
it "continues from a specified category ID" do
87+
category1 = Fabricate(:category, name: "First", description: "First description")
88+
category2 = Fabricate(:category, name: "Second", description: "Second description")
89+
90+
DiscourseTranslator::CategoryTranslator
91+
.expects(:translate)
92+
.with(category1, any_parameters)
93+
.never
94+
DiscourseTranslator::CategoryTranslator
95+
.expects(:translate)
96+
.with(category2, any_parameters)
97+
.twice
98+
99+
job.execute(from_category_id: category2.id)
100+
end
101+
102+
it "handles translation errors gracefully" do
103+
localize_all_categories("pt", "zh_CN")
104+
105+
category1 = Fabricate(:category, name: "First", description: "First description")
106+
DiscourseTranslator::CategoryTranslator
107+
.expects(:translate)
108+
.with(category1, "pt")
109+
.raises(StandardError.new("API error"))
110+
DiscourseTranslator::CategoryTranslator.expects(:translate).with(category1, "zh_CN").once
111+
112+
expect { job.execute({}) }.not_to raise_error
113+
end
114+
115+
it "enqueues the next batch when there are more categories" do
116+
Jobs.run_later!
117+
freeze_time
118+
Jobs::TranslateCategories.const_set(:BATCH_SIZE, 1)
119+
120+
job.execute({})
121+
122+
Category.all.each do |category|
123+
puts category.id
124+
expect_job_enqueued(
125+
job: :translate_categories,
126+
args: {
127+
from_category_id: category.id + 1,
128+
},
129+
at: 10.seconds.from_now,
130+
)
131+
end
132+
133+
Jobs::TranslateCategories.send(:remove_const, :BATCH_SIZE)
134+
Jobs::TranslateCategories.const_set(:BATCH_SIZE, 50)
135+
end
136+
end
Lines changed: 65 additions & 0 deletions
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)