Skip to content

Commit d7ae611

Browse files
authored
FEATURE: Translate topics on a schedule and on topic create (#291)
This PR adds the functionality to translate topics using the selected provider. We are keeping the topic translation feature separate from the post translation on purpose, despite many things being the same. This allows easy modification per model and less coupling.
1 parent 1d6840e commit d7ae611

14 files changed

+617
-1
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class DetectTranslateTopic < ::Jobs::Base
5+
def execute(args)
6+
return unless SiteSetting.translator_enabled
7+
return unless SiteSetting.experimental_content_translation
8+
return if args[:topic_id].blank?
9+
10+
topic = Topic.find(args[:topic_id])
11+
if topic.blank? || topic.title.blank? || topic.deleted_at.present? || topic.user_id <= 0
12+
return
13+
end
14+
15+
detected_locale = DiscourseTranslator::TopicLocaleDetector.detect_locale(topic)
16+
17+
locales = SiteSetting.automatic_translation_target_languages.split("|")
18+
return if locales.blank?
19+
20+
locales.each do |locale|
21+
next if locale == detected_locale
22+
23+
begin
24+
DiscourseTranslator::TopicTranslator.translate(topic, locale)
25+
rescue => e
26+
Rails.logger.error(
27+
"Discourse Translator: Failed to translate topic #{topic.id} to #{locale}: #{e.message}",
28+
)
29+
end
30+
end
31+
end
32+
end
33+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class TranslateTopics < ::Jobs::Base
5+
cluster_concurrency 1
6+
sidekiq_options retry: false
7+
8+
BATCH_SIZE = 50
9+
10+
def execute(args)
11+
return unless SiteSetting.translator_enabled
12+
return unless SiteSetting.experimental_content_translation
13+
14+
locales = SiteSetting.automatic_translation_target_languages.split("|")
15+
return if locales.blank?
16+
17+
limit = args[:limit] || BATCH_SIZE
18+
19+
locales.each do |locale|
20+
topics =
21+
Topic
22+
.joins(
23+
"LEFT JOIN topic_localizations tl ON tl.topic_id = topics.id AND tl.locale = #{ActiveRecord::Base.connection.quote(locale)}",
24+
)
25+
.where(deleted_at: nil)
26+
.where("topics.user_id > 0")
27+
.where.not(locale: nil)
28+
.where.not(locale: locale)
29+
.where("tl.id IS NULL")
30+
.limit(limit)
31+
32+
next if topics.empty?
33+
34+
topics.each do |topic|
35+
begin
36+
DiscourseTranslator::TopicTranslator.translate(topic, locale)
37+
rescue => e
38+
Rails.logger.error(
39+
"Discourse Translator: Failed to translate topic #{topic.id} to #{locale}: #{e.message}",
40+
)
41+
end
42+
end
43+
44+
DiscourseTranslator::VerboseLogger.log("Translated #{topics.size} topics to #{locale}")
45+
end
46+
end
47+
end
48+
end

app/jobs/scheduled/automatic_category_translation.rb renamed to app/jobs/scheduled/category_translation_backfill.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
module Jobs
4-
class AutomaticCategoryTranslation < ::Jobs::Scheduled
4+
class CategoryTranslationBackfill < ::Jobs::Scheduled
55
every 12.hours
66
cluster_concurrency 1
77

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 TopicTranslationBackfill < ::Jobs::Scheduled
5+
every 5.minutes
6+
cluster_concurrency 1
7+
8+
def execute(args)
9+
return unless SiteSetting.translator_enabled
10+
return unless SiteSetting.experimental_content_translation
11+
12+
return if SiteSetting.automatic_translation_target_languages.blank?
13+
return if SiteSetting.automatic_translation_backfill_rate == 0
14+
15+
Jobs.enqueue(:translate_topics, limit: SiteSetting.automatic_translation_backfill_rate)
16+
end
17+
end
18+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class TopicsLocaleDetectionBackfill < ::Jobs::Scheduled
5+
every 5.minutes
6+
cluster_concurrency 1
7+
8+
def execute(args)
9+
return unless SiteSetting.translator_enabled
10+
return unless SiteSetting.experimental_content_translation
11+
return if SiteSetting.automatic_translation_backfill_rate == 0
12+
13+
limit = SiteSetting.automatic_translation_backfill_rate
14+
topics =
15+
Topic
16+
.where(locale: nil)
17+
.where(deleted_at: nil)
18+
.where("topics.user_id > 0")
19+
.order(updated_at: :desc)
20+
.limit(limit)
21+
return if topics.empty?
22+
23+
topics.each do |topic|
24+
begin
25+
DiscourseTranslator::TopicLocaleDetector.detect_locale(topic)
26+
rescue => e
27+
Rails.logger.error(
28+
"Discourse Translator: Failed to detect topic #{topic.id}'s locale: #{e.message}",
29+
)
30+
end
31+
end
32+
33+
DiscourseTranslator::VerboseLogger.log("Detected #{topics.size} topic locales")
34+
end
35+
end
36+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class TopicLocaleDetector
5+
def self.detect_locale(topic)
6+
return if topic.blank?
7+
8+
translator = DiscourseTranslator::Provider::TranslatorProvider.get
9+
detected_locale = translator.detect!(topic)
10+
locale = LocaleNormalizer.normalize_to_i18n(detected_locale)
11+
topic.update!(locale:)
12+
locale
13+
end
14+
end
15+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class TopicTranslator
5+
def self.translate(topic, target_locale = I18n.locale)
6+
return if topic.blank? || target_locale.blank? || topic.locale == target_locale.to_s
7+
8+
target_locale_sym = target_locale.to_s.sub("-", "_").to_sym
9+
10+
translator = DiscourseTranslator::Provider::TranslatorProvider.get
11+
translated_title = translator.translate_topic!(topic, target_locale_sym)
12+
13+
localization =
14+
TopicLocalization.find_or_initialize_by(topic_id: topic.id, locale: target_locale_sym.to_s)
15+
16+
localization.title = translated_title
17+
localization.fancy_title = Topic.fancy_title(translated_title)
18+
localization.localizer_user_id = Discourse.system_user.id
19+
localization.save!
20+
localization
21+
end
22+
end
23+
end

lib/discourse_translator/automatic_translations.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,23 @@ def inject(plugin)
1717
if translatable?(topic)
1818
Jobs.enqueue(:translate_translatable, type: "Topic", translatable_id: topic.id)
1919
end
20+
21+
if SiteSetting.experimental_content_localization
22+
Jobs.enqueue(:detect_translate_topic, topic_id: topic.id)
23+
end
2024
end
2125

2226
plugin.on(:topic_edited) do |topic|
2327
if translatable?(topic)
2428
Jobs.enqueue(:translate_translatable, type: "Topic", translatable_id: topic.id)
2529
end
2630
end
31+
32+
plugin.on(:post_edited) do |post, topic_changed|
33+
if SiteSetting.experimental_content_localization && topic_changed
34+
Jobs.enqueue(:detect_translate_topic, topic_id: post.topic_id)
35+
end
36+
end
2737
end
2838

2939
def translatable?(content)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
describe Jobs::DetectTranslateTopic do
4+
fab!(:topic)
5+
subject(:job) { described_class.new }
6+
7+
let(:locales) { %w[en ja] }
8+
9+
before do
10+
SiteSetting.translator_enabled = true
11+
SiteSetting.experimental_content_translation = true
12+
SiteSetting.automatic_translation_backfill_rate = 1
13+
SiteSetting.automatic_translation_target_languages = locales.join("|")
14+
end
15+
16+
it "does nothing when translator is disabled" do
17+
SiteSetting.translator_enabled = false
18+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).never
19+
DiscourseTranslator::TopicTranslator.expects(:translate).never
20+
21+
job.execute({ topic_id: topic.id })
22+
end
23+
24+
it "does nothing when content translation is disabled" do
25+
SiteSetting.experimental_content_translation = false
26+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).never
27+
DiscourseTranslator::TopicTranslator.expects(:translate).never
28+
29+
job.execute({ topic_id: topic.id })
30+
end
31+
32+
it "detects locale" do
33+
SiteSetting.translator_enabled = true
34+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).once
35+
DiscourseTranslator::TopicTranslator.expects(:translate).twice
36+
37+
job.execute({ topic_id: topic.id })
38+
end
39+
40+
it "skips bot topics" do
41+
topic.update!(user: Discourse.system_user)
42+
DiscourseTranslator::TopicTranslator.expects(:translate).never
43+
44+
job.execute({ topic_id: topic.id })
45+
end
46+
47+
it "does not translate when no target languages are configured" do
48+
SiteSetting.automatic_translation_target_languages = ""
49+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).returns("en")
50+
DiscourseTranslator::TopicTranslator.expects(:translate).never
51+
52+
job.execute({ topic_id: topic.id })
53+
end
54+
55+
it "skips translating to the topic's language" do
56+
topic.update(locale: "en")
57+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).returns("en")
58+
DiscourseTranslator::TopicTranslator.expects(:translate).with(topic, "en").never
59+
DiscourseTranslator::TopicTranslator.expects(:translate).with(topic, "ja").once
60+
61+
job.execute({ topic_id: topic.id })
62+
end
63+
64+
it "handles translation errors gracefully" do
65+
topic.update(locale: "en")
66+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).returns("en")
67+
DiscourseTranslator::TopicTranslator.expects(:translate).raises(StandardError.new("API error"))
68+
69+
expect { job.execute({ topic_id: topic.id }) }.not_to raise_error
70+
end
71+
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
describe Jobs::TopicsLocaleDetectionBackfill do
4+
fab!(:topic) { Fabricate(:topic, locale: nil) }
5+
subject(:job) { described_class.new }
6+
7+
before do
8+
SiteSetting.translator_enabled = true
9+
SiteSetting.experimental_content_translation = true
10+
SiteSetting.automatic_translation_backfill_rate = 100
11+
end
12+
13+
it "does nothing when translator is disabled" do
14+
SiteSetting.translator_enabled = false
15+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).never
16+
17+
job.execute({})
18+
end
19+
20+
it "does nothing when content translation is disabled" do
21+
SiteSetting.experimental_content_translation = false
22+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).never
23+
24+
job.execute({})
25+
end
26+
27+
it "does nothing when there are no topics to detect" do
28+
Topic.update_all(locale: "en")
29+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).never
30+
31+
job.execute({})
32+
end
33+
34+
it "detects locale for topics with nil locale" do
35+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).once
36+
job.execute({})
37+
end
38+
39+
it "detects most recently updated topics first" do
40+
topic_2 = Fabricate(:topic, locale: nil)
41+
topic_3 = Fabricate(:topic, locale: nil)
42+
43+
topic.update!(updated_at: 3.days.ago)
44+
topic_2.update!(updated_at: 2.day.ago)
45+
topic_3.update!(updated_at: 4.day.ago)
46+
47+
SiteSetting.automatic_translation_backfill_rate = 1
48+
49+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic_2).once
50+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).never
51+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic_3).never
52+
53+
job.execute({})
54+
end
55+
56+
it "skips bot topics" do
57+
topic.update!(user: Discourse.system_user)
58+
DiscourseTranslator::TopicLocaleDetector.expects(:detect_locale).with(topic).never
59+
60+
job.execute({})
61+
end
62+
63+
it "handles detection errors gracefully" do
64+
DiscourseTranslator::TopicLocaleDetector
65+
.expects(:detect_locale)
66+
.with(topic)
67+
.raises(StandardError.new("jiboomz"))
68+
.once
69+
70+
expect { job.execute({}) }.not_to raise_error
71+
end
72+
73+
it "logs a summary after running" do
74+
DiscourseTranslator::TopicLocaleDetector.stubs(:detect_locale)
75+
DiscourseTranslator::VerboseLogger.expects(:log).with(includes("Detected 1 topic locales"))
76+
77+
job.execute({})
78+
end
79+
end

0 commit comments

Comments
 (0)