diff --git a/assets/javascripts/discourse/components/show-original-content.gjs b/assets/javascripts/discourse/components/show-original-content.gjs new file mode 100644 index 00000000..ce8e19a0 --- /dev/null +++ b/assets/javascripts/discourse/components/show-original-content.gjs @@ -0,0 +1,46 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import concatClass from "discourse/helpers/concat-class"; + +export default class ShowOriginalContent extends Component { + @service router; + @tracked isTranslated = true; + + constructor() { + super(...arguments); + this.isTranslated = !new URLSearchParams(window.location.search).has( + "show" + ); + } + + @action + async showOriginal() { + const params = new URLSearchParams(window.location.search); + if (this.isTranslated) { + params.append("show", "original"); + } else { + params.delete("show"); + } + window.location.search = params.toString(); + } + + get title() { + return this.isTranslated + ? "translator.content_translated" + : "translator.content_not_translated"; + } + + +} diff --git a/assets/javascripts/discourse/initializers/extend-for-translate-button.js b/assets/javascripts/discourse/initializers/extend-for-translate-button.js index 44b75c49..518377be 100644 --- a/assets/javascripts/discourse/initializers/extend-for-translate-button.js +++ b/assets/javascripts/discourse/initializers/extend-for-translate-button.js @@ -5,6 +5,7 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import { i18n } from "discourse-i18n"; import LanguageSwitcher from "../components/language-switcher"; import ToggleTranslationButton from "../components/post-menu/toggle-translation-button"; +import ShowOriginalContent from "../components/show-original-content"; import TranslatedPost from "../components/translated-post"; function initializeTranslation(api) { @@ -23,7 +24,33 @@ function initializeTranslation(api) { ); } - if (currentUser) { + if ( + siteSettings.experimental_topic_translation && + (currentUser || siteSettings.experimental_anon_language_switcher) + ) { + api.renderInOutlet("topic-navigation", ShowOriginalContent); + api.decorateCookedElement((cookedElement, helper) => { + if (helper) { + const translatedCooked = helper.getModel().get("translated_cooked"); + if (translatedCooked) { + cookedElement.innerHTML = translatedCooked; + } else { + // this experimental feature does not yet support + // translating individual untranslated posts + } + } + }); + + api.registerModelTransformer("topic", (topics) => { + topics.forEach((topic) => { + if (topic.translated_title) { + topic.set("fancy_title", topic.translated_title); + } + }); + }); + } + + if (!siteSettings.experimental_topic_translation) { customizePostMenu(api); } } diff --git a/assets/stylesheets/common/common.scss b/assets/stylesheets/common/common.scss index 202aec15..5fe59387 100644 --- a/assets/stylesheets/common/common.scss +++ b/assets/stylesheets/common/common.scss @@ -2,3 +2,19 @@ .fk-d-menu__inner-content { max-height: 50vh; } + +.topic-navigation.with-timeline .discourse-translator_toggle-original { + margin-bottom: 0.5em; +} + +.topic-navigation.with-topic-progress + .discourse-translator_toggle-original + button { + height: 100%; +} + +.discourse-translator_toggle-original { + button.active svg { + color: var(--tertiary); + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index f0217836..865967a8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -6,7 +6,7 @@ en: discourse_translator: "Discourse Translator" js: translator: - view_translation: "View translation" - hide_translation: "Hide translation" + content_not_translated: "Content not translated. Click to translate" + content_translated: "Content is translated. Click to view original" translated_from: "Translated from %{language} by %{translator}" translating: "Translating" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ff4ceb40..e4230523 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -20,6 +20,7 @@ en: 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." errors: set_locale_cookie_requirements: "The experimental language switcher for anonymous users requires the `set locale from cookie` site setting to be enabled." + 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." translator: failed: "The translator is unable to translate this content (%{source_locale}) to the default language of this site (%{target_locale})." not_supported: "This language is not supported by the translator." diff --git a/config/settings.yml b/config/settings.yml index b17ece4e..bb643193 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -106,3 +106,6 @@ discourse_translator: default: false client: true validator: "LanguageSwitcherSettingValidator" + experimental_topic_translation: + default: false + client: true diff --git a/plugin.rb b/plugin.rb index 7e927c71..9fead2d9 100644 --- a/plugin.rb +++ b/plugin.rb @@ -41,4 +41,18 @@ module ::DiscourseTranslator add_to_serializer :post, :can_translate do scope.can_translate?(object) end + + add_to_serializer :post, :translated_cooked do + if !SiteSetting.experimental_topic_translation || scope.request.params["show"] == "original" + return nil + end + object.translation_for(I18n.locale) || nil + end + + add_to_serializer :topic_view, :translated_title do + if !SiteSetting.experimental_topic_translation || scope.request.params["show"] == "original" + return nil + end + object.topic.translation_for(I18n.locale) || nil + end end diff --git a/spec/serializers/post_serializer_spec.rb b/spec/serializers/post_serializer_spec.rb index c3b230cb..c935d248 100644 --- a/spec/serializers/post_serializer_spec.rb +++ b/spec/serializers/post_serializer_spec.rb @@ -79,4 +79,40 @@ end end end + + describe "#cooked" do + def serialize_post(guardian_user: user, params: {}) + env = { "action_dispatch.request.parameters" => params, "REQUEST_METHOD" => "GET" } + request = ActionDispatch::Request.new(env) + guardian = Guardian.new(guardian_user, request) + PostSerializer.new(post, scope: guardian) + end + + before { SiteSetting.experimental_topic_translation = true } + + it "does not return translated_cooked when experimental_topic_translation is disabled" do + SiteSetting.experimental_topic_translation = false + expect(serialize_post.translated_cooked).to eq(nil) + end + + it "does not return translated_cooked when show=original param is present" do + I18n.locale = "ja" + post.set_translation("ja", "こんにちは") + + expect(serialize_post(params: { "show" => "original" }).translated_cooked).to eq(nil) + expect(serialize_post(params: { "show" => "derp" }).translated_cooked).to eq("こんにちは") + end + + it "returns translated content based on locale" do + I18n.locale = "ja" + post.set_translation("ja", "こんにちは") + post.set_translation("es", "Hola") + expect(serialize_post.translated_cooked).to eq("こんにちは") + end + + it "does not return translated_cooked when plugin is disabled" do + SiteSetting.translator_enabled = false + expect(serialize_post.translated_cooked).to eq(nil) + end + end end diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 58c1bc79..641efb16 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -30,4 +30,47 @@ expect(topic_view.posts.first.association(:content_locale)).to be_loaded end + + describe "#translated_title" do + fab!(:user) { Fabricate(:user, locale: "ja") } + fab!(:topic) + + let!(:guardian) { Guardian.new(user) } + let!(:original_title) { "FUS ROH DAAHHH" } + let!(:jap_title) { "フス・ロ・ダ・ア" } + + before do + topic.title = original_title + SiteSetting.experimental_topic_translation = true + I18n.locale = "ja" + end + + def serialize_topic(guardian_user: user, params: {}) + env = { "action_dispatch.request.parameters" => params, "REQUEST_METHOD" => "GET" } + request = ActionDispatch::Request.new(env) + guardian = Guardian.new(guardian_user, request) + TopicViewSerializer.new(TopicView.new(topic), scope: guardian) + end + + it "does not return translated_title when experimental_topic_translation is disabled" do + SiteSetting.experimental_topic_translation = false + topic.set_translation("ja", jap_title) + + expect(serialize_topic.translated_title).to eq(nil) + end + + it "does not return translated_title when show_original param is present" do + topic.set_translation("ja", jap_title) + expect(serialize_topic(params: { "show" => "original" }).translated_title).to eq(nil) + end + + it "does not return translated_title when no translation exists" do + expect(serialize_topic.translated_title).to eq(nil) + end + + it "returns translated title when translation exists for current locale" do + topic.set_translation("ja", jap_title) + expect(serialize_topic.translated_title).to eq(jap_title) + end + end end diff --git a/spec/system/full_page_translation_spec.rb b/spec/system/full_page_translation_spec.rb new file mode 100644 index 00000000..a9023a25 --- /dev/null +++ b/spec/system/full_page_translation_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.describe "Full page translation", type: :system do + fab!(:japanese_user) { Fabricate(:user, locale: "ja") } + fab!(:site_local_user) { Fabricate(:user, locale: "en") } + fab!(:author) { Fabricate(:user) } + + fab!(:topic) { Fabricate(:topic, title: "Life strategies from The Art of War", user: author) } + fab!(:post_1) do + Fabricate(:post, topic: topic, raw: "The masterpiece isn’t just about military strategy") + end + fab!(:post_2) do + Fabricate(:post, topic: topic, raw: "The greatest victory is that which requires no battle") + end + + let(:topic_page) { PageObjects::Pages::Topic.new } + + before do + # topic translation setup + topic.set_detected_locale("en") + post_1.set_detected_locale("en") + post_2.set_detected_locale("en") + + topic.set_translation("ja", "孫子兵法からの人生戦略") + topic.set_translation("es", "Estrategias de vida de El arte de la guerra") + post_1.set_translation("ja", "傑作は単なる軍事戦略についてではありません") + post_2.set_translation("ja", "最大の勝利は戦いを必要としないものです") + end + + context "when the feature is enabled" do + before do + SiteSetting.translator_enabled = true + SiteSetting.allow_user_locale = true + SiteSetting.set_locale_from_cookie = true + SiteSetting.set_locale_from_param = true + SiteSetting.experimental_anon_language_switcher = true + SiteSetting.experimental_topic_translation = true + end + + it "shows the correct language based on the selected language and login status" do + visit("/t/#{topic.slug}/#{topic.id}?lang=ja") + expect(topic_page.has_topic_title?("孫子兵法からの人生戦略")).to eq(true) + expect(find(topic_page.post_by_number_selector(1))).to have_content("傑作は単なる軍事戦略についてではありません") + + visit("/t/#{topic.id}") + expect(topic_page.has_topic_title?("Life strategies from The Art of War")).to eq(true) + expect(find(topic_page.post_by_number_selector(1))).to have_content( + "The masterpiece isn’t just about military strategy", + ) + + sign_in(japanese_user) + visit("/") + visit("/t/#{topic.id}") + expect(topic_page.has_topic_title?("孫子兵法からの人生戦略")).to eq(true) + end + end +end