Skip to content

Commit 1059f11

Browse files
committed
FEATURE: Translate all new posts automatically
Adds a new site setting 'translate_posts_to_languages'
1 parent 644a165 commit 1059f11

File tree

15 files changed

+341
-20
lines changed

15 files changed

+341
-20
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class TranslateTranslatable < ::Jobs::Base
5+
def execute(args)
6+
return unless SiteSetting.translator_enabled
7+
return if SiteSetting.automatic_translation_target_languages.blank?
8+
9+
translatable = args[:type].constantize.find_by(id: args[:translatable_id])
10+
return if translatable.blank?
11+
12+
target_locales = SiteSetting.automatic_translation_target_languages.split("|")
13+
target_locales.each do |target_locale|
14+
# 1. no special retry, job will be automatically retried with backoff
15+
# 2. translate function will handle cases where translation is not needed or not possible
16+
"DiscourseTranslator::#{SiteSetting.translator}".constantize.translate(
17+
translatable,
18+
target_locale.to_sym,
19+
)
20+
end
21+
22+
topic_id = translatable.is_a?(Post) ? translatable.topic.id : translatable.id
23+
post_id = translatable.is_a?(Post) ? translatable.id : 1
24+
MessageBus.publish("/topic/#{topic_id}", type: :revised, id: post_id)
25+
end
26+
end
27+
end
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class AutomaticTranslationBackfill < ::Jobs::Scheduled
5+
every 5.minutes
6+
7+
BACKFILL_LOCK_KEY = "discourse_translator_backfill_lock"
8+
9+
def execute(args = nil)
10+
return unless SiteSetting.translator_enabled
11+
return unless should_backfill?
12+
return unless secure_backfill_lock
13+
14+
begin
15+
process_batch
16+
ensure
17+
Discourse.redis.del(BACKFILL_LOCK_KEY)
18+
end
19+
end
20+
21+
def fetch_untranslated_model_ids(model = Post, limit = 100, target_locales = backfill_locales)
22+
m = model.name.downcase
23+
DB.query_single(<<~SQL, target_locales: target_locales, limit: limit)
24+
SELECT m.id
25+
FROM #{m}s m
26+
LEFT JOIN discourse_translator_#{m}_locales dl ON dl.#{m}_id = m.id
27+
LEFT JOIN LATERAL (
28+
SELECT array_agg(DISTINCT locale)::text[] as locales
29+
FROM discourse_translator_#{m}_translations dt
30+
WHERE dt.#{m}_id = m.id
31+
) translations ON true
32+
WHERE NOT (
33+
ARRAY[:target_locales]::text[] <@
34+
(COALESCE(
35+
array_cat(
36+
ARRAY[COALESCE(dl.detected_locale, '')]::text[],
37+
COALESCE(translations.locales, ARRAY[]::text[])
38+
),
39+
ARRAY[]::text[]
40+
))
41+
)
42+
ORDER BY m.id DESC
43+
LIMIT :limit
44+
SQL
45+
end
46+
47+
private
48+
49+
def should_backfill?
50+
return false if SiteSetting.automatic_translation_target_languages.blank?
51+
return false if SiteSetting.automatic_translation_backfill_maximum_translations_per_hour == 0
52+
true
53+
end
54+
55+
def secure_backfill_lock
56+
Discourse.redis.set(BACKFILL_LOCK_KEY, "1", ex: 5.minutes.to_i, nx: true)
57+
end
58+
59+
def translations_per_run
60+
[
61+
(SiteSetting.automatic_translation_backfill_maximum_translations_per_hour / 12) /
62+
backfill_locales.size,
63+
1,
64+
].max
65+
end
66+
67+
def backfill_locales
68+
@backfill_locales ||= SiteSetting.automatic_translation_target_languages.split("|")
69+
end
70+
71+
def translator
72+
@translator_klass ||= "DiscourseTranslator::#{SiteSetting.translator}".constantize
73+
end
74+
75+
def translate_records(type, record_ids)
76+
record_ids.each do |id|
77+
record = type.find(id)
78+
backfill_locales.each do |target_locale|
79+
begin
80+
translator.translate(record, target_locale.to_sym)
81+
rescue => e
82+
# continue with other locales even if one fails
83+
Rails.logger.warn(
84+
"Failed to machine-translate #{type.name}##{id} to #{target_locale}: #{e.message}\n#{e.backtrace.join("\n")}",
85+
)
86+
next
87+
end
88+
end
89+
end
90+
end
91+
92+
def process_batch
93+
models_translated = [Post, Topic].size
94+
translations_to_run = [translations_per_run / models_translated, 1].max
95+
topic_ids = fetch_untranslated_model_ids(Topic, translations_to_run)
96+
translations_to_run = translations_per_run if topic_ids.empty?
97+
post_ids = fetch_untranslated_model_ids(Post, translations_to_run)
98+
return if topic_ids.empty? && post_ids.empty?
99+
100+
translate_records(Topic, topic_ids)
101+
translate_records(Post, post_ids)
102+
end
103+
end
104+
end

app/services/discourse_translator/amazon.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def self.detect!(topic_or_post)
126126
def self.translate!(translatable, target_locale_sym = I18n.locale)
127127
detected_lang = detect(translatable)
128128

129-
save_translation(translatable) do
129+
save_translation(translatable, target_locale_sym) do
130130
begin
131131
client.translate_text(
132132
{

app/services/discourse_translator/base.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def self.translate(translatable, target_locale_sym = I18n.locale)
4848
),
4949
)
5050
end
51-
[detected_lang, translate!(translatable)]
51+
[detected_lang, translate!(translatable, target_locale_sym)]
5252
end
5353

5454
# Subclasses must implement this method to translate the text of a post or topic
@@ -77,9 +77,9 @@ def self.access_token
7777
raise "Not Implemented"
7878
end
7979

80-
def self.save_translation(translatable)
80+
def self.save_translation(translatable, target_locale_sym = I18n.locale)
8181
translation = yield
82-
translatable.set_translation(I18n.locale, translation)
82+
translatable.set_translation(target_locale_sym, translation)
8383
translation
8484
end
8585

app/services/discourse_translator/discourse_ai.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def self.detect!(topic_or_post)
2121

2222
def self.translate!(translatable, target_locale_sym = I18n.locale)
2323
return unless required_settings_enabled
24-
save_translation(translatable) do
24+
save_translation(translatable, target_locale_sym) do
2525
::DiscourseAi::Translator.new(
2626
text_for_translation(translatable),
2727
target_locale_sym,

app/services/discourse_translator/google.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def self.translate_supported?(source, target)
9191

9292
def self.translate!(translatable, target_locale_sym = I18n.locale)
9393
detected_locale = detect(translatable)
94-
save_translation(translatable) do
94+
save_translation(translatable, target_locale_sym) do
9595
res =
9696
result(
9797
TRANSLATE_URI,

app/services/discourse_translator/libre_translate.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def self.translate_supported?(source, target)
9898
def self.translate!(translatable, target_locale_sym = I18n.locale)
9999
detected_lang = detect(translatable)
100100

101-
save_translation(translatable) do
101+
save_translation(translatable, target_locale_sym) do
102102
res =
103103
result(
104104
translate_uri,

app/services/discourse_translator/microsoft.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def self.translate!(translatable, target_locale_sym = I18n.locale)
166166
locale =
167167
SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported"))
168168

169-
save_translation(translatable) do
169+
save_translation(translatable, target_locale_sym) do
170170
query = default_query.merge("from" => detected_lang, "to" => locale, "textType" => "html")
171171

172172
body = [{ "Text" => text_for_translation(translatable) }].to_json

app/services/discourse_translator/yandex.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def self.translate!(translatable, target_locale_sym = I18n.locale)
137137
locale =
138138
SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported"))
139139

140-
save_translation(translatable) do
140+
save_translation(translatable, target_locale_sym) do
141141
query =
142142
default_query.merge(
143143
"lang" => "#{detected_lang}-#{locale}",

assets/javascripts/discourse/initializers/extend-for-translate-button.js

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,6 @@ function initializeTranslation(api) {
2929
(currentUser || siteSettings.experimental_anon_language_switcher)
3030
) {
3131
api.renderInOutlet("topic-navigation", ShowOriginalContent);
32-
api.decorateCookedElement((cookedElement, helper) => {
33-
if (helper) {
34-
const translatedCooked = helper.getModel().get("translated_cooked");
35-
if (translatedCooked) {
36-
cookedElement.innerHTML = translatedCooked;
37-
} else {
38-
// this experimental feature does not yet support
39-
// translating individual untranslated posts
40-
}
41-
}
42-
});
4332

4433
api.registerModelTransformer("topic", (topics) => {
4534
topics.forEach((topic) => {
@@ -48,6 +37,12 @@ function initializeTranslation(api) {
4837
}
4938
});
5039
});
40+
41+
api.registerModelTransformer("post", (post) => {
42+
if (post.translated_cooked) {
43+
post.set("cooked", post.translated_cooked);
44+
}
45+
});
5146
}
5247

5348
if (!siteSettings.experimental_topic_translation) {

0 commit comments

Comments
 (0)