Skip to content

Commit aea45e7

Browse files
committed
FEATURE: Translate topics on a schedule and on topic create
1 parent 7c3bc25 commit aea45e7

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)