Skip to content

Commit fb5af06

Browse files
committed
FEATURE: Show full topic translations
1 parent 7fc45d5 commit fb5af06

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)