Skip to content

Commit 3537345

Browse files
authored
FEATURE: Show full topic translations (#205)
This feature shows a fully translated topic in the user's language. For now, the topic needs to already have translations for the translated topic to show. If there are posts that have not been translated, the original content will be shown. The `translated_title` is returned via the `TopicViewSerializer` and displayed via the `applyTransformations` API. The `translated_cooked` is returned via the `PostSerializer` and displayed via `decorateCooked`. This takes a different approach from the previous PR #199 which overrode the `fancy_title` and `cooked` directly.
1 parent 7fc45d5 commit 3537345

File tree

10 files changed

+246
-3
lines changed

10 files changed

+246
-3
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { action } from "@ember/object";
4+
import { service } from "@ember/service";
5+
import DButton from "discourse/components/d-button";
6+
import concatClass from "discourse/helpers/concat-class";
7+
8+
export default class ShowOriginalContent extends Component {
9+
@service router;
10+
@tracked isTranslated = true;
11+
12+
constructor() {
13+
super(...arguments);
14+
this.isTranslated = !new URLSearchParams(window.location.search).has(
15+
"show"
16+
);
17+
}
18+
19+
@action
20+
async showOriginal() {
21+
const params = new URLSearchParams(window.location.search);
22+
if (this.isTranslated) {
23+
params.append("show", "original");
24+
} else {
25+
params.delete("show");
26+
}
27+
window.location.search = params.toString();
28+
}
29+
30+
get title() {
31+
return this.isTranslated
32+
? "translator.content_translated"
33+
: "translator.content_not_translated";
34+
}
35+
36+
<template>
37+
<div class="discourse-translator_toggle-original">
38+
<DButton
39+
@icon="language"
40+
@title={{this.title}}
41+
class={{concatClass "btn btn-default" (if this.isTranslated "active")}}
42+
@action={{this.showOriginal}}
43+
/>
44+
</div>
45+
</template>
46+
}

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
55
import { i18n } from "discourse-i18n";
66
import LanguageSwitcher from "../components/language-switcher";
77
import ToggleTranslationButton from "../components/post-menu/toggle-translation-button";
8+
import ShowOriginalContent from "../components/show-original-content";
89
import TranslatedPost from "../components/translated-post";
910

1011
function initializeTranslation(api) {
@@ -23,7 +24,33 @@ function initializeTranslation(api) {
2324
);
2425
}
2526

26-
if (currentUser) {
27+
if (
28+
siteSettings.experimental_topic_translation &&
29+
(currentUser || siteSettings.experimental_anon_language_switcher)
30+
) {
31+
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+
});
43+
44+
api.registerModelTransformer("topic", (topics) => {
45+
topics.forEach((topic) => {
46+
if (topic.translated_title) {
47+
topic.set("fancy_title", topic.translated_title);
48+
}
49+
});
50+
});
51+
}
52+
53+
if (!siteSettings.experimental_topic_translation) {
2754
customizePostMenu(api);
2855
}
2956
}

assets/stylesheets/common/common.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,19 @@
22
.fk-d-menu__inner-content {
33
max-height: 50vh;
44
}
5+
6+
.topic-navigation.with-timeline .discourse-translator_toggle-original {
7+
margin-bottom: 0.5em;
8+
}
9+
10+
.topic-navigation.with-topic-progress
11+
.discourse-translator_toggle-original
12+
button {
13+
height: 100%;
14+
}
15+
16+
.discourse-translator_toggle-original {
17+
button.active svg {
18+
color: var(--tertiary);
19+
}
20+
}

config/locales/client.en.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ en:
66
discourse_translator: "Discourse Translator"
77
js:
88
translator:
9-
view_translation: "View translation"
10-
hide_translation: "Hide translation"
9+
content_not_translated: "Content not translated. Click to translate"
10+
content_translated: "Content is translated. Click to view original"
1111
translated_from: "Translated from %{language} by %{translator}"
1212
translating: "Translating"

config/locales/server.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ en:
2020
experimental_anon_language_switcher: "Enable experimental language switcher for anonymous users. This will allow anonymous users to switch between translated versions of Discourse and user-contributed content in topics."
2121
errors:
2222
set_locale_cookie_requirements: "The experimental language switcher for anonymous users requires the `set locale from cookie` site setting to be enabled."
23+
experimental_topic_translation: "Enable experimental topic translation feature. This replaces existing post in-line translation with a button that allows users to translate the entire topic."
2324
translator:
2425
failed: "The translator is unable to translate this content (%{source_locale}) to the default language of this site (%{target_locale})."
2526
not_supported: "This language is not supported by the translator."

config/settings.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,6 @@ discourse_translator:
106106
default: false
107107
client: true
108108
validator: "LanguageSwitcherSettingValidator"
109+
experimental_topic_translation:
110+
default: false
111+
client: true

plugin.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,18 @@ module ::DiscourseTranslator
4141
add_to_serializer :post, :can_translate do
4242
scope.can_translate?(object)
4343
end
44+
45+
add_to_serializer :post, :translated_cooked do
46+
if !SiteSetting.experimental_topic_translation || scope.request.params["show"] == "original"
47+
return nil
48+
end
49+
object.translation_for(I18n.locale) || nil
50+
end
51+
52+
add_to_serializer :topic_view, :translated_title do
53+
if !SiteSetting.experimental_topic_translation || scope.request.params["show"] == "original"
54+
return nil
55+
end
56+
object.topic.translation_for(I18n.locale) || nil
57+
end
4458
end

spec/serializers/post_serializer_spec.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,40 @@
7979
end
8080
end
8181
end
82+
83+
describe "#cooked" do
84+
def serialize_post(guardian_user: user, params: {})
85+
env = { "action_dispatch.request.parameters" => params, "REQUEST_METHOD" => "GET" }
86+
request = ActionDispatch::Request.new(env)
87+
guardian = Guardian.new(guardian_user, request)
88+
PostSerializer.new(post, scope: guardian)
89+
end
90+
91+
before { SiteSetting.experimental_topic_translation = true }
92+
93+
it "does not return translated_cooked when experimental_topic_translation is disabled" do
94+
SiteSetting.experimental_topic_translation = false
95+
expect(serialize_post.translated_cooked).to eq(nil)
96+
end
97+
98+
it "does not return translated_cooked when show=original param is present" do
99+
I18n.locale = "ja"
100+
post.set_translation("ja", "こんにちは")
101+
102+
expect(serialize_post(params: { "show" => "original" }).translated_cooked).to eq(nil)
103+
expect(serialize_post(params: { "show" => "derp" }).translated_cooked).to eq("こんにちは")
104+
end
105+
106+
it "returns translated content based on locale" do
107+
I18n.locale = "ja"
108+
post.set_translation("ja", "こんにちは")
109+
post.set_translation("es", "Hola")
110+
expect(serialize_post.translated_cooked).to eq("こんにちは")
111+
end
112+
113+
it "does not return translated_cooked when plugin is disabled" do
114+
SiteSetting.translator_enabled = false
115+
expect(serialize_post.translated_cooked).to eq(nil)
116+
end
117+
end
82118
end

spec/serializers/topic_view_serializer_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,47 @@
3030

3131
expect(topic_view.posts.first.association(:content_locale)).to be_loaded
3232
end
33+
34+
describe "#translated_title" do
35+
fab!(:user) { Fabricate(:user, locale: "ja") }
36+
fab!(:topic)
37+
38+
let!(:guardian) { Guardian.new(user) }
39+
let!(:original_title) { "FUS ROH DAAHHH" }
40+
let!(:jap_title) { "フス・ロ・ダ・ア" }
41+
42+
before do
43+
topic.title = original_title
44+
SiteSetting.experimental_topic_translation = true
45+
I18n.locale = "ja"
46+
end
47+
48+
def serialize_topic(guardian_user: user, params: {})
49+
env = { "action_dispatch.request.parameters" => params, "REQUEST_METHOD" => "GET" }
50+
request = ActionDispatch::Request.new(env)
51+
guardian = Guardian.new(guardian_user, request)
52+
TopicViewSerializer.new(TopicView.new(topic), scope: guardian)
53+
end
54+
55+
it "does not return translated_title when experimental_topic_translation is disabled" do
56+
SiteSetting.experimental_topic_translation = false
57+
topic.set_translation("ja", jap_title)
58+
59+
expect(serialize_topic.translated_title).to eq(nil)
60+
end
61+
62+
it "does not return translated_title when show_original param is present" do
63+
topic.set_translation("ja", jap_title)
64+
expect(serialize_topic(params: { "show" => "original" }).translated_title).to eq(nil)
65+
end
66+
67+
it "does not return translated_title when no translation exists" do
68+
expect(serialize_topic.translated_title).to eq(nil)
69+
end
70+
71+
it "returns translated title when translation exists for current locale" do
72+
topic.set_translation("ja", jap_title)
73+
expect(serialize_topic.translated_title).to eq(jap_title)
74+
end
75+
end
3376
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe "Full page translation", type: :system do
4+
fab!(:japanese_user) { Fabricate(:user, locale: "ja") }
5+
fab!(:site_local_user) { Fabricate(:user, locale: "en") }
6+
fab!(:author) { Fabricate(:user) }
7+
8+
fab!(:topic) { Fabricate(:topic, title: "Life strategies from The Art of War", user: author) }
9+
fab!(:post_1) do
10+
Fabricate(:post, topic: topic, raw: "The masterpiece isn’t just about military strategy")
11+
end
12+
fab!(:post_2) do
13+
Fabricate(:post, topic: topic, raw: "The greatest victory is that which requires no battle")
14+
end
15+
16+
let(:topic_page) { PageObjects::Pages::Topic.new }
17+
18+
before do
19+
# topic translation setup
20+
topic.set_detected_locale("en")
21+
post_1.set_detected_locale("en")
22+
post_2.set_detected_locale("en")
23+
24+
topic.set_translation("ja", "孫子兵法からの人生戦略")
25+
topic.set_translation("es", "Estrategias de vida de El arte de la guerra")
26+
post_1.set_translation("ja", "傑作は単なる軍事戦略についてではありません")
27+
post_2.set_translation("ja", "最大の勝利は戦いを必要としないものです")
28+
end
29+
30+
context "when the feature is enabled" do
31+
before do
32+
SiteSetting.translator_enabled = true
33+
SiteSetting.allow_user_locale = true
34+
SiteSetting.set_locale_from_cookie = true
35+
SiteSetting.set_locale_from_param = true
36+
SiteSetting.experimental_anon_language_switcher = true
37+
SiteSetting.experimental_topic_translation = true
38+
end
39+
40+
it "shows the correct language based on the selected language and login status" do
41+
visit("/t/#{topic.slug}/#{topic.id}?lang=ja")
42+
expect(topic_page.has_topic_title?("孫子兵法からの人生戦略")).to eq(true)
43+
expect(find(topic_page.post_by_number_selector(1))).to have_content("傑作は単なる軍事戦略についてではありません")
44+
45+
visit("/t/#{topic.id}")
46+
expect(topic_page.has_topic_title?("Life strategies from The Art of War")).to eq(true)
47+
expect(find(topic_page.post_by_number_selector(1))).to have_content(
48+
"The masterpiece isn’t just about military strategy",
49+
)
50+
51+
sign_in(japanese_user)
52+
visit("/")
53+
visit("/t/#{topic.id}")
54+
expect(topic_page.has_topic_title?("孫子兵法からの人生戦略")).to eq(true)
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)