├── .discourse-compatibility ├── .github └── workflows │ └── discourse-plugin.yml ├── .gitignore ├── .npmrc ├── .prettierrc.cjs ├── .rubocop.yml ├── .streerc ├── .template-lintrc.cjs ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── about.json ├── app ├── controllers │ └── discourse_translator │ │ └── translator_controller.rb ├── jobs │ └── regular │ │ └── detect_translatable_language.rb ├── models │ ├── concerns │ │ └── discourse_translator │ │ │ └── translatable.rb │ └── discourse_translator │ │ ├── post_locale.rb │ │ ├── post_translation.rb │ │ ├── topic_locale.rb │ │ └── topic_translation.rb └── services │ ├── discourse_translator │ └── provider │ │ ├── amazon.rb │ │ ├── base_provider.rb │ │ ├── google.rb │ │ ├── libre_translate.rb │ │ ├── microsoft.rb │ │ ├── translator_provider.rb │ │ └── yandex.rb │ └── problem_check │ ├── missing_translator_api_key.rb │ └── translator_error.rb ├── assets ├── javascripts │ └── discourse │ │ ├── components │ │ ├── post-menu │ │ │ └── toggle-translation-button.gjs │ │ ├── translated-post-indicator.gjs │ │ └── translated-post.gjs │ │ ├── initializers │ │ └── extend-for-translate-button.js │ │ └── services │ │ └── translator.js └── stylesheets │ └── common │ ├── common.scss │ └── post.scss ├── config ├── locales │ ├── client.ar.yml │ ├── client.be.yml │ ├── client.bg.yml │ ├── client.bs_BA.yml │ ├── client.ca.yml │ ├── client.cs.yml │ ├── client.da.yml │ ├── client.de.yml │ ├── client.el.yml │ ├── client.en.yml │ ├── client.en_GB.yml │ ├── client.es.yml │ ├── client.et.yml │ ├── client.fa_IR.yml │ ├── client.fi.yml │ ├── client.fr.yml │ ├── client.gl.yml │ ├── client.he.yml │ ├── client.hr.yml │ ├── client.hu.yml │ ├── client.hy.yml │ ├── client.id.yml │ ├── client.it.yml │ ├── client.ja.yml │ ├── client.ko.yml │ ├── client.lt.yml │ ├── client.lv.yml │ ├── client.nb_NO.yml │ ├── client.nl.yml │ ├── client.pl_PL.yml │ ├── client.pt.yml │ ├── client.pt_BR.yml │ ├── client.ro.yml │ ├── client.ru.yml │ ├── client.sk.yml │ ├── client.sl.yml │ ├── client.sq.yml │ ├── client.sr.yml │ ├── client.sv.yml │ ├── client.sw.yml │ ├── client.te.yml │ ├── client.th.yml │ ├── client.tr_TR.yml │ ├── client.ug.yml │ ├── client.uk.yml │ ├── client.ur.yml │ ├── client.vi.yml │ ├── client.zh_CN.yml │ ├── client.zh_TW.yml │ ├── server.ar.yml │ ├── server.be.yml │ ├── server.bg.yml │ ├── server.bs_BA.yml │ ├── server.ca.yml │ ├── server.cs.yml │ ├── server.da.yml │ ├── server.de.yml │ ├── server.el.yml │ ├── server.en.yml │ ├── server.en_GB.yml │ ├── server.es.yml │ ├── server.et.yml │ ├── server.fa_IR.yml │ ├── server.fi.yml │ ├── server.fr.yml │ ├── server.gl.yml │ ├── server.he.yml │ ├── server.hr.yml │ ├── server.hu.yml │ ├── server.hy.yml │ ├── server.id.yml │ ├── server.it.yml │ ├── server.ja.yml │ ├── server.ko.yml │ ├── server.lt.yml │ ├── server.lv.yml │ ├── server.nb_NO.yml │ ├── server.nl.yml │ ├── server.pl_PL.yml │ ├── server.pt.yml │ ├── server.pt_BR.yml │ ├── server.ro.yml │ ├── server.ru.yml │ ├── server.sk.yml │ ├── server.sl.yml │ ├── server.sq.yml │ ├── server.sr.yml │ ├── server.sv.yml │ ├── server.sw.yml │ ├── server.te.yml │ ├── server.th.yml │ ├── server.tr_TR.yml │ ├── server.ug.yml │ ├── server.uk.yml │ ├── server.ur.yml │ ├── server.vi.yml │ ├── server.zh_CN.yml │ └── server.zh_TW.yml ├── routes.rb └── settings.yml ├── db └── migrate │ ├── 20210429154318_remove_empty_translation_custom_fields.rb │ ├── 20230323110557_rename_translator_azure_custom_domain_site_setting.rb │ ├── 20250205082400_create_translation_tables.rb │ ├── 20250205082401_move_translations_custom_fields_to_table.rb │ ├── 20250210171147_hyphenate_translator_locales.rb │ ├── 20250224120505_cleanup_ai_translations.rb │ ├── 20250227074505_rename_translator_site_settings.rb │ ├── 20250313082243_create_translation_indexes.rb │ ├── 20250429102109_rename_site_setting_content_translation.rb │ ├── 20250522045138_cleanup_amazon_translations.rb │ ├── 20250528040217_rename_translation_target_languages_to_content_localization_supported_locales.rb │ └── 20250528105453_disable_translator_discourse_ai.rb ├── eslint.config.mjs ├── example.gif ├── lib └── discourse_translator │ ├── engine.rb │ ├── extensions │ ├── guardian_extension.rb │ ├── post_extension.rb │ └── topic_extension.rb │ ├── parallel_text_translation.rb │ └── translatable_languages_setting.rb ├── package.json ├── plugin.rb ├── pnpm-lock.yaml ├── setup.png ├── spec ├── fabricators │ ├── post_locale.rb │ ├── post_translation.rb │ ├── topic_locale.rb │ └── topic_translation.rb ├── jobs │ └── detect_translatable_language_spec.rb ├── lib │ └── guardian_extension_spec.rb ├── models │ ├── post_spec.rb │ └── topic_spec.rb ├── requests │ └── translator_controller_spec.rb ├── serializers │ └── post_serializer_spec.rb ├── services │ ├── amazon_spec.rb │ ├── base_provider_spec.rb │ ├── google_spec.rb │ ├── libre_translate_spec.rb │ ├── microsoft_spec.rb │ ├── problem_check │ │ └── missing_translator_api_key_spec.rb │ └── yandex_spec.rb └── system │ └── core_features_spec.rb ├── stylelint.config.mjs ├── test └── javascripts │ ├── integration │ ├── toggle-translation-button-test.gjs │ └── translated-post-test.gjs │ └── service │ └── translator-test.js └── translator.yml /.discourse-compatibility: -------------------------------------------------------------------------------- 1 | < 3.5.0.beta6-dev: 7e0a51707f98eb8ec49fa39c519075b1766d54be 2 | < 3.5.0.beta5-dev: 5c44f829ef82ded3416b0cddc521e9e6d62ed534 3 | < 3.5.0.beta4-dev: 14ca3c07efa0a80712a4cbb8ca455c32a727adec 4 | < 3.5.0.beta2-dev: 5f24835801fdc7cb98e1bcf42d2ab2e49e609921 5 | < 3.5.0.beta1-dev: 7d411e458bdd449f8aead2bc07cedeb00b856798 6 | < 3.4.0.beta3-dev: b4cf3a065884816fa3f770248c2bf908ba65d8ac 7 | < 3.4.0.beta1-dev: 5346b4bafba2c2fb817f030a473b7bbca97b909c 8 | < 3.3.0.beta1-dev: 6750e10a6d9dfd3fc2c9a0cac5a83aca1a8ee401 9 | 3.1.999: 20aed65b909fb41e22181067dc990b52ab0b7a96 10 | -------------------------------------------------------------------------------- /.github/workflows/discourse-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Discourse Plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | .rubocop-https---raw-githubusercontent-com-discourse-* 4 | gems 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | auto-install-peers = false 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/prettier"); 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma 3 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/template-lint"); 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "rubocop-discourse" 7 | gem "syntax_tree" 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | ast (2.4.3) 18 | base64 (0.2.0) 19 | benchmark (0.4.0) 20 | bigdecimal (3.1.9) 21 | concurrent-ruby (1.3.5) 22 | connection_pool (2.5.3) 23 | drb (2.2.3) 24 | i18n (1.14.7) 25 | concurrent-ruby (~> 1.0) 26 | json (2.12.2) 27 | language_server-protocol (3.17.0.5) 28 | lint_roller (1.1.0) 29 | logger (1.7.0) 30 | minitest (5.25.5) 31 | parallel (1.27.0) 32 | parser (3.3.8.0) 33 | ast (~> 2.4.1) 34 | racc 35 | prettier_print (1.2.1) 36 | prism (1.4.0) 37 | racc (1.8.1) 38 | rack (3.1.15) 39 | rainbow (3.1.1) 40 | regexp_parser (2.10.0) 41 | rubocop (1.75.7) 42 | json (~> 2.3) 43 | language_server-protocol (~> 3.17.0.2) 44 | lint_roller (~> 1.1.0) 45 | parallel (~> 1.10) 46 | parser (>= 3.3.0.2) 47 | rainbow (>= 2.2.2, < 4.0) 48 | regexp_parser (>= 2.9.3, < 3.0) 49 | rubocop-ast (>= 1.44.0, < 2.0) 50 | ruby-progressbar (~> 1.7) 51 | unicode-display_width (>= 2.4.0, < 4.0) 52 | rubocop-ast (1.44.1) 53 | parser (>= 3.3.7.2) 54 | prism (~> 1.4) 55 | rubocop-capybara (2.22.1) 56 | lint_roller (~> 1.1) 57 | rubocop (~> 1.72, >= 1.72.1) 58 | rubocop-discourse (3.12.1) 59 | activesupport (>= 6.1) 60 | lint_roller (>= 1.1.0) 61 | rubocop (>= 1.73.2) 62 | rubocop-capybara (>= 2.22.0) 63 | rubocop-factory_bot (>= 2.27.0) 64 | rubocop-rails (>= 2.30.3) 65 | rubocop-rspec (>= 3.0.1) 66 | rubocop-rspec_rails (>= 2.31.0) 67 | rubocop-factory_bot (2.27.1) 68 | lint_roller (~> 1.1) 69 | rubocop (~> 1.72, >= 1.72.1) 70 | rubocop-rails (2.32.0) 71 | activesupport (>= 4.2.0) 72 | lint_roller (~> 1.1) 73 | rack (>= 1.1) 74 | rubocop (>= 1.75.0, < 2.0) 75 | rubocop-ast (>= 1.44.0, < 2.0) 76 | rubocop-rspec (3.6.0) 77 | lint_roller (~> 1.1) 78 | rubocop (~> 1.72, >= 1.72.1) 79 | rubocop-rspec_rails (2.31.0) 80 | lint_roller (~> 1.1) 81 | rubocop (~> 1.72, >= 1.72.1) 82 | rubocop-rspec (~> 3.5) 83 | ruby-progressbar (1.13.0) 84 | securerandom (0.4.1) 85 | syntax_tree (6.2.0) 86 | prettier_print (>= 1.2.0) 87 | tzinfo (2.0.6) 88 | concurrent-ruby (~> 1.0) 89 | unicode-display_width (3.1.4) 90 | unicode-emoji (~> 4.0, >= 4.0.4) 91 | unicode-emoji (4.0.4) 92 | uri (1.0.3) 93 | 94 | PLATFORMS 95 | ruby 96 | 97 | DEPENDENCIES 98 | rubocop-discourse 99 | syntax_tree 100 | 101 | BUNDLED WITH 102 | 2.6.9 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 TAN GUO XIANG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discourse Translator Plugin 2 | 3 | Translate posts on Discourse using LibreTranslate, Microsoft, Google, Amazon or Yandex translation APIs. 4 | 5 | For more information, please see: https://meta.discourse.org/t/discourse-translator/32630 6 | -------------------------------------------------------------------------------- /about.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": { 3 | "requiredPlugins": [ 4 | "https://github.com/discourse/discourse-ai" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/controllers/discourse_translator/translator_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseTranslator 4 | class TranslatorController < ::ApplicationController 5 | requires_plugin PLUGIN_NAME 6 | 7 | before_action :ensure_logged_in 8 | 9 | def translate 10 | if !current_user.staff? 11 | RateLimiter.new( 12 | current_user, 13 | "translate_post", 14 | SiteSetting.max_translations_per_minute, 15 | 1.minute, 16 | ).performed! 17 | end 18 | 19 | params.require(:post_id) 20 | post = Post.find_by(id: params[:post_id]) 21 | raise Discourse::InvalidParameters.new(:post_id) if post.blank? 22 | guardian.ensure_can_see!(post) 23 | 24 | if !guardian.user_group_allow_translate? 25 | raise Discourse::InvalidAccess.new( 26 | "not_in_group", 27 | SiteSetting.restrict_translation_by_group, 28 | custom_message: "not_in_group.user_not_in_group", 29 | group: current_user.groups.pluck(:id), 30 | ) 31 | end 32 | 33 | if !guardian.poster_group_allow_translate?(post) 34 | raise Discourse::InvalidAccess.new( 35 | "not_in_group", 36 | SiteSetting.restrict_translation_by_poster_group, 37 | custom_message: "not_in_group.poster_not_in_group", 38 | ) 39 | end 40 | 41 | begin 42 | title_json = {} 43 | detected_lang, translation = 44 | DiscourseTranslator::Provider::TranslatorProvider.get.translate(post) 45 | if post.is_first_post? 46 | _, title_translation = 47 | DiscourseTranslator::Provider::TranslatorProvider.get.translate(post.topic) 48 | title_json = { title_translation: title_translation } 49 | end 50 | render json: { translation: translation, detected_lang: detected_lang }.merge(title_json), 51 | status: 200 52 | rescue ::DiscourseTranslator::Provider::TranslatorError => e 53 | render_json_error e.message, status: 422 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/jobs/regular/detect_translatable_language.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::Jobs 4 | class DetectTranslatableLanguage < ::Jobs::Base 5 | def execute(args) 6 | return unless SiteSetting.translator_enabled 7 | 8 | return if !%w[Post Topic].include?(args[:type]) 9 | return if !args[:translatable_id].is_a?(Integer) 10 | 11 | translatable = args[:type].constantize.find_by(id: args[:translatable_id]) 12 | return if translatable.blank? 13 | begin 14 | translator = DiscourseTranslator::Provider::TranslatorProvider.get 15 | translator.detect(translatable) 16 | rescue ::DiscourseTranslator::Provider::ProblemCheckedTranslationError 17 | # problem-checked translation errors gracefully 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/concerns/discourse_translator/translatable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Translatable 5 | extend ActiveSupport::Concern 6 | 7 | prepended do 8 | has_many :translations, 9 | class_name: "DiscourseTranslator::#{name}Translation", 10 | dependent: :destroy 11 | has_one :content_locale, class_name: "DiscourseTranslator::#{name}Locale", dependent: :destroy 12 | end 13 | 14 | def set_detected_locale(locale) 15 | # locales should be "en-US" instead of "en_US" per https://www.rfc-editor.org/rfc/rfc5646#section-2.1 16 | locale = locale.to_s.gsub("_", "-") 17 | (content_locale || build_content_locale).update!(detected_locale: locale) 18 | locale 19 | end 20 | 21 | # This method is used to create a translation for a translatable (Post or Topic) and a specific locale. 22 | # If a translation already exists for the locale, it will be updated. 23 | # Texts are put through a Sanitizer to clean them up before saving. 24 | # @param locale [String] the locale of the translation 25 | # @param text [String] the translated text 26 | def set_translation(locale, text) 27 | locale = locale.to_s.gsub("_", "-") 28 | translations.find_or_initialize_by(locale: locale).update!(translation: text) 29 | end 30 | 31 | def translation_for(locale) 32 | locale = locale.to_s.gsub("_", "-") 33 | # this is a tricky perf balancing act when loading translations for a topic with many posts. 34 | # the topic_view_serializer includes(:translations) for posts in the topic, 35 | # the alternative is to do a find_by(locale: locale) which would result in a query per post. 36 | translations.to_a.find { |t| t.locale == locale }&.translation 37 | end 38 | 39 | def detected_locale 40 | content_locale&.detected_locale 41 | end 42 | 43 | def locale_matches?(locale, normalise_region: true) 44 | return false if detected_locale.blank? || locale.blank? 45 | 46 | # locales can be :en :en_US "en" "en-US" 47 | detected = detected_locale.gsub("_", "-") 48 | target = locale.to_s.gsub("_", "-") 49 | detected = detected.split("-").first if normalise_region 50 | target = target.split("-").first if normalise_region 51 | detected == target 52 | end 53 | 54 | private 55 | 56 | def clear_translations 57 | return if !SiteSetting.translator_enabled 58 | 59 | translations.delete_all 60 | content_locale&.destroy 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /app/models/discourse_translator/post_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class PostLocale < ActiveRecord::Base 5 | self.table_name = "discourse_translator_post_locales" 6 | 7 | belongs_to :post 8 | 9 | validates :post_id, presence: true, uniqueness: true 10 | validates :detected_locale, presence: true 11 | end 12 | end 13 | 14 | # == Schema Information 15 | # 16 | # Table name: discourse_translator_post_locales 17 | # 18 | # id :bigint not null, primary key 19 | # post_id :integer not null 20 | # detected_locale :string(20) not null 21 | # created_at :datetime not null 22 | # updated_at :datetime not null 23 | # 24 | -------------------------------------------------------------------------------- /app/models/discourse_translator/post_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class PostTranslation < ActiveRecord::Base 5 | self.table_name = "discourse_translator_post_translations" 6 | 7 | belongs_to :post 8 | 9 | validates :post_id, presence: true 10 | validates :locale, presence: true 11 | validates :translation, presence: true 12 | validates :locale, uniqueness: { scope: :post_id } 13 | end 14 | end 15 | 16 | # == Schema Information 17 | # 18 | # Table name: discourse_translator_post_translations 19 | # 20 | # id :bigint not null, primary key 21 | # post_id :integer not null 22 | # locale :string not null 23 | # translation :text not null 24 | # created_at :datetime not null 25 | # updated_at :datetime not null 26 | # 27 | # Indexes 28 | # 29 | # idx_on_post_id_locale_0cc3d81e5b (post_id,locale) UNIQUE 30 | # 31 | -------------------------------------------------------------------------------- /app/models/discourse_translator/topic_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class TopicLocale < ActiveRecord::Base 5 | self.table_name = "discourse_translator_topic_locales" 6 | 7 | belongs_to :topic 8 | 9 | validates :topic_id, presence: true, uniqueness: true 10 | validates :detected_locale, presence: true 11 | end 12 | end 13 | 14 | # == Schema Information 15 | # 16 | # Table name: discourse_translator_topic_locales 17 | # 18 | # id :bigint not null, primary key 19 | # topic_id :integer not null 20 | # detected_locale :string(20) not null 21 | # created_at :datetime not null 22 | # updated_at :datetime not null 23 | # 24 | -------------------------------------------------------------------------------- /app/models/discourse_translator/topic_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class TopicTranslation < ActiveRecord::Base 5 | self.table_name = "discourse_translator_topic_translations" 6 | 7 | belongs_to :topic 8 | 9 | validates :topic_id, presence: true 10 | validates :locale, presence: true 11 | validates :translation, presence: true 12 | validates :locale, uniqueness: { scope: :topic_id } 13 | end 14 | end 15 | 16 | # == Schema Information 17 | # 18 | # Table name: discourse_translator_topic_translations 19 | # 20 | # id :bigint not null, primary key 21 | # topic_id :integer not null 22 | # locale :string not null 23 | # translation :text not null 24 | # created_at :datetime not null 25 | # updated_at :datetime not null 26 | # 27 | # Indexes 28 | # 29 | # idx_on_topic_id_locale_70b2f83213 (topic_id,locale) UNIQUE 30 | # 31 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/amazon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | class Amazon < BaseProvider 6 | require "aws-sdk-translate" 7 | 8 | MAX_BYTES = 10_000 9 | 10 | # Hash which maps Discourse's locale code to Amazon Translate's language code found in 11 | # https://docs.aws.amazon.com/translate/latest/dg/what-is-languages.html 12 | SUPPORTED_LANG_MAPPING = { 13 | af: "af", 14 | am: "am", 15 | ar: "ar", 16 | az: "az", 17 | bg: "bg", 18 | bn: "bn", 19 | bs: "bs", 20 | bs_BA: "bs", 21 | ca: "ca", 22 | cs: "cs", 23 | cy: "cy", 24 | da: "da", 25 | de: "de", 26 | el: "el", 27 | en: "en", 28 | en_GB: "en", 29 | es: "es", 30 | es_MX: "es-MX", 31 | et: "et", 32 | fa: "fa", 33 | fa_AF: "fa-AF", 34 | fa_IR: "fa-AF", 35 | fi: "fi", 36 | fr: "fr", 37 | fr_CA: "fr-CA", 38 | ga: "ga", 39 | gu: "gu", 40 | ha: "ha", 41 | he: "he", 42 | hi: "hi", 43 | hr: "hr", 44 | ht: "ht", 45 | hu: "hu", 46 | hy: "hy", 47 | id: "id", 48 | is: "is", 49 | it: "it", 50 | ja: "ja", 51 | ka: "ka", 52 | kk: "kk", 53 | kn: "kn", 54 | ko: "ko", 55 | lt: "lt", 56 | lv: "lv", 57 | mk: "mk", 58 | ml: "ml", 59 | mn: "mn", 60 | mr: "mr", 61 | ms: "ms", 62 | mt: "mt", 63 | nl: "nl", 64 | no: "no", 65 | pa: "pa", 66 | pl: "pl", 67 | pl_PL: "pl", 68 | ps: "ps", 69 | pt: "pt", 70 | pt_PT: "pt-PT", 71 | pt_BR: "pt", 72 | ro: "ro", 73 | ru: "ru", 74 | si: "si", 75 | sk: "sk", 76 | sl: "sl", 77 | so: "so", 78 | sq: "sq", 79 | sr: "sr", 80 | sv: "sv", 81 | sw: "sw", 82 | ta: "ta", 83 | te: "te", 84 | th: "th", 85 | tl: "tl", 86 | tr: "tr", 87 | tr_TR: "tr_TR", 88 | uk: "uk", 89 | ur: "ur", 90 | uz: "uz", 91 | vi: "vi", 92 | zh: "zh", 93 | zh_CN: "zh", 94 | zh_TW: "zh-TW", 95 | } 96 | 97 | # The API expects a maximum of 10k __bytes__ of text 98 | def self.truncate(text) 99 | return text if text.bytesize <= MAX_BYTES 100 | text = text.byteslice(...MAX_BYTES) 101 | text = text.byteslice(...text.bytesize - 1) until text.valid_encoding? 102 | text 103 | end 104 | 105 | def self.access_token_key 106 | "aws-translator" 107 | end 108 | 109 | def self.detect!(topic_or_post) 110 | begin 111 | client.translate_text( 112 | { 113 | text: truncate(text_for_detection(topic_or_post)), 114 | source_language_code: "auto", 115 | target_language_code: SUPPORTED_LANG_MAPPING[I18n.locale], 116 | }, 117 | )&.source_language_code 118 | rescue Aws::Errors::MissingCredentialsError 119 | raise I18n.t("translator.amazon.invalid_credentials") 120 | end 121 | end 122 | 123 | def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) 124 | raw = opts.key?(:raw) ? opts[:raw] : !opts[:cooked] 125 | text = text_for_translation(post, raw:) 126 | translate_text!(text, target_locale_sym) 127 | end 128 | 129 | def self.translate_topic!(topic, target_locale_sym = I18n.locale) 130 | text = text_for_translation(topic) 131 | translate_text!(text, target_locale_sym) 132 | end 133 | 134 | def self.translate_text!(text, target_locale_sym = I18n.locale) 135 | client.translate_text( 136 | { 137 | text: truncate(text), 138 | source_language_code: "auto", 139 | target_language_code: SUPPORTED_LANG_MAPPING[target_locale_sym], 140 | }, 141 | )&.translated_text 142 | end 143 | 144 | def self.client 145 | opts = { region: SiteSetting.translator_aws_region } 146 | 147 | if SiteSetting.translator_aws_key_id.present? && 148 | SiteSetting.translator_aws_secret_access.present? 149 | opts[:access_key_id] = SiteSetting.translator_aws_key_id 150 | opts[:secret_access_key] = SiteSetting.translator_aws_secret_access 151 | elsif SiteSetting.translator_aws_iam_role.present? 152 | sts_client = Aws::STS::Client.new(region: SiteSetting.translator_aws_region) 153 | 154 | opts[:credentials] = Aws::AssumeRoleCredentials.new( 155 | client: sts_client, 156 | role_arn: SiteSetting.translator_aws_iam_role, 157 | role_session_name: "discourse-aws-translator", 158 | ) 159 | end 160 | 161 | @client ||= Aws::Translate::Client.new(opts) 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/base_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | extend ActiveSupport::Concern 6 | 7 | class TranslatorError < ::StandardError 8 | end 9 | 10 | class ProblemCheckedTranslationError < TranslatorError 11 | end 12 | 13 | class BaseProvider 14 | DETECTION_CHAR_LIMIT = 1000 15 | 16 | def self.key_prefix 17 | "#{PLUGIN_NAME}:".freeze 18 | end 19 | 20 | def self.access_token_key 21 | raise "Not Implemented" 22 | end 23 | 24 | def self.cache_key 25 | "#{key_prefix}#{access_token_key}" 26 | end 27 | 28 | # Translates and saves it into a PostTranslation/TopicTranslation 29 | # If the detected language is the same as the target language, the original text will be returned. 30 | # @param translatable [Post|Topic] 31 | # @return [Array] the detected language and the translated text 32 | def self.translate(translatable, target_locale_sym = I18n.locale) 33 | detected_lang = detect(translatable) 34 | 35 | if translatable.locale_matches?(target_locale_sym) 36 | return detected_lang, get_untranslated(translatable) 37 | end 38 | 39 | translation = translatable.translation_for(target_locale_sym) 40 | return detected_lang, translation if translation.present? 41 | 42 | unless translate_supported?(detected_lang, target_locale_sym) 43 | raise TranslatorError.new( 44 | I18n.t( 45 | "translator.failed.#{translatable.class.name.downcase}", 46 | source_locale: detected_lang, 47 | target_locale: target_locale_sym, 48 | ), 49 | ) 50 | end 51 | 52 | begin 53 | begin 54 | translated = 55 | case translatable.class.name 56 | when "Post" 57 | translate_post!(translatable, target_locale_sym, { cooked: true }) 58 | when "Topic" 59 | translate_topic!(translatable, target_locale_sym) 60 | end 61 | end 62 | rescue => e 63 | raise I18n.t( 64 | "translator.failed.#{translatable.class.name.downcase}", 65 | source_locale: detected_lang, 66 | target_locale: target_locale_sym, 67 | ) 68 | end 69 | 70 | translatable.set_translation(target_locale_sym, translated) 71 | [detected_lang, translated] 72 | end 73 | 74 | def self.translate_text!(text, target_locale_sym = I18n.locale) 75 | raise "Not Implemented" 76 | end 77 | 78 | def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) 79 | raise "Not Implemented" 80 | end 81 | 82 | def self.translate_topic!(topic, target_locale_sym = I18n.locale) 83 | raise "Not Implemented" 84 | end 85 | 86 | # Returns the stored detected locale of a post or topic. 87 | # If the locale does not exist yet, it will be detected first via the API then stored. 88 | # @param translatable [Post|Topic] 89 | def self.detect(translatable) 90 | return if text_for_detection(translatable).blank? 91 | translatable.detected_locale || translatable.set_detected_locale(detect!(translatable)) 92 | end 93 | 94 | # Subclasses must implement this method to detect the text of a post or topic 95 | # and return only the detected locale. 96 | # Subclasses should use text_for_detection 97 | # @param translatable [Post|Topic] 98 | # @return [String] 99 | def self.detect!(translatable) 100 | raise "Not Implemented" 101 | end 102 | 103 | def self.access_token 104 | raise "Not Implemented" 105 | end 106 | 107 | def self.language_supported?(detected_lang) 108 | raise NotImplementedError unless self.const_defined?(:SUPPORTED_LANG_MAPPING) 109 | supported_lang = const_get(:SUPPORTED_LANG_MAPPING) 110 | return false if supported_lang[I18n.locale].nil? 111 | detected_lang != supported_lang[I18n.locale] 112 | end 113 | 114 | def self.translate_supported?(detected_lang, target_lang) 115 | true 116 | end 117 | 118 | private 119 | 120 | def self.text_for_detection(translatable) 121 | text = get_untranslated(translatable, raw: true) 122 | 123 | if translatable.class.name == "Topic" 124 | # due to topics having short titles, 125 | # we need to add the first post to the detection text 126 | first_post = get_untranslated(translatable.first_post, raw: true) 127 | text = text + " " + first_post if first_post 128 | end 129 | 130 | text.truncate(DETECTION_CHAR_LIMIT, omission: nil) 131 | end 132 | 133 | def self.text_for_translation(translatable, raw: false) 134 | max_char = SiteSetting.max_characters_per_translation 135 | get_untranslated(translatable, raw:).truncate(max_char, omission: nil) 136 | end 137 | 138 | def self.get_untranslated(translatable, raw: false) 139 | case translatable.class.name 140 | when "Post" 141 | raw ? translatable.raw : translatable.cooked 142 | when "Topic" 143 | translatable.title 144 | end 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/google.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | class Google < BaseProvider 6 | TRANSLATE_URI = "https://www.googleapis.com/language/translate/v2".freeze 7 | DETECT_URI = "https://www.googleapis.com/language/translate/v2/detect".freeze 8 | SUPPORT_URI = "https://www.googleapis.com/language/translate/v2/languages".freeze 9 | 10 | # Hash which maps Discourse's locale code to Google Translate's locale code found in 11 | # https://cloud.google.com/translate/docs/languages 12 | SUPPORTED_LANG_MAPPING = { 13 | en: "en", 14 | en_GB: "en", 15 | en_US: "en", 16 | ar: "ar", 17 | bg: "bg", 18 | bs_BA: "bs", 19 | ca: "ca", 20 | cs: "cs", 21 | da: "da", 22 | de: "de", 23 | el: "el", 24 | es: "es", 25 | et: "et", 26 | fi: "fi", 27 | fr: "fr", 28 | he: "iw", 29 | hi: "hi", 30 | hr: "hr", 31 | hu: "hu", 32 | hy: "hy", 33 | id: "id", 34 | it: "it", 35 | ja: "ja", 36 | ka: "ka", 37 | kk: "kk", 38 | ko: "ko", 39 | ky: "ky", 40 | lv: "lv", 41 | mk: "mk", 42 | nl: "nl", 43 | pt: "pt", 44 | ro: "ro", 45 | ru: "ru", 46 | sk: "sk", 47 | sl: "sl", 48 | sq: "sq", 49 | sr: "sr", 50 | sv: "sv", 51 | tg: "tg", 52 | te: "te", 53 | th: "th", 54 | uk: "uk", 55 | uz: "uz", 56 | zh_CN: "zh-CN", 57 | zh_TW: "zh-TW", 58 | tr_TR: "tr", 59 | pt_BR: "pt", 60 | pl_PL: "pl", 61 | no_NO: "no", 62 | nb_NO: "no", 63 | fa_IR: "fa", 64 | } 65 | CHINESE_LOCALE = "zh" 66 | 67 | def self.access_token_key 68 | "google-translator" 69 | end 70 | 71 | def self.access_token 72 | if SiteSetting.translator_google_api_key.present? 73 | return SiteSetting.translator_google_api_key 74 | end 75 | raise ProblemCheckedTranslationError.new("NotFound: Google Api Key not set.") 76 | end 77 | 78 | def self.detect!(topic_or_post) 79 | result(DETECT_URI, q: text_for_detection(topic_or_post))["detections"][0].max do |a, b| 80 | a.confidence <=> b.confidence 81 | end[ 82 | "language" 83 | ] 84 | end 85 | 86 | def self.translate_supported?(source, target) 87 | res = result(SUPPORT_URI, target: SUPPORTED_LANG_MAPPING[target]) 88 | supported = res["languages"].any? { |obj| obj["language"] == source } 89 | return true if supported 90 | 91 | normalized_source = source.split("-").first 92 | if (source.include?("-") && normalized_source != CHINESE_LOCALE) 93 | res["languages"].any? { |obj| obj["language"] == normalized_source } 94 | else 95 | false 96 | end 97 | end 98 | 99 | def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) 100 | raw = opts.key?(:raw) ? opts[:raw] : !opts[:cooked] 101 | text = text_for_translation(post, raw:) 102 | translate_text!(text, target_locale_sym) 103 | end 104 | 105 | def self.translate_topic!(topic, target_locale_sym = I18n.locale) 106 | text = text_for_translation(topic) 107 | translate_text!(text, target_locale_sym) 108 | end 109 | 110 | def self.translate_text!(text, target_locale_sym = I18n.locale) 111 | res = result(TRANSLATE_URI, q: text, target: SUPPORTED_LANG_MAPPING[target_locale_sym]) 112 | res["translations"][0]["translatedText"] 113 | end 114 | 115 | def self.result(url, body) 116 | body[:key] = access_token 117 | 118 | response = 119 | Excon.post( 120 | url, 121 | body: URI.encode_www_form(body), 122 | headers: { 123 | "Content-Type" => "application/x-www-form-urlencoded", 124 | "Referer" => Discourse.base_url, 125 | }, 126 | ) 127 | 128 | body = nil 129 | begin 130 | body = JSON.parse(response.body) 131 | rescue JSON::ParserError 132 | end 133 | 134 | if response.status != 200 135 | if body && body["error"] 136 | ProblemCheckTracker[:translator_error].problem!( 137 | details: { 138 | provider: "Google", 139 | code: body["error"]["code"], 140 | message: body["error"]["message"], 141 | }, 142 | ) 143 | raise ProblemCheckedTranslationError.new(body["error"]["message"]) 144 | else 145 | raise TranslatorError.new(response.inspect) 146 | end 147 | else 148 | ProblemCheckTracker[:translator_error].no_problem! 149 | body["data"] 150 | end 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/libre_translate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | class LibreTranslate < BaseProvider 6 | SUPPORTED_LANG_MAPPING = { 7 | en: "en", 8 | en_GB: "en", 9 | en_US: "en", 10 | ar: "ar", 11 | bg: "bg", 12 | bs_BA: "bs", 13 | ca: "ca", 14 | cs: "cs", 15 | da: "da", 16 | de: "de", 17 | el: "el", 18 | es: "es", 19 | et: "et", 20 | fi: "fi", 21 | fr: "fr", 22 | he: "iw", 23 | hr: "hr", 24 | hu: "hu", 25 | hy: "hy", 26 | id: "id", 27 | it: "it", 28 | ja: "ja", 29 | ka: "ka", 30 | kk: "kk", 31 | ko: "ko", 32 | ky: "ky", 33 | lv: "lv", 34 | mk: "mk", 35 | nl: "nl", 36 | pt: "pt", 37 | ro: "ro", 38 | ru: "ru", 39 | sk: "sk", 40 | sl: "sl", 41 | sq: "sq", 42 | sr: "sr", 43 | sv: "sv", 44 | tg: "tg", 45 | te: "te", 46 | th: "th", 47 | uk: "uk", 48 | uz: "uz", 49 | zh_CN: "zh", 50 | zh_TW: "zh", 51 | tr_TR: "tr", 52 | pt_BR: "pt", 53 | pl_PL: "pl", 54 | no_NO: "no", 55 | nb_NO: "no", 56 | fa_IR: "fa", 57 | } 58 | 59 | def self.translate_uri 60 | SiteSetting.translator_libretranslate_endpoint + "/translate" 61 | end 62 | 63 | def self.detect_uri 64 | SiteSetting.translator_libretranslate_endpoint + "/detect" 65 | end 66 | 67 | def self.support_uri 68 | SiteSetting.translator_libretranslate_endpoint + "/languages" 69 | end 70 | 71 | def self.access_token_key 72 | "libretranslate-translator" 73 | end 74 | 75 | def self.access_token 76 | SiteSetting.translator_libretranslate_api_key 77 | end 78 | 79 | def self.detect!(topic_or_post) 80 | res = 81 | result( 82 | detect_uri, 83 | q: ActionController::Base.helpers.strip_tags(text_for_detection(topic_or_post)), 84 | ) 85 | !res.empty? ? res[0]["language"] : "en" 86 | end 87 | 88 | def self.translate_supported?(source, target) 89 | lang = SUPPORTED_LANG_MAPPING[target] 90 | res = get(support_uri) 91 | res.any? { |obj| obj["code"] == source } && res.any? { |obj| obj["code"] == lang } 92 | end 93 | 94 | def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) 95 | raw = opts.key?(:raw) ? opts[:raw] : !opts[:cooked] 96 | text = text_for_translation(post, raw:) 97 | 98 | detected_lang = detect(post) 99 | 100 | send_for_translation(text, detected_lang, target_locale_sym) 101 | end 102 | 103 | def self.translate_topic!(topic, target_locale_sym = I18n.locale) 104 | detected_lang = detect(topic) 105 | text = text_for_translation(topic) 106 | send_for_translation(text, detected_lang, target_locale_sym) 107 | end 108 | 109 | def self.translate_text!(text, target_locale_sym = I18n.locale) 110 | # Unsupported - see https://libretranslate.com/docs/#/translate/post_translate 111 | # requires a source language 112 | raise TranslatorError.new(I18n.t("translator.not_supported")) 113 | end 114 | 115 | def self.get(url) 116 | begin 117 | response = Excon.get(url) 118 | body = JSON.parse(response.body) 119 | status = response.status 120 | rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout 121 | body = I18n.t("translator.not_available") 122 | status = 500 123 | end 124 | 125 | if status != 200 126 | raise TranslatorError.new(body || response.inspect) 127 | else 128 | body 129 | end 130 | end 131 | 132 | def self.result(url, body) 133 | begin 134 | body[:api_key] = access_token 135 | 136 | response = 137 | Excon.post( 138 | url, 139 | body: URI.encode_www_form(body), 140 | headers: { 141 | "Content-Type" => "application/x-www-form-urlencoded", 142 | }, 143 | ) 144 | 145 | body = JSON.parse(response.body) 146 | status = response.status 147 | rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout 148 | body = I18n.t("translator.not_available") 149 | status = 500 150 | end 151 | 152 | if status != 200 153 | raise TranslatorError.new(body || response.inspect) 154 | else 155 | body 156 | end 157 | end 158 | 159 | private 160 | 161 | def self.send_for_translation(text, source_locale, target_locale) 162 | res = 163 | result( 164 | translate_uri, 165 | q: text, 166 | source: source_locale, 167 | target: SUPPORTED_LANG_MAPPING[target_locale], 168 | format: "html", 169 | ) 170 | res["translatedText"] 171 | end 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/translator_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | class TranslatorProvider 6 | def self.get 7 | "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/services/discourse_translator/provider/yandex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Provider 5 | class Yandex < BaseProvider 6 | TRANSLATE_URI = "https://translate.yandex.net/api/v1.5/tr.json/translate" 7 | DETECT_URI = "https://translate.yandex.net/api/v1.5/tr.json/detect" 8 | 9 | # Hash which maps Discourse's locale code to Yandex Translate's language code found in 10 | # https://yandex.com/dev/translate/doc/dg/concepts/api-overview.html 11 | SUPPORTED_LANG_MAPPING = { 12 | pt_BR: "pt", 13 | pl_PL: "pl", 14 | no_NO: "no", 15 | fa_IR: "fa", 16 | zh_CN: "zh", 17 | zh_TW: "zh", 18 | tr_TR: "tr", 19 | en: "en", 20 | en_US: "en", 21 | en_GB: "en", 22 | az: "az", 23 | ml: "ml", 24 | sq: "sq", 25 | mt: "mt", 26 | am: "am", 27 | mk: "mk", 28 | mi: "mi", 29 | ar: "ar", 30 | mr: "mr", 31 | hy: "hy", 32 | mhr: "mhr", 33 | af: "af", 34 | mn: "mn", 35 | eu: "eu", 36 | de: "de", 37 | ba: "ba", 38 | ne: "ne", 39 | be: "be", 40 | no: "no", 41 | bn: "bn", 42 | pa: "pa", 43 | my: "my", 44 | pap: "pap", 45 | bg: "bg", 46 | fa: "fa", 47 | bs: "bs", 48 | pl: "pl", 49 | cy: "cy", 50 | pt: "pt", 51 | hu: "hu", 52 | ro: "ro", 53 | vi: "vi", 54 | ru: "ru", 55 | ht: "ht", 56 | ceb: "ceb", 57 | gl: "gl", 58 | sr: "sr", 59 | nl: "nl", 60 | si: "si", 61 | mrj: "mrj", 62 | sk: "sk", 63 | el: "el", 64 | sl: "sl", 65 | ka: "ka", 66 | sw: "sw", 67 | gu: "gu", 68 | su: "su", 69 | da: "da", 70 | tg: "tg", 71 | he: "he", 72 | th: "th", 73 | yi: "yi", 74 | tl: "tl", 75 | id: "id", 76 | ta: "ta", 77 | ga: "ga", 78 | tt: "tt", 79 | it: "it", 80 | te: "te", 81 | is: "is", 82 | tr: "tr", 83 | es: "es", 84 | udm: "udm", 85 | kk: "kk", 86 | uz: "uz", 87 | kn: "kn", 88 | uk: "uk", 89 | ca: "ca", 90 | ur: "ur", 91 | ky: "ky", 92 | fi: "fi", 93 | zh: "zh", 94 | fr: "fr", 95 | ko: "ko", 96 | hi: "hi", 97 | xh: "xh", 98 | hr: "hr", 99 | km: "km", 100 | cs: "cs", 101 | lo: "lo", 102 | sv: "sv", 103 | la: "la", 104 | gd: "gd", 105 | lv: "lv", 106 | et: "et", 107 | lt: "lt", 108 | eo: "eo", 109 | lb: "lb", 110 | jv: "jv", 111 | mg: "mg", 112 | ja: "ja", 113 | ms: "ms", 114 | } 115 | 116 | def self.access_token_key 117 | "yandex-translator" 118 | end 119 | 120 | def self.access_token 121 | SiteSetting.translator_yandex_api_key || 122 | (raise TranslatorError.new("NotFound: Yandex API Key not set.")) 123 | end 124 | 125 | def self.detect!(topic_or_post) 126 | query = default_query.merge("text" => text_for_detection(topic_or_post)) 127 | uri = URI(DETECT_URI) 128 | uri.query = URI.encode_www_form(query) 129 | result(uri.to_s, "", default_headers)["lang"] 130 | end 131 | 132 | def self.translate_post!(post, target_locale_sym = I18n.locale, opts = {}) 133 | raw = opts.key?(:raw) ? opts[:raw] : !opts[:cooked] 134 | text = text_for_translation(post, raw:) 135 | 136 | detected_lang = detect(post) 137 | locale = SUPPORTED_LANG_MAPPING[target_locale_sym] 138 | 139 | send_for_translation(text, detected_lang, locale) 140 | end 141 | 142 | def self.translate_topic!(topic, target_locale_sym = I18n.locale) 143 | detected_lang = detect(topic) 144 | locale = SUPPORTED_LANG_MAPPING[target_locale_sym] 145 | text = text_for_translation(topic) 146 | 147 | send_for_translation(text, detected_lang, locale) 148 | end 149 | 150 | def self.translate_text!(text, target_locale_sym = I18n.locale) 151 | # Not supported for v1.5 https://translate.yandex.com/developers 152 | raise TranslatorError.new(I18n.t("translator.not_supported")) 153 | end 154 | 155 | def self.translate_supported?(detected_lang, target_lang) 156 | SUPPORTED_LANG_MAPPING.keys.include?(detected_lang.to_sym) && 157 | SUPPORTED_LANG_MAPPING.values.include?(detected_lang.to_s) 158 | end 159 | 160 | private 161 | 162 | def self.send_for_translation(text, source_locale, target_locale) 163 | query = 164 | default_query.merge( 165 | "lang" => "#{source_locale}-#{target_locale}", 166 | "text" => text, 167 | "format" => "html", 168 | ) 169 | 170 | uri = URI(TRANSLATE_URI) 171 | uri.query = URI.encode_www_form(query) 172 | 173 | response_body = result(uri.to_s, "", default_headers) 174 | response_body["text"][0] 175 | end 176 | 177 | def self.post(uri, body, headers = {}) 178 | Excon.post(uri, body: body, headers: headers) 179 | end 180 | 181 | def self.result(uri, body, headers) 182 | response = post(uri, body, headers) 183 | response_body = JSON.parse(response.body) 184 | 185 | if response.status != 200 186 | raise TranslatorError.new(response_body) 187 | else 188 | response_body 189 | end 190 | end 191 | 192 | def self.default_headers 193 | { "Content-Type" => "application/x-www-form-urlencoded" } 194 | end 195 | 196 | def self.default_query 197 | { key: access_token } 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /app/services/problem_check/missing_translator_api_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProblemCheck::MissingTranslatorApiKey < ProblemCheck 4 | self.priority = "high" 5 | 6 | def call 7 | return no_problem unless SiteSetting.translator_enabled 8 | name = api_key_site_setting_name 9 | return no_problem if name.nil? 10 | return no_problem if SiteSetting.get(name).present? 11 | 12 | problem 13 | end 14 | 15 | private 16 | 17 | def translation_data 18 | { 19 | provider: SiteSetting.translator_provider, 20 | key: I18n.t("site_settings.#{api_key_site_setting_name}"), 21 | key_name: api_key_site_setting_name, 22 | } 23 | end 24 | 25 | def api_key_site_setting_name 26 | case SiteSetting.translator_provider 27 | when "Google" 28 | "translator_google_api_key" 29 | when "Microsoft" 30 | "translator_azure_subscription_key" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/problem_check/translator_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProblemCheck::TranslatorError < ProblemCheck::InlineProblemCheck 4 | self.priority = "high" 5 | end 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/post-menu/toggle-translation-button.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { action } from "@ember/object"; 3 | import { service } from "@ember/service"; 4 | import DButton from "discourse/components/d-button"; 5 | import concatClass from "discourse/helpers/concat-class"; 6 | import { popupAjaxError } from "discourse/lib/ajax-error"; 7 | 8 | export default class ToggleTranslationButton extends Component { 9 | static shouldRender(args) { 10 | return args.post.can_translate; 11 | } 12 | 13 | @service modal; 14 | @service translator; 15 | 16 | get isTranslating() { 17 | return this.args.post.isTranslating; 18 | } 19 | 20 | get isTranslated() { 21 | return this.args.post.isTranslated; 22 | } 23 | 24 | get showButton() { 25 | return this.args.post.can_translate; 26 | } 27 | 28 | get title() { 29 | if (this.isTranslating) { 30 | return "translator.translating"; 31 | } 32 | 33 | return this.isTranslated 34 | ? "translator.hide_translation" 35 | : "translator.view_translation"; 36 | } 37 | 38 | @action 39 | hideTranslation() { 40 | this.args.post.isTranslated = false; 41 | this.args.post.isTranslating = false; 42 | this.translator.clearPostTranslation(this.args.post); 43 | } 44 | 45 | @action 46 | toggleTranslation() { 47 | return this.args.post.isTranslated 48 | ? this.hideTranslation() 49 | : this.translate(); 50 | } 51 | 52 | @action 53 | async translate() { 54 | const post = this.args.post; 55 | post.isTranslating = true; 56 | 57 | try { 58 | await this.translator.translatePost(post); 59 | post.isTranslated = true; 60 | } catch (error) { 61 | this.translator.clearPostTranslation(this.args.post); 62 | post.isTranslated = false; 63 | popupAjaxError(error); 64 | } finally { 65 | post.isTranslating = false; 66 | } 67 | } 68 | 69 | 85 | } 86 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/translated-post-indicator.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { i18n } from "discourse-i18n"; 3 | import DTooltip from "float-kit/components/d-tooltip"; 4 | 5 | export default class TranslatedPostIndicator extends Component { 6 | get tooltip() { 7 | return i18n("translator.originally_written_in", { 8 | language: this.args.data.detectedLanguage, 9 | }); 10 | } 11 | 12 | 19 | } 20 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/translated-post.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { service } from "@ember/service"; 3 | import { htmlSafe } from "@ember/template"; 4 | import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; 5 | import { i18n } from "discourse-i18n"; 6 | 7 | export default class TranslatedPost extends Component { 8 | static shouldRender(args) { 9 | return args.post.isTranslated || args.post.isTranslating; 10 | } 11 | 12 | @service siteSettings; 13 | 14 | get loading() { 15 | return this.post.isTranslating; 16 | } 17 | 18 | get isTranslated() { 19 | return this.post.isTranslated; 20 | } 21 | 22 | get post() { 23 | return this.args.outletArgs.post; 24 | } 25 | 26 | get translatedText() { 27 | return this.post.translatedText; 28 | } 29 | 30 | get translatedTitle() { 31 | return this.post.translatedTitle; 32 | } 33 | 34 | 60 | } 61 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/extend-for-translate-button.js: -------------------------------------------------------------------------------- 1 | import { withPluginApi } from "discourse/lib/plugin-api"; 2 | import ToggleTranslationButton from "../components/post-menu/toggle-translation-button"; 3 | import TranslatedPost from "../components/translated-post"; 4 | 5 | function initializeTranslation(api) { 6 | const siteSettings = api.container.lookup("service:site-settings"); 7 | if (!siteSettings.translator_enabled) { 8 | return; 9 | } 10 | 11 | customizePostMenu(api); 12 | } 13 | 14 | function customizePostMenu(api) { 15 | api.registerValueTransformer( 16 | "post-menu-buttons", 17 | ({ value: dag, context: { firstButtonKey } }) => { 18 | dag.add("translate", ToggleTranslationButton, { before: firstButtonKey }); 19 | } 20 | ); 21 | 22 | // the plugin outlet is not updated when the post instance is modified unless we register the new properties as 23 | // tracked 24 | api.addTrackedPostProperties( 25 | "detectedLang", 26 | "isTranslating", 27 | "isTranslated", 28 | "translatedText", 29 | "translatedTitle" 30 | ); 31 | 32 | api.renderBeforeWrapperOutlet("post-menu", TranslatedPost); 33 | } 34 | 35 | export default { 36 | name: "extend-for-translate-button", 37 | initialize() { 38 | withPluginApi("1.39.2", (api) => initializeTranslation(api)); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/services/translator.js: -------------------------------------------------------------------------------- 1 | import Service, { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | 4 | export default class TranslatorService extends Service { 5 | @service siteSettings; 6 | @service appEvents; 7 | @service documentTitle; 8 | 9 | async translatePost(post) { 10 | const response = await ajax("/translator/translate", { 11 | type: "POST", 12 | data: { post_id: post.id }, 13 | }); 14 | 15 | post.detectedLang = response.detected_lang; 16 | post.translatedText = response.translation; 17 | post.translatedTitle = response.title_translation; 18 | } 19 | 20 | clearPostTranslation(post) { 21 | post.detectedLang = null; 22 | post.translatedText = null; 23 | post.translatedTitle = null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/stylesheets/common/common.scss: -------------------------------------------------------------------------------- 1 | [data-identifier="discourse-translator_language-switcher"] 2 | .fk-d-menu__inner-content { 3 | max-height: 50vh; 4 | } 5 | 6 | .topic-navigation.with-timeline .discourse-translator_toggle-original { 7 | margin-bottom: 0.5em; 8 | } 9 | 10 | .topic-navigation.with-topic-progress .discourse-translator_toggle-original { 11 | margin-right: 0.5em; 12 | align-self: stretch; 13 | 14 | button { 15 | height: 100%; 16 | width: 100%; 17 | } 18 | } 19 | 20 | .discourse-translator_toggle-original { 21 | button.active svg { 22 | color: var(--tertiary); 23 | } 24 | } 25 | 26 | .post-info .post-info.post-translated-indicator { 27 | display: inline; 28 | 29 | svg { 30 | color: var(--primary-med-or-secondary-med); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/stylesheets/common/post.scss: -------------------------------------------------------------------------------- 1 | .topic-attribution { 2 | font-weight: bold; 3 | margin-bottom: 1em; 4 | margin-top: 1em; 5 | } 6 | 7 | .post-attribution { 8 | color: #8899a6; 9 | font-size: 12px; 10 | } 11 | 12 | button.translated { 13 | color: var(--tertiary) !important; 14 | } 15 | -------------------------------------------------------------------------------- /config/locales/client.ar.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ar: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "مترجم Discourse" 13 | js: 14 | translator: 15 | view_translation: "عرض الترجمة" 16 | hide_translation: "إخفاء الترجمة" 17 | translated_from: "تمت الترجمة من %{language} بواسطة %{translator}" 18 | translating: "جارٍ الترجمة" 19 | -------------------------------------------------------------------------------- /config/locales/client.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | -------------------------------------------------------------------------------- /config/locales/client.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | -------------------------------------------------------------------------------- /config/locales/client.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | -------------------------------------------------------------------------------- /config/locales/client.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | -------------------------------------------------------------------------------- /config/locales/client.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | js: 9 | translator: 10 | view_translation: "Zobrazit překlad" 11 | hide_translation: "Zobrazit překlad" 12 | -------------------------------------------------------------------------------- /config/locales/client.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | -------------------------------------------------------------------------------- /config/locales/client.de.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | de: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse – Übersetzer" 13 | js: 14 | translator: 15 | view_translation: "Übersetzung anzeigen" 16 | hide_translation: "Übersetzung ausblenden" 17 | content_not_translated: "Seite nicht übersetzt. Klicke zum Übersetzen" 18 | content_translated: "Die Seite wurde maschinell übersetzt. Klicke, um das Original zu sehen" 19 | translated_from: "Übersetzt aus %{language} von %{translator}" 20 | translating: "Wird übersetzt" 21 | originally_written_in: "Dieser Beitrag wurde ursprünglich auf %{language} geschrieben." 22 | -------------------------------------------------------------------------------- /config/locales/client.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Μεταφραστής Discourse" 13 | js: 14 | translator: 15 | view_translation: "Προβολή μετάφρασης" 16 | hide_translation: "Απόκρυψη μετάφρασης" 17 | translated_from: "Μεταφρασμένο από %{language} από τον/την %{translator}" 18 | -------------------------------------------------------------------------------- /config/locales/client.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | admin_js: 3 | admin: 4 | site_settings: 5 | categories: 6 | discourse_translator: "Discourse Translator" 7 | js: 8 | translator: 9 | view_translation: "View translation" 10 | hide_translation: "Hide translation" 11 | content_not_translated: "Page not translated. Click to translate" 12 | content_translated: "Page is machine-translated. Click to view original" 13 | translated_from: "Translated from %{language} by %{translator}" 14 | translating: "Translating" 15 | originally_written_in: "This post was originally written in %{language}" 16 | -------------------------------------------------------------------------------- /config/locales/client.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/client.es.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | es: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Traductor de Discourse" 13 | js: 14 | translator: 15 | view_translation: "Ver traducción" 16 | hide_translation: "Ocultar traducción" 17 | translated_from: "Traducido del %{language} por %{translator}" 18 | translating: "Traduciendo" 19 | -------------------------------------------------------------------------------- /config/locales/client.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | -------------------------------------------------------------------------------- /config/locales/client.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "مترجم دیسکورس" 13 | js: 14 | translator: 15 | view_translation: "مشاهده ترجمه" 16 | hide_translation: "پنهان کردن ترجمه" 17 | translated_from: "ترجمه شده از %{language} توسط %{translator}" 18 | translating: "در حال ترجمه" 19 | -------------------------------------------------------------------------------- /config/locales/client.fi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fi: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse-kääntäjä" 13 | js: 14 | translator: 15 | view_translation: "Näytä käännös" 16 | hide_translation: "Piilota käännös" 17 | translated_from: "%{translator} käänsi kielestä %{language}" 18 | translating: "Kääntäminen" 19 | -------------------------------------------------------------------------------- /config/locales/client.fr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fr: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Traducteur de Discourse" 13 | js: 14 | translator: 15 | view_translation: "Voir la traduction" 16 | hide_translation: "Masquer la traduction" 17 | translated_from: "Traduit de la langue originale (%{language}) par %{translator}" 18 | translating: "Traduction en cours" 19 | -------------------------------------------------------------------------------- /config/locales/client.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | -------------------------------------------------------------------------------- /config/locales/client.he.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | he: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse מתרגם" 13 | js: 14 | translator: 15 | view_translation: "הצגת התרגום" 16 | hide_translation: "הסתרת התרגום" 17 | content_not_translated: "העמוד לא מתורגם. לחיצה תתרגם אותו" 18 | content_translated: "העמוד תורגם אוטומטית. לחיצה תציג את המקור" 19 | translated_from: "תורגם מ%{language} ע״י %{translator}" 20 | translating: "תרגום" 21 | originally_written_in: "הפוסט הזה נכתב במקור ב%{language}" 22 | -------------------------------------------------------------------------------- /config/locales/client.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | -------------------------------------------------------------------------------- /config/locales/client.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | js: 9 | translator: 10 | view_translation: "Fordítás megtekintése" 11 | hide_translation: "Fordítás elrejtése" 12 | translated_from: "%{language} → %{translator} fordítás" 13 | -------------------------------------------------------------------------------- /config/locales/client.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | -------------------------------------------------------------------------------- /config/locales/client.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | -------------------------------------------------------------------------------- /config/locales/client.it.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | it: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Traduttore Discourse" 13 | js: 14 | translator: 15 | view_translation: "Visualizza traduzione" 16 | hide_translation: "Nascondi traduzione" 17 | translated_from: "Tradotto dalla lingua %{language} da %{translator}" 18 | translating: "Traduzione in corso" 19 | -------------------------------------------------------------------------------- /config/locales/client.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse 翻訳ツール" 13 | js: 14 | translator: 15 | view_translation: "翻訳を表示" 16 | hide_translation: "翻訳を非表示" 17 | translated_from: "%{translator} で%{language}から翻訳" 18 | translating: "翻訳中" 19 | -------------------------------------------------------------------------------- /config/locales/client.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | -------------------------------------------------------------------------------- /config/locales/client.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | -------------------------------------------------------------------------------- /config/locales/client.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | -------------------------------------------------------------------------------- /config/locales/client.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | -------------------------------------------------------------------------------- /config/locales/client.nl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nl: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse-vertaler" 13 | js: 14 | translator: 15 | view_translation: "Vertaling weergeven" 16 | hide_translation: "Vertaling verbergen" 17 | translated_from: "Vertaald uit het %{language} door %{translator}" 18 | translating: "Vertalen" 19 | -------------------------------------------------------------------------------- /config/locales/client.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | js: 9 | translator: 10 | view_translation: "Zobacz tłumaczenie" 11 | hide_translation: "Ukryj tłumaczenie" 12 | translated_from: "Przetłumaczone z %{language} przez %{translator}" 13 | -------------------------------------------------------------------------------- /config/locales/client.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | -------------------------------------------------------------------------------- /config/locales/client.pt_BR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt_BR: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Tradutor do Discourse" 13 | js: 14 | translator: 15 | view_translation: "Exibir tradução" 16 | hide_translation: "Ocultar tradução" 17 | translated_from: "Tradução de %{language} feita por %{translator}" 18 | translating: "Traduzindo" 19 | -------------------------------------------------------------------------------- /config/locales/client.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse Translator" 13 | js: 14 | translator: 15 | view_translation: "Vezi traducere" 16 | hide_translation: "Ascunde traducere" 17 | translated_from: "Tradus din %{language} cu %{translator}" 18 | translating: "Se traduce" 19 | originally_written_in: "Această postare a fost scrisă inițial în %{language}" 20 | -------------------------------------------------------------------------------- /config/locales/client.ru.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ru: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Плагин Discourse «Переводчик»" 13 | js: 14 | translator: 15 | view_translation: "Показать перевод" 16 | hide_translation: "Скрыть перевод" 17 | translated_from: "Перевод; переводчик — %{translator}, исходный язык — %{language}" 18 | translating: "Переводится" 19 | -------------------------------------------------------------------------------- /config/locales/client.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | -------------------------------------------------------------------------------- /config/locales/client.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | -------------------------------------------------------------------------------- /config/locales/client.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | -------------------------------------------------------------------------------- /config/locales/client.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | -------------------------------------------------------------------------------- /config/locales/client.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | js: 9 | translator: 10 | view_translation: "Visa översättning" 11 | hide_translation: "Dölj översättning" 12 | translated_from: "Översatt från %{language} av %{translator}" 13 | -------------------------------------------------------------------------------- /config/locales/client.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | -------------------------------------------------------------------------------- /config/locales/client.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | -------------------------------------------------------------------------------- /config/locales/client.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | -------------------------------------------------------------------------------- /config/locales/client.tr_TR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | tr_TR: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse Çevirmen" 13 | js: 14 | translator: 15 | view_translation: "Çeviriyi görüntüle" 16 | hide_translation: "Çeviriyi gizle" 17 | translated_from: "%{language} dilinden %{translator} tarafından çevrilmiştir." 18 | translating: "Çevriliyor" 19 | -------------------------------------------------------------------------------- /config/locales/client.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | -------------------------------------------------------------------------------- /config/locales/client.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | -------------------------------------------------------------------------------- /config/locales/client.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | -------------------------------------------------------------------------------- /config/locales/client.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | -------------------------------------------------------------------------------- /config/locales/client.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_translator: "Discourse Translator" 13 | js: 14 | translator: 15 | view_translation: "查看翻译" 16 | hide_translation: "隐藏翻译" 17 | translated_from: "由 %{translator} 译自%{language}" 18 | translating: "正在翻译" 19 | -------------------------------------------------------------------------------- /config/locales/client.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | -------------------------------------------------------------------------------- /config/locales/server.ar.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ar: 8 | site_settings: 9 | translator_provider: "مقدِّم خدمة الترجمة." 10 | translator_azure_subscription_key: "مفتاح اشتراك Azure" 11 | translator_azure_region: "منطقة Azure" 12 | translator_google_api_key: "مفتاح واجهة برمجة تطبيقات Google" 13 | translator_yandex_api_key: "مفتاح واجهة برمجة تطبيقات Yandex" 14 | max_characters_per_translation: "الحد الأقصى لعدد الأحرف التي يمكن إرسالها للترجمة. إذا كان المحتوى أطول من هذا، فسيتم اقتطاع النص. لاحظ أن كل مقدِّم خدمة لديه حدوده الخاصة أيضًا." 15 | max_translations_per_minute: "عدد الترجمات التي يمكن للمستخدم المنتظم إجراؤها في الدقيقة" 16 | translator_libretranslate_endpoint: "نقطة نهاية واجهة برمجة التطبيقات لـ LibreTranslate" 17 | translator_libretranslate_api_key: "مفتاح واجهة برمجة التطبيقات لـ LibreTranslate" 18 | translator_aws_region: "منطقة AWS" 19 | translator_aws_key_id: "معرِّف مفتاح AWS" 20 | translator_aws_secret_access: "مفتاح الوصول السري لـ AWS" 21 | translator_aws_iam_role: "دور واجهة الهوية والوصول لـ AWS" 22 | translator_azure_custom_subdomain: "مطلوب في حال استخدام شبكة افتراضية أو جدار حماية لـ Azure Cognitive Services. ملاحظة: أدخل النطاق الفرعي المخصَّص فقط وليس نقطة النهاية المخصَّصة بالكامل." 23 | restrict_translation_by_group: "لا يمكن الترجمة إلا للمجموعات المُدرَجة في قائمة السماح" 24 | restrict_translation_by_poster_group: "السماح بترجمة المنشورات التي يُنشئها المستخدمون في المجموعات المسموح بها فقط. إذا كانت فارغة، فاسمح بترجمة المنشورات من جميع المستخدمين." 25 | translator: 26 | not_supported: "لا يدعم المترجم هذه اللغة." 27 | too_long: "هذا المنشور طويل جدًا ولا يستطيع المترجم ترجمته." 28 | not_available: "خدمة المترجم غير متوفرة حاليًا." 29 | amazon: 30 | invalid_credentials: "بيانات الاعتماد المقدَّمة لترجمة AWS غير صالحة." 31 | microsoft: 32 | missing_token: "لم يتمكَّن المترجم من استرداد رمز مميَّز صالح." 33 | missing_key: "لم يتم توفير مفتاح اشتراك Azure." 34 | discourse_ai: 35 | not_installed: "يجب عليك تثبيت المكوِّن الإضافي discourse-ai لاستخدام هذه الميزة." 36 | ai_helper_required: 'يتعيَّن عليك تكوين مساعد الذكاء الاصطناعي لاستخدام هذه الميزة.' 37 | not_in_group: 38 | user_not_in_group: "أنت لا تنتمي إلى مجموعة مسموح لها بالترجمة." 39 | poster_not_in_group: "لم يتم إنشاء المنشور بواسطة مستخدم في مجموعة مسموح بها." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "تم استخدام %{provider} كمترجم، ولكن لم يتم تقديم مفتاح %{key}. انظر إعدادات الموقع." 43 | translator_error: "أبلغ %{provider} عن خطأ في الترجمة بالرمز %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | -------------------------------------------------------------------------------- /config/locales/server.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | -------------------------------------------------------------------------------- /config/locales/server.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | -------------------------------------------------------------------------------- /config/locales/server.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | -------------------------------------------------------------------------------- /config/locales/server.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | site_settings: 9 | translator_provider: "Poskytovatel překladatelské služby." 10 | translator_azure_subscription_key: "Klíč předplatného Azure" 11 | translator_azure_region: "Azure Region" 12 | translator_google_api_key: "Klíč Google API" 13 | translator_yandex_api_key: "Yandex API klíč" 14 | max_translations_per_minute: "Počet překladů za minutu, které může běžný uživatel provést." 15 | translator: 16 | not_supported: "Tento jazyk není podporován překladačem." 17 | too_long: "Tento příspěvek je příliš dlouhý na to, aby jej překladatel přeložil." 18 | microsoft: 19 | missing_token: "Překladač nebyl schopen získat platný token." 20 | -------------------------------------------------------------------------------- /config/locales/server.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | -------------------------------------------------------------------------------- /config/locales/server.de.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | de: 8 | site_settings: 9 | translator_enabled: "Aktiviere das Übersetzer-Plugin." 10 | translator_provider: "Der Anbieter des Übersetzungsdienstes." 11 | translator_azure_subscription_key: "Azure-Abonnementschlüssel" 12 | translator_azure_region: "Azure-Region" 13 | translator_google_api_key: "Google-API-Schlüssel" 14 | translator_yandex_api_key: "Yandex-API-Schlüssel" 15 | max_characters_per_translation: "Die maximale Anzahl von Zeichen, die zur Übersetzung gesendet werden können. Wenn der Inhalt länger als diese Zahl ist, wird der Text abgeschnitten. Beachte, dass jeder Anbieter auch seine eigenen Limits hat." 16 | max_translations_per_minute: "Die Anzahl der Übersetzungen pro Minute, die ein normaler Benutzer durchführen kann." 17 | translator_libretranslate_endpoint: "LibreTranslate-API-Endpunkt" 18 | translator_libretranslate_api_key: "LibreTranslate-API-Schlüssel" 19 | translator_aws_region: "AWS-Region" 20 | translator_aws_key_id: "AWS-Schlüssel-ID" 21 | translator_aws_secret_access: "AWS-Geheimnis-Zugriffsschlüssel" 22 | translator_aws_iam_role: "AWS-IAM-Rolle" 23 | translator_azure_custom_subdomain: "Erforderlich, wenn du ein virtuelles Netzwerk oder eine Firewall für Azure Cognitive Services verwendest. Hinweis: Gib nur die benutzerdefinierte Subdomain ein, nicht den vollständigen benutzerdefinierten Endpunkt." 24 | restrict_translation_by_group: "Nur berechtigte Gruppen können übersetzen" 25 | restrict_translation_by_poster_group: "Erlaube nur die Übersetzung von Beiträgen von Benutzern aus zulässigen Gruppen. Wenn das Feld leer ist, wird die Übersetzung von Beiträgen aller Benutzer erlaubt." 26 | translator: 27 | failed: 28 | topic: "Der Übersetzer kann diesen Thementitel (%{source_locale}) nicht in deine Sprache (%{target_locale}) übersetzen." 29 | post: "Der Übersetzer kann den Inhalt dieses Beitrags (%{source_locale}) nicht in deine Sprache (%{target_locale}) übersetzen." 30 | not_supported: "Diese Sprache wird vom Übersetzer nicht unterstützt." 31 | too_long: "Dieser Beitrag ist zu lang, um vom Übersetzer übersetzt zu werden." 32 | not_available: "Der Übersetzerdienst ist derzeit nicht verfügbar." 33 | api_timeout: "Der Übersetzungsdienst hat zu lange gebraucht, um zu antworten. Bitte versuche es später noch einmal." 34 | amazon: 35 | invalid_credentials: "Die angegebenen Anmeldeinformationen für AWS translate sind ungültig." 36 | microsoft: 37 | missing_token: "Der Übersetzer konnte kein gültiges Token abrufen." 38 | missing_key: "Kein Azure-Abonnementschlüssel bereitgestellt." 39 | discourse_ai: 40 | not_installed: "Um diese Funktion zu nutzen, musst du das discourse-ai-Plug-in installieren." 41 | ai_helper_required: 'Du musst den KI-Helfer konfigurieren, um diese Funktion zu nutzen.' 42 | not_in_group: 43 | user_not_in_group: "Du gehörst nicht zu einer Gruppe, die übersetzen darf." 44 | poster_not_in_group: "Der Beitrag wurde nicht von einem Benutzer einer zulässigen Gruppe erstellt." 45 | dashboard: 46 | problem: 47 | missing_translator_api_key: "%{provider} wurde als Übersetzer verwendet, aber %{key} wurde nicht angegeben. Siehe Website-Einstellungen." 48 | translator_error: "%{provider} meldete einen Übersetzungsfehler mit dem Code %{code}: %{message}" 49 | -------------------------------------------------------------------------------- /config/locales/server.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | site_settings: 9 | translator_provider: "Ο πάροχος της μεταφραστικής υπηρεσίας." 10 | translator_azure_subscription_key: "Κλειδί Συνδρομής Azure" 11 | translator_azure_region: "Περιοχή Azure" 12 | translator_google_api_key: "Κλειδί API Google" 13 | translator_yandex_api_key: "Κλειδί API Yandex" 14 | max_translations_per_minute: "Το αριθμός των μεταφράσεων κάθε λεπτό που μπορούν να γραφτούν από χρήστες." 15 | translator_libretranslate_endpoint: "LibreTranslate API Endpoint" 16 | translator_libretranslate_api_key: "Κλειδί API LibreTranslate" 17 | translator_aws_region: "Περιοχή AWS" 18 | translator_aws_key_id: "ID κλειδιού AWS" 19 | translator_aws_secret_access: "Κλειδί μυστικής πρόσβασης AWS" 20 | translator_aws_iam_role: "Ρόλος IAM AWS" 21 | translator: 22 | not_supported: "Αυτή η γλώσσα δεν υποστηρίζεται από τον μεταφραστή." 23 | too_long: "Αυτή η ανάρτηση είναι πολύ μεγάλη για να μεταφραστεί από τον μεταφραστή." 24 | not_available: "Η υπηρεσία μεταφραστή δεν είναι διαθέσιμη προς το παρόν." 25 | microsoft: 26 | missing_token: "Ο μεταφραστής δεν μπόρεσε να ανακτήσει ένα έγκυρο token." 27 | missing_key: "Δεν δόθηκε Κλειδί Συνδρομής Azure." 28 | dashboard: 29 | problem: 30 | translator_error: "Ο/Η %{provider} ανέφερε σφάλμα μετάφρασης με κωδικό %{code}: %{message}" 31 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | translator_enabled: "Enable the translator plugin." 4 | translator_provider: "The provider of the translation service." 5 | translator_azure_subscription_key: "Azure Subscription Key" 6 | translator_azure_region: "Azure Region" 7 | translator_google_api_key: "Google API Key" 8 | translator_yandex_api_key: "Yandex API Key" 9 | max_characters_per_translation: "The maximum number of characters that can be sent for translation. If content is longer than this, text will be truncated. Note that each provider also has their own limits." 10 | max_translations_per_minute: "The number of translations per minute a regular user can perform." 11 | translator_libretranslate_endpoint: "LibreTranslate API Endpoint" 12 | translator_libretranslate_api_key: "LibreTranslate API Key" 13 | translator_aws_region: "AWS Region" 14 | translator_aws_key_id: "AWS Key ID" 15 | translator_aws_secret_access: "AWS secret access key" 16 | translator_aws_iam_role: "AWS IAM Role" 17 | translator_azure_custom_subdomain: "Required if using a Virtual Network or Firewall for Azure Cognitive Services. Note: Only enter the custom subdomain not the full custom endpoint." 18 | restrict_translation_by_group: "Only allowed groups can translate" 19 | restrict_translation_by_poster_group: "Only allow translation of posts made by users in allowed groups. If empty, allow translations of posts from all users." 20 | translator: 21 | failed: 22 | topic: "The translator is unable to translate this topic's title (%{source_locale}) to your language (%{target_locale})." 23 | post: "The translator is unable to translate this post's content (%{source_locale}) to your language (%{target_locale})." 24 | not_supported: "This language is not supported by the translator." 25 | too_long: "This post is too long to be translated by the translator." 26 | not_available: "The translator service is currently not available." 27 | api_timeout: "The translator service took too long to respond. Please try again later." 28 | 29 | amazon: 30 | invalid_credentials: "The provided credentials for AWS translate are invalid." 31 | 32 | microsoft: 33 | missing_token: "The translator was unable to retrieve a valid token." 34 | missing_key: "No Azure Subscription Key provided." 35 | 36 | discourse_ai: 37 | not_installed: "You need to install the discourse-ai plugin to use this feature." 38 | ai_helper_required: 'You need to configure the ai helper to use this feature.' 39 | not_in_group: 40 | user_not_in_group: "You don't belong to a group allowed to translate." 41 | poster_not_in_group: "Post wasn't made by an user in an allowed group." 42 | dashboard: 43 | problem: 44 | missing_translator_api_key: "%{provider} was used as the translator, but no %{key} was provided. See site settings." 45 | translator_error: "%{provider} reported a translation error with code %{code}: %{message}" 46 | -------------------------------------------------------------------------------- /config/locales/server.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | es: 8 | site_settings: 9 | translator_provider: "El proveedor del servicio de traducción." 10 | translator_azure_subscription_key: "Clave de suscripción de Azure" 11 | translator_azure_region: "Región de Azure" 12 | translator_google_api_key: "Clave API de Google" 13 | translator_yandex_api_key: "Clave API de Yandex" 14 | max_characters_per_translation: "El número máximo de caracteres que se pueden enviar para su traducción. Si el contenido es más largo que esto, el texto se truncará. Ten en cuenta que cada proveedor también tiene sus propios límites." 15 | max_translations_per_minute: "El número de traducciones por minuto que puede realizar un usuario normal." 16 | translator_libretranslate_endpoint: "Ruta de API de LibreTranslate" 17 | translator_libretranslate_api_key: "Clave de API de LibreTranslate" 18 | translator_aws_region: "Región de AWS" 19 | translator_aws_key_id: "ID de clave de AWS" 20 | translator_aws_secret_access: "Clave de acceso secreta de AWS" 21 | translator_aws_iam_role: "Rol de IAM de AWS" 22 | translator_azure_custom_subdomain: "Obligatorio si se utiliza una red virtual o un cortafuegos para Azure Cognitive Services. Nota: Introduzca únicamente el subdominio personalizado, no el terminal personalizado completo." 23 | restrict_translation_by_group: "Solo los grupos autorizados pueden traducir" 24 | restrict_translation_by_poster_group: "Permitir solo la traducción de las publicaciones realizadas por usuarios de los grupos permitidos. Si está vacío, permite la traducción de las publicaciones por parte de todos los usuarios." 25 | translator: 26 | not_supported: "El traductor no admite este idioma." 27 | too_long: "Esta publicación es demasiado larga para ser traducida por el traductor." 28 | not_available: "El servicio de traducción no está disponible actualmente." 29 | amazon: 30 | invalid_credentials: "Las credenciales proporcionadas para AWS Translate no son válidas." 31 | microsoft: 32 | missing_token: "El traductor no pudo recuperar un token válido." 33 | missing_key: "No se ha proporcionado la clave de suscripción a Azure." 34 | discourse_ai: 35 | not_installed: "Necesitas instalar el plugin discourse-ai para utilizar esta característica." 36 | ai_helper_required: 'Necesitas configurar el asistente de IA para utilizar esta característica.' 37 | not_in_group: 38 | user_not_in_group: "Usted no pertenece a un grupo autorizado para traducir." 39 | poster_not_in_group: "La publicación no la realizó ningún usuario de un grupo permitido." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "Se utilizó %{provider} como traductor, pero no se proporcionó %{key}. Consulta los ajustes del sitio." 43 | translator_error: "%{provider} informó de un error de traducción con el código %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | -------------------------------------------------------------------------------- /config/locales/server.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | site_settings: 9 | translator_provider: "ارائه دهنده خدمات ترجمه" 10 | translator_azure_subscription_key: "کلید اشتراک Azure" 11 | translator_azure_region: "منطقه Azure" 12 | translator_google_api_key: "کلید API گوگل" 13 | translator_yandex_api_key: "کلید API یاندکس" 14 | translator_aws_region: "منطقه AWS" 15 | translator_aws_key_id: "شناسه کلید AWS" 16 | translator_aws_secret_access: "کلید دسترسی امن AWS" 17 | translator_aws_iam_role: "نقش AWS IAM" 18 | restrict_translation_by_group: "فقط گروه‌های مجاز می‌توانند، ترجمه کنند" 19 | not_in_group: 20 | user_not_in_group: "شما به گروه‌ی تعلق ندارید که مجاز به ترجمه هستند." 21 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fi: 8 | site_settings: 9 | translator_provider: "Käännöspalveluntarjoaja." 10 | translator_azure_subscription_key: "Azure Subscription Key -tilaustunniste" 11 | translator_azure_region: "Azure-alue" 12 | translator_google_api_key: "Googlen API-avain" 13 | translator_yandex_api_key: "Yandexin API-avain" 14 | max_characters_per_translation: "Merkkien enimmäismäärä, joka voidaan lähettää käännettäväksi. Jos sisältö on tätä pidempi, teksti katkaistaan. Huomaa, että jokaisella palveluntarjoajalla on myös omat rajansa." 15 | max_translations_per_minute: "Käännösten määrä minuutissa, jonka tavallinen käyttäjä voi tehdä." 16 | translator_libretranslate_endpoint: "LibreTranslaten API-päätepiste" 17 | translator_libretranslate_api_key: "LibreTranslaten API-avain" 18 | translator_aws_region: "AWS-alue" 19 | translator_aws_key_id: "AWS-avaimen tunnus" 20 | translator_aws_secret_access: "AWS-salainen pääsyavain" 21 | translator_aws_iam_role: "AWS:n IAM-rooli" 22 | translator_azure_custom_subdomain: "Vaaditaan, jos käytät Azure Cognitive Services -palveluiden virtuaaliverkkoa tai palomuuria. Huomioi: syötä vain oma aliverkkotunnuksesi päätelaitteesi koko osoitteen sijaan." 23 | restrict_translation_by_group: "Vain sallitut ryhmät voivat kääntää" 24 | restrict_translation_by_poster_group: "Salli vain sallittujen ryhmien käyttäjien tekemien viestien kääntäminen. Jos tämä on tyhjä, salli kaikkien käyttäjien viestien käännökset." 25 | translator: 26 | not_supported: "Kääntäjä ei tue tätä kieltä." 27 | too_long: "Tämä viesti on liian pitkä kääntäjän käännettäväksi." 28 | not_available: "Kääntäjäpalvelu ei ole tällä hetkellä käytettävissä." 29 | amazon: 30 | invalid_credentials: "Ilmoitetut AWS Translate -tunnukset ovat virheelliset." 31 | microsoft: 32 | missing_token: "Kääntäjä ei voinut noutaa kelvollista tunnusta." 33 | missing_key: "Azure Subscription Key -tilaustunnistetta ei ole ilmoitettu." 34 | discourse_ai: 35 | not_installed: "Sinun täytyy asentaa discourse-ai-lisäosa tämän ominaisuuden käyttämiseksi." 36 | ai_helper_required: 'Sinun täytyy määrittää ai-apuohjelma tämän ominaisuuden käyttämiseksi.' 37 | not_in_group: 38 | user_not_in_group: "Et kuulu ryhmään, jolla on oikeus kääntää." 39 | poster_not_in_group: "Viestiä ei tehnyt sallittuun ryhmään kuuluva käyttäjä." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "Kääntäjänä käytettiin palvelua %{provider}, mutta %{key} puuttuu. Katso sivuston asetukset." 43 | translator_error: "%{provider} ilmoitti käännösvirheestä koodilla %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fr: 8 | site_settings: 9 | translator_provider: "Le fournisseur du service de traduction." 10 | translator_azure_subscription_key: "Clé d'abonnement Azure" 11 | translator_azure_region: "Région Azure" 12 | translator_google_api_key: "Clé d'API Google" 13 | translator_yandex_api_key: "Clé d'API Yandex" 14 | max_characters_per_translation: "Le nombre maximum de caractères pouvant être envoyés pour traduction. Si le contenu est plus long, le texte sera tronqué. Notez que chaque fournisseur a également ses propres limites." 15 | max_translations_per_minute: "Le nombre de traductions par minute qu'un utilisateur normal peut effectuer." 16 | translator_libretranslate_endpoint: "Point de terminaison de l'API LibreTranslate" 17 | translator_libretranslate_api_key: "Clé API de LibreTranslate" 18 | translator_aws_region: "Région AWS" 19 | translator_aws_key_id: "ID de clé AWS" 20 | translator_aws_secret_access: "Clé d'accès secrète AWS" 21 | translator_aws_iam_role: "Rôle AWS IAM" 22 | translator_azure_custom_subdomain: "Obligatoire si vous utilisez un réseau virtuel ou un pare-feu pour Azure Cognitive Services. Remarque : saisissez uniquement le sous-domaine personnalisé et non le point de terminaison personnalisé complet." 23 | restrict_translation_by_group: "Seuls les groupes autorisés peuvent traduire" 24 | restrict_translation_by_poster_group: "Autorisez uniquement la traduction des publications faites par les utilisateurs des groupes autorisés. Si ce champ est vide, autorisez la traduction des publications de tous les utilisateurs." 25 | translator: 26 | not_supported: "Cette langue n'est pas prise en charge par le traducteur." 27 | too_long: "Ce message est trop long pour être traduit par le traducteur." 28 | not_available: "Le service de traduction est actuellement indisponible." 29 | amazon: 30 | invalid_credentials: "Les informations d'identification fournies pour AWS Translate ne sont pas valides." 31 | microsoft: 32 | missing_token: "Le traducteur n'a pas pu récupérer un jeton valide." 33 | missing_key: "Aucune clé d'abonnement Azure n'a été fournie." 34 | discourse_ai: 35 | not_installed: "Vous devez installer l'extension discourse-ai pour utiliser cette fonctionnalité." 36 | ai_helper_required: 'Vous devez configurer l''assistant IA pour utiliser cette fonctionnalité.' 37 | not_in_group: 38 | user_not_in_group: "Vous ne faites pas partie d'un groupe autorisé à traduire." 39 | poster_not_in_group: "La publication n'a pas été faite par un utilisateur appartenant à un groupe autorisé." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} a été utilisé comme traducteur, mais aucune %{key} n'a été fournie. Voir les paramètres du site." 43 | translator_error: "%{provider} a signalé une erreur de traduction avec le code %{code} : %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | -------------------------------------------------------------------------------- /config/locales/server.he.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | he: 8 | site_settings: 9 | translator_enabled: "הפעלת תוסף ההמתרגם." 10 | translator_provider: "ספק שירות התרגום." 11 | translator_azure_subscription_key: "מפתח מינוי ל־Azure" 12 | translator_azure_region: "אזור ב־Azure" 13 | translator_google_api_key: "מפתח API של Google" 14 | translator_yandex_api_key: "מפתח API של Yandex" 15 | max_characters_per_translation: "מספר התווים המרבי שניתן לשלוח לתרגום. אם התוכן יותר ארוך מזה, הטקסט יקוצר. נא לשים לב שלכל ספק יש מגבלות משלו." 16 | max_translations_per_minute: "מספר התרגומים לדקה שמשתמש רגיל יכול לבצע." 17 | translator_libretranslate_endpoint: "נק׳ קצה ל־API של LibreTranslate" 18 | translator_libretranslate_api_key: "מפתח API ל־LibreTranslate" 19 | translator_aws_region: "אזור ב־AWS" 20 | translator_aws_key_id: "מזהה מפתח AWS" 21 | translator_aws_secret_access: "מפתח גישה סודי של AWS" 22 | translator_aws_iam_role: "תפקיד IAM ב־AWS" 23 | translator_azure_custom_subdomain: "נחוץ אם נעשה שימוש ברשת וירטואלית או בחומת אש לשירותי Azure Cognitive. לתשומת לבך: יש למלא רק את תת־שם התחום, לא את נקודת הגישה המותאמת אישית במלואה." 24 | restrict_translation_by_group: "רק קבוצות מורשות יכולות לתרגם" 25 | restrict_translation_by_poster_group: "לאפשר תרגום של פוסטים שנוצרו על ידי משתמשים בקבוצות מורשות בלבד. אם ריק, לאפשר תרגומים של פוסטים מכל המשתמשים." 26 | translator: 27 | failed: 28 | topic: "המתרגם לא מסוגל לתרגם את כותרת הנושא הזאת (%{source_locale}) לשפה שלך (%{target_locale})." 29 | post: "המתרגם לא מסוגל לתרגם את תוכן הפוסט הזה (%{source_locale}) לשפה שלך (%{target_locale})." 30 | not_supported: "שפה זו אינה נתמכת על ידי המתרגם." 31 | too_long: "פוסט זה ארוך מכדי לתרגם אותו על ידי המתרגם." 32 | not_available: "שירות התרגום אינו זמין כרגע." 33 | api_timeout: "לשירות התרגום לקח יותר מדי זמן להגיב. נא לנסות שוב מאוחר יותר." 34 | amazon: 35 | invalid_credentials: "פרטי הגישה שצוינו למנגנון התרגום של AWS שגויים." 36 | microsoft: 37 | missing_token: "המתרגם לא הצליח לקבל אסימון תקף." 38 | missing_key: "לא סופק מפתח מינוי של Azure." 39 | discourse_ai: 40 | not_installed: "יש להתקין את התוסף discourse-ai כדי להשתמש ביכולת הזאת." 41 | ai_helper_required: 'יש להגדיר את מסייע הבינה המלאכותית כדי להשתמש ביכולת הזאת.' 42 | not_in_group: 43 | user_not_in_group: "לא חברת לקבוצה שמורשית לתרגם." 44 | poster_not_in_group: "הפוסט לא פורסם על ידי משתמש בקבוצה מותרת." 45 | dashboard: 46 | problem: 47 | missing_translator_api_key: "%{provider} שימש כמתרגם אבל לא סופק %{key}. נא לגשת להגדרות האתר." 48 | translator_error: "%{provider} דיווח על שגיאת תרגום עם קוד %{code}: %{message}" 49 | -------------------------------------------------------------------------------- /config/locales/server.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | -------------------------------------------------------------------------------- /config/locales/server.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | site_settings: 9 | translator_provider: "A fordítási szolgáltatás szolgáltatója." 10 | translator_azure_subscription_key: "Azure előfizetési kulcs" 11 | translator_azure_region: "Azure régió" 12 | translator_google_api_key: "Google API kulcs" 13 | translator_yandex_api_key: "Yandex API kulcs" 14 | max_translations_per_minute: "A percenkénti fordítások száma, amelyet egy átlagos felhasználó elvégezhet." 15 | translator_aws_region: "AWS régió" 16 | translator_aws_key_id: "AWS kulcsazonosító" 17 | translator_aws_secret_access: "AWS titkos hozzáférési kulcs" 18 | translator_aws_iam_role: "AWS IAM szerepkör" 19 | translator: 20 | not_supported: "A fordító nem támogatja ezt a nyelvet." 21 | too_long: "Ez a bejegyzés túl hosszú ahhoz, hogy a fordító lefordítsa." 22 | microsoft: 23 | missing_token: "A fordító nem tudott érvényes tokent lekérni." 24 | -------------------------------------------------------------------------------- /config/locales/server.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | -------------------------------------------------------------------------------- /config/locales/server.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | -------------------------------------------------------------------------------- /config/locales/server.it.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | it: 8 | site_settings: 9 | translator_provider: "Il fornitore del servizio di traduzione." 10 | translator_azure_subscription_key: "Chiave di sottoscrizione Azure" 11 | translator_azure_region: "Regione Azure" 12 | translator_google_api_key: "Chiave API Google" 13 | translator_yandex_api_key: "Chiave API Yandex" 14 | max_characters_per_translation: "Il numero massimo di caratteri che possono essere inviati per la traduzione. Se il contenuto è più lungo, il testo verrà troncato. Nota che ogni fornitore ha i propri limiti." 15 | max_translations_per_minute: "Il numero di traduzioni al minuto che un utente normale può eseguire." 16 | translator_libretranslate_endpoint: "Endpoint API di LibreTranslate" 17 | translator_libretranslate_api_key: "Chiave API di LibreTranslate" 18 | translator_aws_region: "Regione AWS" 19 | translator_aws_key_id: "ID chiave AWS" 20 | translator_aws_secret_access: "Chiave segreta di accesso AWS" 21 | translator_aws_iam_role: "Ruolo AWS IAM" 22 | translator_azure_custom_subdomain: "Obbligatorio se si usa una rete virtuale o un firewall per i servizi cognitivi di Azure. Nota: inserisci solo il sottodominio personalizzato, non l'endpoint personalizzato completo." 23 | restrict_translation_by_group: "Solo i gruppi autorizzati possono tradurre" 24 | restrict_translation_by_poster_group: "Consenti solo la traduzione dei messaggi scritti dagli utenti nei gruppi consentiti. Se vuoto, consente le traduzioni dei messaggi di tutti gli utenti." 25 | translator: 26 | not_supported: "Questa lingua non è supportata dal traduttore." 27 | too_long: "Questo messaggio è troppo lungo per essere tradotto dal traduttore." 28 | not_available: "Il servizio di traduzione non è attualmente disponibile." 29 | amazon: 30 | invalid_credentials: "Le credenziali fornite per AWS Translate non sono valide." 31 | microsoft: 32 | missing_token: "Il traduttore non è stato in grado di recuperare un token valido." 33 | missing_key: "Nessuna chiave di sottoscrizione di Azure fornita." 34 | discourse_ai: 35 | not_installed: "Per utilizzare questa funzionalità è necessario installare il plugin discourse-ai." 36 | ai_helper_required: 'Per utilizzare questa funzionalità è necessario configurare l''assistente ia.' 37 | not_in_group: 38 | user_not_in_group: "Non appartieni a nessun gruppo autorizzato a tradurre." 39 | poster_not_in_group: "Il messaggio non è stato creato da un utente in un gruppo consentito." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} è stato utilizzato come traduttore, ma non è stata fornita alcuna %{key}. Controlla le impostazioni del sito." 43 | translator_error: "%{provider} ha segnalato un errore di traduzione con il codice %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | site_settings: 9 | translator_provider: "翻訳サービスのプロバイダー。" 10 | translator_azure_subscription_key: "Azure サブスクリプションキー" 11 | translator_azure_region: "Azure リージョン" 12 | translator_google_api_key: "Google API キー" 13 | translator_yandex_api_key: "Yandex API キー" 14 | max_characters_per_translation: "翻訳に送信できる最大文字数。コンテンツがこれより長い場合、テキストは切り捨てられます。各プロバイダーにも独自の制限があることに注意してください。" 15 | max_translations_per_minute: "レギュラーユーザーが実行できる 1 分当たりの翻訳数。" 16 | translator_libretranslate_endpoint: "LibreTranslate API エンドポイント" 17 | translator_libretranslate_api_key: "LibreTranslate API キー" 18 | translator_aws_region: "AWS リージョン" 19 | translator_aws_key_id: "AWS キー ID" 20 | translator_aws_secret_access: "AWS シークレットアクセスキー" 21 | translator_aws_iam_role: "AWS IAM ロール" 22 | translator_azure_custom_subdomain: "Azure Cognitive Services に仮想ネットワークまたはファイアウォールを使用している場合は必須です。注意: 完全なカスタムエンドポイントではなく、カスタムサブドメインのみを入力します。" 23 | restrict_translation_by_group: "許可されたグループのみが翻訳できます" 24 | restrict_translation_by_poster_group: "許可されたグループのユーザーによる投稿の翻訳のみを許可します。空白の場合、すべてのユーザーによる投稿の翻訳を許可します。" 25 | translator: 26 | not_supported: "翻訳ツールはこの言語をサポートしていません。" 27 | too_long: "この投稿は翻訳ツールで翻訳するには長すぎます。" 28 | not_available: "現在、翻訳ツールサービスは使用できません。" 29 | amazon: 30 | invalid_credentials: "指定された AWS Translate の資格情報は無効です。" 31 | microsoft: 32 | missing_token: "翻訳ツールは有効なトークンを取得できませんでした。" 33 | missing_key: "Azure サブスクリプションキーが指定されていません。" 34 | discourse_ai: 35 | not_installed: "この機能を使用するには、discourse-ai プラグインをインストールする必要があります。" 36 | ai_helper_required: 'この機能を使用するには、ai ヘルパーを構成する必要があります。' 37 | not_in_group: 38 | user_not_in_group: "翻訳が許可されたグループに属していません。" 39 | poster_not_in_group: "許可されたグループのユーザーによる投稿ではありません。" 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "翻訳ツールとして %{provider} が使用されましたが、%{key} が提供されていません。サイト設定をご覧ください。" 43 | translator_error: "%{provider} はコード %{code} の翻訳エラーを報告しました: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | -------------------------------------------------------------------------------- /config/locales/server.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | -------------------------------------------------------------------------------- /config/locales/server.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | -------------------------------------------------------------------------------- /config/locales/server.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | -------------------------------------------------------------------------------- /config/locales/server.nl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nl: 8 | site_settings: 9 | translator_provider: "De aanbieder van de vertaaldienst." 10 | translator_azure_subscription_key: "Azure-abonnementssleutel" 11 | translator_azure_region: "Azure-regio" 12 | translator_google_api_key: "Google API-sleutel" 13 | translator_yandex_api_key: "Yandex API-sleutel" 14 | max_characters_per_translation: "Het maximale aantal tekens dat kan worden verzonden voor vertaling. Als de inhoud langer is dan dit, wordt de tekst afgekapt. Elke aanbieder heeft ook zijn eigen limieten." 15 | max_translations_per_minute: "Het aantal vertalingen per minuut dat een gewone gebruiker kan uitvoeren." 16 | translator_libretranslate_endpoint: "LibreTranslate-API-eindpunt" 17 | translator_libretranslate_api_key: "LibreTranslate-API-sleutel" 18 | translator_aws_region: "AWS-regio" 19 | translator_aws_key_id: "AWS-sleutel-ID" 20 | translator_aws_secret_access: "AWS geheime toegangssleutel" 21 | translator_aws_iam_role: "AWS IAM-rol" 22 | translator_azure_custom_subdomain: "Vereist bij gebruik van een virtueel netwerk of firewall voor Azure Cognitive Services. Opmerking: voer alleen het aangepaste subdomein in, niet het volledige aangepaste eindpunt." 23 | restrict_translation_by_group: "Alleen toegestane groepen kunnen vertalen" 24 | restrict_translation_by_poster_group: "Sta alleen vertalingen toe van berichten gemaakt door gebruikers in toegestane groepen. Als dit leeg is, worden vertalingen van berichten van alle gebruikers toegestaan." 25 | translator: 26 | not_supported: "Deze taal wordt niet ondersteund door de vertaler." 27 | too_long: "Dit bericht is te lang om door de vertaler te worden vertaald." 28 | not_available: "De vertaalservice is momenteel niet beschikbaar." 29 | amazon: 30 | invalid_credentials: "De opgegeven aanmeldgegevens voor AWS Translate zijn ongeldig." 31 | microsoft: 32 | missing_token: "De vertaler kon geen geldig token ophalen." 33 | missing_key: "Geen Azure-abonnementscode opgegeven." 34 | discourse_ai: 35 | not_installed: "Je moet de plug-in discourse-ai installeren om deze functie te kunnen gebruiken." 36 | ai_helper_required: 'Je moet de AI-helper configureren om deze functie te kunnen gebruiken.' 37 | not_in_group: 38 | user_not_in_group: "U behoort niet tot een groep die mag vertalen." 39 | poster_not_in_group: "Bericht is niet gemaakt door een gebruiker in een toegestane groep." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} is gebruikt als vertaler, maar er is geen %{key} opgegeven. Zie de site-instellingen." 43 | translator_error: "%{provider} heeft een vertaalfout gemeld met code %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | site_settings: 9 | translator_provider: "Dostawca usług tłumaczeniowych." 10 | translator_azure_subscription_key: "Klucz subskrypcji Azure" 11 | translator_azure_region: "Region Azure" 12 | translator_google_api_key: "Klucz API Google" 13 | translator_yandex_api_key: "Klucz API Yandex" 14 | max_translations_per_minute: "Liczba tłumaczeń na minutę, które może wykonać zwykły użytkownik." 15 | translator_aws_region: "Region AWS" 16 | translator_aws_key_id: "ID klucza AWS" 17 | translator_aws_secret_access: "Klucz tajnego dostępu AWS" 18 | translator_aws_iam_role: "Rola AWS IAM" 19 | translator_azure_custom_subdomain: "Wymagane w przypadku korzystania z sieci wirtualnej lub zapory dla usług Azure Cognitive Services. Uwaga: wprowadź tylko niestandardową subdomenę, a nie pełny niestandardowy punkt końcowy." 20 | translator: 21 | not_supported: "Ten język nie jest obsługiwany przez tłumacza." 22 | too_long: "Ten post jest zbyt długi, aby mógł zostać przetłumaczony przez tłumacza." 23 | microsoft: 24 | missing_token: "Tłumacz nie mógł pobrać prawidłowego tokenu." 25 | missing_key: "Nie podano klucza subskrypcji Azure" 26 | not_in_group: 27 | user_not_in_group: "Nie należysz do grupy, która może tłumaczyć." 28 | -------------------------------------------------------------------------------- /config/locales/server.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | -------------------------------------------------------------------------------- /config/locales/server.pt_BR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt_BR: 8 | site_settings: 9 | translator_provider: "O provedor do serviço de tradução." 10 | translator_azure_subscription_key: "Chave de Assinatura do Azure" 11 | translator_azure_region: "Região do Azure" 12 | translator_google_api_key: "Chave de API do Google" 13 | translator_yandex_api_key: "Chave de API do Yandex" 14 | max_characters_per_translation: "A quantidade máxima de caracteres que podem ser enviados para tradução. Se o conteúdo ultrapassar esse valor, o texto ficará truncado. Observação: cada provedor também tem seus próprios limites." 15 | max_translations_per_minute: "A quantidade de traduções por minuto que um(a) usuário(a) comum pode realizar." 16 | translator_libretranslate_endpoint: "Ponto de extremidade da API LibreTranslate" 17 | translator_libretranslate_api_key: "Chave da API LibreTranslate" 18 | translator_aws_region: "Região do AWS" 19 | translator_aws_key_id: "ID da Chave do AWS" 20 | translator_aws_secret_access: "Chave de acesso secreta do AWS" 21 | translator_aws_iam_role: "Função do AWS IAM" 22 | translator_azure_custom_subdomain: "É necessário se estiver usando uma rede virtual ou firewall para o Azure Cognitive Services. Observação: insira apenas o subdomínio personalizado, não o endpoint completo." 23 | restrict_translation_by_group: "Apenas os grupos permitidos podem traduzir" 24 | restrict_translation_by_poster_group: "Permita tradução de postagens realizadas por usuários(as) nos grupos permitidos. Se estiver vazio, permita traduções de postagens de todos(as) usuários(as)." 25 | translator: 26 | not_supported: "Este idioma não é compatível com o(a) tradutor(a)." 27 | too_long: "Esta postagem é muito longa para ser traduzida pelo(a) tradutor(a)." 28 | not_available: "O serviço de tradução não está disponível por enquanto." 29 | amazon: 30 | invalid_credentials: "As credenciais fornecidas para a tradução do AWS são inválidas." 31 | microsoft: 32 | missing_token: "O(a) tradutor(a) não pôde recuperar um token válido." 33 | missing_key: "Nenhuma Chave de Assinatura do Azure fornecida." 34 | discourse_ai: 35 | not_installed: "É preciso instalar o plugin do discourse-ia para usar este recurso." 36 | ai_helper_required: 'Você precisa configurar o ajudante de IA para usar este recurso.' 37 | not_in_group: 38 | user_not_in_group: "Você não pertence a um grupo com permissão para traduzir." 39 | poster_not_in_group: "Esta postagem não foi feita por um(a) usuário(a) em um grupo permitido." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "Usaram %{provider} como tradutor, mas sem nenhuma %{key}. Confira as configurações do site." 43 | translator_error: "%{provider} relatou um erro de tradução no código %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | site_settings: 9 | translator_enabled: "Activează modulul pentru traduceri." 10 | translator_provider: "Furnizorul serviciului de traducere." 11 | translator_azure_subscription_key: "Cheie de abonament Azure" 12 | translator_azure_region: "Regiune Azure" 13 | translator_google_api_key: "Cheie API Google" 14 | translator_yandex_api_key: "Cheie API Yandex" 15 | max_characters_per_translation: "Numărul maxim de caractere care pot fi trimise pentru traducere. Dacă textul depășește această lungime, va fi trunchiat. Reține că fiecare furnizor are propriile sale limite." 16 | max_translations_per_minute: "Numărul de traduceri pe minut pe care un utilizator obișnuit le poate efectua." 17 | translator_libretranslate_endpoint: "Punct-final API LibreTranslate" 18 | translator_libretranslate_api_key: "Cheie API LibreTranslate" 19 | translator_aws_region: "Regiune AWS" 20 | translator_aws_key_id: "ID cheie AWS" 21 | translator_aws_secret_access: "Cheie de acces secretă AWS" 22 | translator_aws_iam_role: "Rol AWS IAM" 23 | translator_azure_custom_subdomain: "Necesar dacă utilizezi o rețea virtuală sau un firewall pentru Azure Cognitive Services. Notă: introdu numai subdomeniul personalizat, nu punctul-final personalizat complet." 24 | restrict_translation_by_group: "Doar grupurile permise pot traduce" 25 | restrict_translation_by_poster_group: "Permite traducerea numai a articolelor făcute de utilizatorii din grupurile permise. Dacă este gol, permite traducerea articolelor de la toți utilizatorii." 26 | translator: 27 | failed: 28 | topic: "Traducătorul nu poate traduce titlul acestui subiect (%{source_locale}) în limba ta (%{target_locale})." 29 | post: "Traducătorul nu poate traduce conținutul acestei postări (%{source_locale}) în limba ta (%{target_locale})." 30 | not_supported: "Această limbă nu este acceptată de traducător." 31 | too_long: "Acest articol este prea lung pentru a fi tradusă de către traducător." 32 | not_available: "Momentan serviciul de traducere nu este disponibil." 33 | api_timeout: "Serviciul de traducere a durat prea mult să răspundă. Te rog să încerci din nou mai târziu." 34 | amazon: 35 | invalid_credentials: "Datele de identificare furnizate pentru traducerea AWS nu sunt valide." 36 | microsoft: 37 | missing_token: "Traducătorul nu a putut să recupereze un jeton valid." 38 | missing_key: "Nicio cheie de abonament Azure nu a fost furnizată." 39 | discourse_ai: 40 | not_installed: "Trebuie să instalezi modulul discourse-ai pentru a utiliza această funcționalitate." 41 | ai_helper_required: 'Trebuie să configurezi ajutorul IA pentru a utiliza această funcționalitate.' 42 | not_in_group: 43 | user_not_in_group: "Nu faci parte dintr-un grup autorizat să traducă." 44 | poster_not_in_group: "Articolul nu a fost făcut de un utilizator dintr-un grup permis." 45 | dashboard: 46 | problem: 47 | missing_translator_api_key: "%{provider} a fost utilizat ca traducător, dar %{key} nu a fost furnizat. Consultă setările site-ului." 48 | translator_error: "%{provider} a raportat o eroare de traducere cu codul %{code}: %{message}" 49 | -------------------------------------------------------------------------------- /config/locales/server.ru.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ru: 8 | site_settings: 9 | translator_provider: "Поставщик услуг перевода." 10 | translator_azure_subscription_key: "Ключ подписки Azure" 11 | translator_azure_region: "Регион Azure" 12 | translator_google_api_key: "Ключ API Google" 13 | translator_yandex_api_key: "Ключ API Яндекса" 14 | max_characters_per_translation: "Максимальное количество символов, которое можно отправить на перевод. Если контент длиннее, текст будет обрезан. Обратите внимание: у каждого поставщика также есть свои ограничения." 15 | max_translations_per_minute: "Количество переводов в минуту, которое может выполнять обычный пользователь." 16 | translator_libretranslate_endpoint: "Конечная точка API LibreTranslate" 17 | translator_libretranslate_api_key: "Ключ API LibreTranslate" 18 | translator_aws_region: "Регион AWS" 19 | translator_aws_key_id: "Код ключа AWS" 20 | translator_aws_secret_access: "Секретный ключ доступа AWS" 21 | translator_aws_iam_role: "Роль AWS IAM" 22 | translator_azure_custom_subdomain: "Требуется при использовании виртуальной сети или брандмауэра для Azure Cognitive Services. Примечание: вводите только настраиваемый поддомен, а не полную настраиваемую конечную точку." 23 | restrict_translation_by_group: "Только разрешенные группы могут запрашивать перевод" 24 | restrict_translation_by_poster_group: "Допускает перевод только публикаций пользователей из разрешенных групп. Если параметр не задан, разрешаются переводы публикаций от всех пользователей." 25 | translator: 26 | not_supported: "Этот язык не поддерживается переводчиком." 27 | too_long: "Эта запись слишком длинная для перевода." 28 | not_available: "Сервис перевода сейчас недоступен." 29 | amazon: 30 | invalid_credentials: "Предоставленные учетные данные для сервиса машинного перевода AWS недействительны." 31 | microsoft: 32 | missing_token: "Переводчику не удалось получить действительный токен." 33 | missing_key: "Ключ подписки Azure не указан." 34 | discourse_ai: 35 | not_installed: "Для использования этой функции вам необходимо установить плагин discourse-ai." 36 | ai_helper_required: 'Вам необходимо настроить ИИ-помощника, чтобы использовать эту функцию.' 37 | not_in_group: 38 | user_not_in_group: "Вы не входите в группу, которой разрешено запрашивать перевод." 39 | poster_not_in_group: "Публикацию создал пользователь не из разрешенной группы." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} был использован в качестве переводчика, но %{key} не был предоставлен. См. настройки сайта ." 43 | translator_error: "%{provider} сообщил об ошибке перевода с кодом %{code}: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | -------------------------------------------------------------------------------- /config/locales/server.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | -------------------------------------------------------------------------------- /config/locales/server.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | -------------------------------------------------------------------------------- /config/locales/server.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | -------------------------------------------------------------------------------- /config/locales/server.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | site_settings: 9 | translator_provider: "Leverantören av översättningstjänsten." 10 | translator_azure_subscription_key: "Azure -prenumerationsnyckel" 11 | translator_azure_region: "Azure-region" 12 | translator_google_api_key: "Google API-nyckel" 13 | translator_yandex_api_key: "Yandex API-nyckel" 14 | max_translations_per_minute: "Antalet översättningar per minut som en vanlig användare kan utföra." 15 | translator_aws_region: "AWS Region" 16 | translator_aws_key_id: "AWS nyckel-ID" 17 | translator_aws_secret_access: "AWS hemlig åtkomstnyckel" 18 | translator_aws_iam_role: "AWS IAM-roll" 19 | translator: 20 | not_supported: "Detta språk stöds inte av översättaren." 21 | too_long: "Det här inlägget är för långt för att översättas av översättaren." 22 | microsoft: 23 | missing_token: "Översättaren kunde inte hämta en giltig token." 24 | -------------------------------------------------------------------------------- /config/locales/server.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | -------------------------------------------------------------------------------- /config/locales/server.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | -------------------------------------------------------------------------------- /config/locales/server.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | -------------------------------------------------------------------------------- /config/locales/server.tr_TR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | tr_TR: 8 | site_settings: 9 | translator_provider: "Çeviri hizmetinin sağlayıcısı." 10 | translator_azure_subscription_key: "Azure Abonelik Anahtarı" 11 | translator_azure_region: "Azure Region" 12 | translator_google_api_key: "Google API Anahtarı" 13 | translator_yandex_api_key: "Yandex API Anahtarı" 14 | max_characters_per_translation: "Çeviri için gönderilebilecek maksimum karakter sayısı. İçerik bundan daha uzunsa metin kesilir. Her sağlayıcının kendi limitleri olduğunu unutmayın." 15 | max_translations_per_minute: "Normal bir kullanıcının gerçekleştirebileceği dakika başına çeviri sayısı." 16 | translator_libretranslate_endpoint: "LibreTranslate API Uç Noktası" 17 | translator_libretranslate_api_key: "LibreTranslate API Anahtarı" 18 | translator_aws_region: "AWS Bölgesi" 19 | translator_aws_key_id: "AWS Anahtar Kimliği" 20 | translator_aws_secret_access: "AWS gizli erişim anahtarı" 21 | translator_aws_iam_role: "AWS IAM Rolü" 22 | translator_azure_custom_subdomain: "Azure Bilişsel Hizmetler için bir Sanal Ağ veya Güvenlik Duvarı kullanılıyorsa gereklidir. Not: Tam özel uç noktayı değil, yalnızca özel alt etki alanını girin." 23 | restrict_translation_by_group: "Yalnızca izin verilen gruplar çeviri yapabilir" 24 | restrict_translation_by_poster_group: "Yalnızca izin verilen gruplardaki kullanıcılar tarafından oluşturulan gönderilerin çevirisine izin verin. Boşsa tüm kullanıcıların gönderilerinin çevrilmesine izin verin." 25 | translator: 26 | not_supported: "Bu dil çevirmen tarafından desteklenmiyor." 27 | too_long: "Bu yazı çevirmen tarafından çevrilemeyecek kadar uzun." 28 | not_available: "Çevirmen hizmeti şu anda kullanılamıyor." 29 | amazon: 30 | invalid_credentials: "AWS çevirisi için sağlanan kimlik bilgileri geçersiz." 31 | microsoft: 32 | missing_token: "Çevirmen geçerli bir belirteç alamadı." 33 | missing_key: "Azure Abonelik Anahtarı sağlanmadı." 34 | discourse_ai: 35 | not_installed: "Bu özelliği kullanabilmek için discourse-ai eklentisini yüklemeniz gerekiyor." 36 | ai_helper_required: 'Bu özelliği kullanmak için yapay zekâ yardımcısını yapılandırmanız gerekiyor.' 37 | not_in_group: 38 | user_not_in_group: "Çeviri yapmanıza izin verilen bir grupta değilsiniz." 39 | poster_not_in_group: "Gönderi, izin verilen bir gruptaki bir kullanıcı tarafından yapılmadı." 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} çevirmen olarak kullanıldı ancak %{key} sağlanmadı. Site ayarlarına bakın." 43 | translator_error: "%{provider}, %{code} koduyla bir çeviri hatası bildirdi: %{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | -------------------------------------------------------------------------------- /config/locales/server.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | -------------------------------------------------------------------------------- /config/locales/server.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | site_settings: 9 | restrict_translation_by_poster_group: "صرف اجازت یافتہ گروپس میں صارفین کے ذریعے کی گئی پوسٹس کے ترجمے کی اجازت دیں۔ اگر خالی ہے تو تمام صارفین کی پوسٹس کے ترجمے کی اجازت دیں۔" 10 | -------------------------------------------------------------------------------- /config/locales/server.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | site_settings: 9 | translator_provider: "翻译服务提供商。" 10 | translator_azure_subscription_key: "Azure 订阅密钥" 11 | translator_azure_region: "Azure 区域" 12 | translator_google_api_key: "Google API 密钥" 13 | translator_yandex_api_key: "Yandex API 密钥" 14 | max_characters_per_translation: "可以发送进行翻译的最大字符数。如果内容超过该长度,文本将被截断。请注意,每个提供程序也有自己的限值。" 15 | max_translations_per_minute: "普通用户每分钟可以执行的翻译次数。" 16 | translator_libretranslate_endpoint: "LibreTranslate API 端点" 17 | translator_libretranslate_api_key: "LibreTranslate API 密钥" 18 | translator_aws_region: "AWS 区域" 19 | translator_aws_key_id: "AWS 密钥 ID" 20 | translator_aws_secret_access: "AWS 密码访问密钥" 21 | translator_aws_iam_role: "AWS IAM 角色" 22 | translator_azure_custom_subdomain: "如果将虚拟网络或防火墙用于 Azure 认知服务,则为必需。注意:仅输入自定义子域,而不是完整的自定义端点。" 23 | restrict_translation_by_group: "只有允许的群组才能翻译" 24 | restrict_translation_by_poster_group: "仅允许翻译允许的群组中用户发布的帖子。如果为空,则允许翻译所有用户的帖子。" 25 | translator: 26 | not_supported: "翻译器不支持此语言。" 27 | too_long: "帖子过长,翻译器无法翻译。" 28 | not_available: "该翻译服务目前不可用。" 29 | amazon: 30 | invalid_credentials: "提供的 AWS 翻译凭据无效。" 31 | microsoft: 32 | missing_token: "翻译器无法检索有效令牌。" 33 | missing_key: "未提供 Azure 订阅密钥。" 34 | discourse_ai: 35 | not_installed: "您需要安装 discourse-ai 插件才能使用此功能。" 36 | ai_helper_required: '您需要配置 AI 助手才能使用此功能。' 37 | not_in_group: 38 | user_not_in_group: "您不属于允许翻译的群组。" 39 | poster_not_in_group: "帖子不是由允许的群组中的用户发布的。" 40 | dashboard: 41 | problem: 42 | missing_translator_api_key: "%{provider} 被用作翻译器,但未提供 %{key}。请参阅网站设置。" 43 | translator_error: "%{provider} 报告了代码为 %{code} 的翻译错误:%{message}" 44 | -------------------------------------------------------------------------------- /config/locales/server.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | DiscourseTranslator::Engine.routes.draw do 4 | post "/translate" => "translator#translate", :format => :json 5 | end 6 | 7 | Discourse::Application.routes.draw { mount ::DiscourseTranslator::Engine, at: "/translator" } 8 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | discourse_translator: 2 | translator_enabled: 3 | default: false 4 | client: true 5 | translator_provider: 6 | default: 'Microsoft' 7 | client: true 8 | type: enum 9 | choices: 10 | - Microsoft 11 | - Google 12 | - Amazon 13 | - Yandex 14 | - LibreTranslate 15 | translator_azure_subscription_key: 16 | default: '' 17 | translator_azure_region: 18 | default: 'global' 19 | client: true 20 | type: enum 21 | choices: # Valid choices retrieved from https://docs.microsoft.com/en-us/azure/cognitive-services/translator/reference/v3-0-reference#authenticating-with-a-multi-service-resource 22 | - global 23 | - australiaeast 24 | - brazilsouth 25 | - canadacentral 26 | - centralindia 27 | - centralus 28 | - centraluseuap 29 | - eastasia 30 | - eastus 31 | - eastus2 32 | - francecentral 33 | - japaneast 34 | - japanwest 35 | - koreacentral 36 | - northcentralus 37 | - northeurope 38 | - southafricanort 39 | - southcentralus 40 | - southeastasia 41 | - uksouth 42 | - westcentralus 43 | - westeurope 44 | - westus 45 | - westus2 46 | translator_azure_custom_subdomain: 47 | default: "" 48 | translator_aws_region: 49 | default: 'us-east-1' 50 | client: true 51 | type: enum 52 | choices: # Valid choices retrieved from https://docs.aws.amazon.com/general/latest/gr/rande.html#translate_region 53 | - us-east-2 54 | - us-east-1 55 | - us-west-1 56 | - us-west-2 57 | - af-south-1 58 | - ap-east-1 59 | - ap-south-1 60 | - ap-northeast-3 61 | - ap-northeast-2 62 | - ap-southeast-1 63 | - ap-southeast-2 64 | - ap-northeast-1 65 | - ca-central-1 66 | - cn-north-1 67 | - cn-northwest-1 68 | - eu-central-1 69 | - eu-west-1 70 | - eu-west-2 71 | - eu-south-1 72 | - eu-west-3 73 | - eu-north-1 74 | - me-south-1 75 | - sa-east-1 76 | translator_aws_key_id: 77 | default: '' 78 | translator_aws_secret_access: 79 | default: '' 80 | translator_aws_iam_role: 81 | default: '' 82 | translator_google_api_key: 83 | default: '' 84 | translator_yandex_api_key: 85 | default: '' 86 | translator_libretranslate_endpoint: 87 | default: '' 88 | translator_libretranslate_api_key: 89 | default: '' 90 | max_characters_per_translation: 91 | default: 5000 92 | client: true 93 | max_translations_per_minute: 94 | default: 3 95 | restrict_translation_by_group: 96 | default: "11" # default group trust_level_1 97 | client: true 98 | type: group_list 99 | restrict_translation_by_poster_group: 100 | default: "" 101 | client: true 102 | type: group_list 103 | -------------------------------------------------------------------------------- /db/migrate/20210429154318_remove_empty_translation_custom_fields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveEmptyTranslationCustomFields < ActiveRecord::Migration[6.0] 4 | def up 5 | execute <<~SQL 6 | DELETE FROM post_custom_fields 7 | WHERE name = 'post_detected_lang' AND value IS NULL 8 | SQL 9 | 10 | execute <<~SQL 11 | DELETE FROM post_custom_fields 12 | WHERE name = 'translated_text' AND value = '{}' 13 | SQL 14 | end 15 | 16 | def down 17 | raise ActiveRecord::IrreversibleMigration 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20230323110557_rename_translator_azure_custom_domain_site_setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameTranslatorAzureCustomDomainSiteSetting < ActiveRecord::Migration[7.0] 4 | def up 5 | execute "UPDATE site_settings SET name = 'translator_azure_custom_subdomain' WHERE name = 'translator_azure_custom_domain'" 6 | end 7 | 8 | def down 9 | execute "UPDATE site_settings SET name = 'translator_azure_custom_domain' WHERE name = 'translator_azure_custom_subdomain'" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250205082400_create_translation_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTranslationTables < ActiveRecord::Migration[7.2] 4 | def change 5 | create_table :discourse_translator_topic_locales do |t| 6 | t.integer :topic_id, null: false 7 | t.string :detected_locale, limit: 20, null: false 8 | t.timestamps 9 | end 10 | 11 | create_table :discourse_translator_topic_translations do |t| 12 | t.integer :topic_id, null: false 13 | t.string :locale, null: false 14 | t.text :translation, null: false 15 | t.timestamps 16 | end 17 | 18 | create_table :discourse_translator_post_locales do |t| 19 | t.integer :post_id, null: false 20 | t.string :detected_locale, limit: 20, null: false 21 | t.timestamps 22 | end 23 | 24 | create_table :discourse_translator_post_translations do |t| 25 | t.integer :post_id, null: false 26 | t.string :locale, null: false 27 | t.text :translation, null: false 28 | t.timestamps 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20250205082401_move_translations_custom_fields_to_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MoveTranslationsCustomFieldsToTable < ActiveRecord::Migration[7.2] 4 | BATCH_SIZE = 1000 5 | 6 | def up 7 | migrate_custom_fields("topic") 8 | migrate_custom_fields("post") 9 | end 10 | 11 | def down 12 | execute "TRUNCATE discourse_translator_topic_locales" 13 | execute "TRUNCATE discourse_translator_topic_translations" 14 | execute "TRUNCATE discourse_translator_post_locales" 15 | execute "TRUNCATE discourse_translator_post_translations" 16 | end 17 | 18 | private 19 | 20 | def migrate_custom_fields(model) 21 | bounds = DB.query_single(<<~SQL, model:) 22 | SELECT 23 | COALESCE(MIN(id), 0) as min_id, 24 | COALESCE(MAX(id), 0) as max_id 25 | FROM #{model}_custom_fields 26 | WHERE name IN ('post_detected_lang', 'translated_text') 27 | SQL 28 | 29 | start_id = bounds[0] 30 | max_id = bounds[1] 31 | 32 | while start_id < max_id 33 | DB.exec(<<~SQL, model:, start_id:, end_id: start_id + BATCH_SIZE) 34 | WITH to_detect AS ( 35 | SELECT #{model}_id, value 36 | FROM #{model}_custom_fields 37 | WHERE name = 'post_detected_lang' 38 | AND length(value) <= 20 39 | AND id >= :start_id 40 | AND id < :end_id 41 | ORDER BY id 42 | ), 43 | do_detect AS ( 44 | INSERT INTO discourse_translator_#{model}_locales (#{model}_id, detected_locale, created_at, updated_at) 45 | SELECT #{model}_id, value, NOW(), NOW() 46 | FROM to_detect 47 | ), 48 | to_translate AS ( 49 | SELECT #{model}_id, value::jsonb, created_at, updated_at 50 | FROM #{model}_custom_fields 51 | WHERE name = 'translated_text' 52 | AND value LIKE '{%}' 53 | AND id >= :start_id 54 | AND id < :end_id 55 | ORDER BY id 56 | ), 57 | do_translate AS ( 58 | INSERT INTO discourse_translator_#{model}_translations (#{model}_id, locale, translation, created_at, updated_at) 59 | SELECT b.#{model}_id, jb.key as locale, jb.value as translation, b.created_at, b.updated_at 60 | FROM to_translate b, jsonb_each_text(b.value) jb 61 | WHERE LENGTH(jb.key) <= 20 62 | ) 63 | SELECT 1 64 | SQL 65 | start_id += BATCH_SIZE 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /db/migrate/20250210171147_hyphenate_translator_locales.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HyphenateTranslatorLocales < ActiveRecord::Migration[7.2] 4 | BATCH_SIZE = 1000 5 | 6 | def up 7 | normalize_table("discourse_translator_topic_translations", "locale") 8 | normalize_table("discourse_translator_post_translations", "locale") 9 | normalize_table("discourse_translator_topic_locales", "detected_locale") 10 | normalize_table("discourse_translator_post_locales", "detected_locale") 11 | end 12 | 13 | def down 14 | raise ActiveRecord::IrreversibleMigration 15 | end 16 | 17 | private 18 | 19 | def normalize_table(table_name, column) 20 | start_id = 0 21 | loop do 22 | result = DB.query_single(<<~SQL, start_id: start_id, batch_size: BATCH_SIZE) 23 | WITH batch AS ( 24 | SELECT id 25 | FROM #{table_name} 26 | WHERE #{column} LIKE '%\\_%' ESCAPE '\\' 27 | AND id > :start_id 28 | ORDER BY id 29 | LIMIT :batch_size 30 | ) 31 | UPDATE #{table_name} 32 | SET #{column} = REGEXP_REPLACE(#{column}, '_', '-') 33 | WHERE id IN (SELECT id FROM batch) 34 | RETURNING id 35 | SQL 36 | 37 | break if result.empty? 38 | start_id = result.max 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /db/migrate/20250224120505_cleanup_ai_translations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CleanupAiTranslations < ActiveRecord::Migration[7.2] 4 | def up 5 | execute <<~SQL 6 | DELETE FROM discourse_translator_topic_translations 7 | WHERE translation LIKE 'To%' 8 | AND translation ILIKE '%translat%' 9 | AND LENGTH(translation) > 100; 10 | SQL 11 | end 12 | 13 | def down 14 | raise ActiveRecord::IrreversibleMigration 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20250227074505_rename_translator_site_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameTranslatorSiteSettings < ActiveRecord::Migration[7.0] 4 | def up 5 | execute <<~SQL 6 | UPDATE site_settings 7 | SET name = 'translator_provider' 8 | WHERE name = 'translator'; 9 | 10 | UPDATE site_settings 11 | SET name = 'experimental_inline_translation' 12 | WHERE name = 'experimental_topic_translation'; 13 | SQL 14 | end 15 | 16 | def down 17 | execute <<~SQL 18 | UPDATE site_settings 19 | SET name = 'translator' 20 | WHERE name = 'translator_provider'; 21 | 22 | UPDATE site_settings 23 | SET name = 'experimental_topic_translation' 24 | WHERE name = 'experimental_inline_translation'; 25 | SQL 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /db/migrate/20250313082243_create_translation_indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTranslationIndexes < ActiveRecord::Migration[7.2] 4 | disable_ddl_transaction! 5 | 6 | def up 7 | remove_index :discourse_translator_topic_translations, 8 | %i[topic_id locale], 9 | unique: true, 10 | algorithm: :concurrently, 11 | if_exists: true 12 | 13 | add_index :discourse_translator_topic_translations, 14 | %i[topic_id locale], 15 | unique: true, 16 | algorithm: :concurrently 17 | 18 | remove_index :discourse_translator_post_translations, 19 | %i[post_id locale], 20 | unique: true, 21 | algorithm: :concurrently, 22 | if_exists: true 23 | 24 | add_index :discourse_translator_post_translations, 25 | %i[post_id locale], 26 | unique: true, 27 | algorithm: :concurrently 28 | end 29 | 30 | def down 31 | raise ActiveRecord::IrreversibleMigration 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /db/migrate/20250429102109_rename_site_setting_content_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameSiteSettingContentTranslation < ActiveRecord::Migration[7.2] 4 | def up 5 | execute <<~SQL 6 | UPDATE site_settings 7 | SET name = 'experimental_content_translation' 8 | WHERE name = 'experimental_category_translation'; 9 | SQL 10 | end 11 | 12 | def down 13 | execute <<~SQL 14 | UPDATE site_settings 15 | SET name = 'experimental_category_translation' 16 | WHERE name = 'experimental_content_translation'; 17 | SQL 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20250522045138_cleanup_amazon_translations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CleanupAmazonTranslations < ActiveRecord::Migration[7.2] 4 | def up 5 | provider = 6 | DB.query_single("SELECT value FROM site_settings WHERE name = 'translator_provider'").first 7 | if provider == "Amazon" 8 | execute <<~SQL 9 | DELETE FROM discourse_translator_post_translations 10 | WHERE translation LIKE '{:translated_text%' 11 | SQL 12 | 13 | execute <<~SQL 14 | DELETE FROM discourse_translator_topic_translations 15 | WHERE translation LIKE '{:translated_text%' 16 | SQL 17 | end 18 | end 19 | 20 | def down 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20250528040217_rename_translation_target_languages_to_content_localization_supported_locales.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameTranslationTargetLanguagesToContentLocalizationSupportedLocales < ActiveRecord::Migration[ 4 | 7.2 5 | ] 6 | def up 7 | setting_exists = 8 | DB.query_single( 9 | "SELECT 1 FROM site_settings WHERE name = 'experimental_content_localization_supported_locales' LIMIT 1", 10 | ).present? 11 | 12 | if setting_exists 13 | execute "DELETE FROM site_settings WHERE name = 'automatic_translation_target_languages'" 14 | else 15 | execute "UPDATE site_settings SET name = 'experimental_content_localization_supported_locales' WHERE name = 'automatic_translation_target_languages'" 16 | end 17 | end 18 | 19 | def down 20 | raise ActiveRecord::IrreversibleMigration 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20250528105453_disable_translator_discourse_ai.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DisableTranslatorDiscourseAi < ActiveRecord::Migration[7.2] 4 | def up 5 | execute(<<~SQL) 6 | UPDATE site_settings SET value = 'f' 7 | WHERE name = 'translator_enabled' 8 | AND EXISTS(SELECT 1 FROM site_settings WHERE name = 'translator_provider' AND value = 'DiscourseAi') 9 | SQL 10 | end 11 | 12 | def down 13 | raise ActiveRecord::IrreversibleMigration 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import DiscourseRecommended from "@discourse/lint-configs/eslint"; 2 | 3 | export default [...DiscourseRecommended]; 4 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/discourse-translator/a16e7c5895a1cf734d3a4ecfab68f206fa24fdd5/example.gif -------------------------------------------------------------------------------- /lib/discourse_translator/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseTranslator 3 | class Engine < ::Rails::Engine 4 | engine_name PLUGIN_NAME 5 | isolate_namespace DiscourseTranslator 6 | config.autoload_paths << File.join(config.root, "lib") 7 | scheduled_job_dir = "#{config.root}/app/jobs/scheduled" 8 | config.to_prepare do 9 | Rails.autoloaders.main.eager_load_dir(scheduled_job_dir) if Dir.exist?(scheduled_job_dir) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/discourse_translator/extensions/guardian_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module DiscourseTranslator 3 | module Extensions 4 | module GuardianExtension 5 | POST_DETECTION_BUFFER = 10.seconds 6 | 7 | def user_group_allow_translate? 8 | return false if !current_user 9 | current_user.in_any_groups?(SiteSetting.restrict_translation_by_group_map) 10 | end 11 | 12 | def poster_group_allow_translate?(post) 13 | return false if !current_user 14 | return true if SiteSetting.restrict_translation_by_poster_group_map.empty? 15 | return false if post.user.nil? 16 | post.user.in_any_groups?(SiteSetting.restrict_translation_by_poster_group_map) 17 | end 18 | 19 | def can_detect_language?(post) 20 | ( 21 | SiteSetting.restrict_translation_by_poster_group_map.empty? || 22 | post&.user&.in_any_groups?(SiteSetting.restrict_translation_by_poster_group_map) 23 | ) && post.raw.present? && post.post_type != Post.types[:small_action] 24 | end 25 | 26 | def can_translate?(post) 27 | return false if post.user&.bot? 28 | return false if !user_group_allow_translate? 29 | 30 | # we want to return false if the post is updated within a short buffer ago, 31 | # this prevents the 🌐from appearing and then disappearing if the lang is same as user's lang 32 | return false if post.updated_at > POST_DETECTION_BUFFER.ago && post.detected_locale.blank? 33 | 34 | locale = I18n.locale 35 | return false if post.locale_matches?(locale) 36 | poster_group_allow_translate?(post) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/discourse_translator/extensions/post_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Extensions 5 | module PostExtension 6 | extend ActiveSupport::Concern 7 | prepended { before_update :clear_translations, if: :raw_changed? } 8 | include Translatable 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/discourse_translator/extensions/topic_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | module Extensions 5 | module TopicExtension 6 | extend ActiveSupport::Concern 7 | prepended { before_update :clear_translations, if: :title_changed? } 8 | include Translatable 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/discourse_translator/parallel_text_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class ParallelTextTranslation 5 | def inject(plugin) 6 | plugin.on(:post_process_cooked) do |_, post| 7 | if Guardian.new.can_detect_language?(post) && post.user_id > 0 8 | Jobs.enqueue(:detect_translatable_language, type: "Post", translatable_id: post.id) 9 | end 10 | end 11 | 12 | plugin.on(:topic_created) do |topic| 13 | if Guardian.new.can_detect_language?(topic.first_post) && topic.user_id > 0 14 | Jobs.enqueue(:detect_translatable_language, type: "Topic", translatable_id: topic.id) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/discourse_translator/translatable_languages_setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseTranslator 4 | class TranslatableLanguagesSetting < LocaleSiteSetting 5 | def self.printable_values 6 | values.map { |v| v[:value] } 7 | end 8 | 9 | @lock = Mutex.new 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@discourse/lint-configs": "2.20.0", 5 | "ember-template-lint": "7.7.0", 6 | "eslint": "9.27.0", 7 | "prettier": "3.5.3", 8 | "stylelint": "16.19.1" 9 | }, 10 | "engines": { 11 | "node": ">= 22", 12 | "npm": "please-use-pnpm", 13 | "yarn": "please-use-pnpm", 14 | "pnpm": "9.x" 15 | }, 16 | "packageManager": "pnpm@9.15.5" 17 | } 18 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # name: discourse-translator 4 | # about: Translates posts on Discourse using Microsoft, Google, Yandex, LibreTranslate, or Discourse AI translation APIs. 5 | # meta_topic_id: 32630 6 | # version: 0.3.0 7 | # authors: Alan Tan 8 | # url: https://github.com/discourse/discourse-translator 9 | 10 | gem "aws-sdk-translate", "1.35.0", require: false 11 | 12 | enabled_site_setting :translator_enabled 13 | register_asset "stylesheets/common/post.scss" 14 | register_asset "stylesheets/common/common.scss" 15 | 16 | module ::DiscourseTranslator 17 | PLUGIN_NAME = "discourse-translator".freeze 18 | 19 | LANG_DETECT_NEEDED = "lang_detect_needed".freeze 20 | end 21 | 22 | require_relative "lib/discourse_translator/engine" 23 | 24 | after_initialize do 25 | register_problem_check ProblemCheck::MissingTranslatorApiKey 26 | register_problem_check ProblemCheck::TranslatorError 27 | 28 | reloadable_patch do 29 | Guardian.prepend(DiscourseTranslator::Extensions::GuardianExtension) 30 | Post.prepend(DiscourseTranslator::Extensions::PostExtension) 31 | Topic.prepend(DiscourseTranslator::Extensions::TopicExtension) 32 | end 33 | 34 | add_to_serializer :post, :can_translate do 35 | scope.can_translate?(object) 36 | end 37 | 38 | DiscourseTranslator::ParallelTextTranslation.new.inject(self) 39 | end 40 | -------------------------------------------------------------------------------- /setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discourse/discourse-translator/a16e7c5895a1cf734d3a4ecfab68f206fa24fdd5/setup.png -------------------------------------------------------------------------------- /spec/fabricators/post_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Fabricator(:post_locale, from: DiscourseTranslator::PostLocale) do 3 | post 4 | detected_locale { %w[en de es en-GB ja pt pt-BR].sample } 5 | end 6 | -------------------------------------------------------------------------------- /spec/fabricators/post_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Fabricator(:post_translation, from: DiscourseTranslator::PostTranslation) do 3 | post 4 | locale { %w[en de es en-GB ja pt pt-BR].sample } 5 | translation do |attrs| 6 | { 7 | "en" => "Hello", 8 | "de" => "Hallo", 9 | "es" => "Hola", 10 | "en-GB" => "Hello", 11 | "ja" => "こんにちは", 12 | "pt" => "Olá", 13 | "pt-BR" => "Olá", 14 | }[ 15 | attrs[:locale] 16 | ] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/fabricators/topic_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Fabricator(:topic_locale, from: DiscourseTranslator::TopicLocale) do 3 | topic 4 | detected_locale { %w[en de es en-GB ja pt pt-BR].sample } 5 | end 6 | -------------------------------------------------------------------------------- /spec/fabricators/topic_translation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Fabricator(:topic_translation, from: DiscourseTranslator::TopicTranslation) do 3 | topic 4 | locale { %w[en de es en-GB ja pt pt-BR].sample } 5 | translation do |attrs| 6 | { 7 | "en" => "Hello", 8 | "de" => "Hallo", 9 | "es" => "Hola", 10 | "en-GB" => "Hello", 11 | "ja" => "こんにちは", 12 | "pt" => "Olá", 13 | "pt-BR" => "Olá", 14 | }[ 15 | attrs[:locale] 16 | ] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/jobs/detect_translatable_language_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "aws-sdk-translate" 4 | 5 | describe Jobs::DetectTranslatableLanguage do 6 | fab!(:post) 7 | fab!(:topic) 8 | let!(:job) { Jobs::DetectTranslatableLanguage.new } 9 | 10 | before do 11 | SiteSetting.translator_enabled = true 12 | SiteSetting.translator_provider = "Amazon" 13 | client = Aws::Translate::Client.new(stub_responses: true) 14 | client.stub_responses( 15 | :translate_text, 16 | { 17 | translated_text: "translated text", 18 | source_language_code: "en", 19 | target_language_code: "jp", 20 | }, 21 | ) 22 | Aws::Translate::Client.stubs(:new).returns(client) 23 | end 24 | 25 | it "does nothing when type is not post or topic" do 26 | expect { job.execute(type: "X", translatable_id: 1) }.not_to raise_error 27 | end 28 | 29 | it "does nothing when id is not int" do 30 | expect { job.execute(type: "Post", translatable_id: "A") }.not_to raise_error 31 | end 32 | 33 | it "updates detected locale" do 34 | job.execute(type: "Post", translatable_id: post.id) 35 | job.execute(type: "Topic", translatable_id: topic.id) 36 | 37 | expect(post.detected_locale).not_to be_nil 38 | expect(topic.detected_locale).not_to be_nil 39 | end 40 | 41 | it "does not update detected locale the translator is disabled" do 42 | SiteSetting.translator_enabled = false 43 | 44 | job.execute(type: "Post", translatable_id: post.id) 45 | job.execute(type: "Topic", translatable_id: topic.id) 46 | 47 | expect(post.detected_locale).to be_nil 48 | expect(topic.detected_locale).to be_nil 49 | end 50 | 51 | it "skips content that no longer exist" do 52 | non_existent_id = -1 53 | 54 | expect { job.execute(type: "Post", translatable_id: non_existent_id) }.not_to raise_error 55 | expect { job.execute(type: "Topic", translatable_id: non_existent_id) }.not_to raise_error 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/models/post_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe Post do 6 | before do 7 | SiteSetting.translator_enabled = true 8 | SiteSetting.create_topic_allowed_groups = Group::AUTO_GROUPS[:everyone] 9 | end 10 | 11 | describe "translatable" do 12 | fab!(:post) 13 | 14 | it "should reset translation data when post title has been updated" do 15 | Fabricate(:post_translation, post:) 16 | Fabricate(:post_locale, post:) 17 | post.update!(raw: "this is an updated title") 18 | 19 | expect(DiscourseTranslator::PostLocale.where(post:)).to be_empty 20 | expect(DiscourseTranslator::PostLocale.find_by(post:)).to be_nil 21 | end 22 | 23 | describe "#set_translation" do 24 | it "creates new translation" do 25 | post.set_translation("en", "Hello") 26 | 27 | translation = post.translations.find_by(locale: "en") 28 | expect(translation.translation).to eq("Hello") 29 | end 30 | 31 | it "updates existing translation" do 32 | post.set_translation("en", "Hello") 33 | post.set_translation("en", "Updated hello") 34 | 35 | expect(post.translations.where(locale: "en").count).to eq(1) 36 | expect(post.translation_for("en")).to eq("Updated hello") 37 | end 38 | 39 | it "converts underscore to hyphen in locale" do 40 | post.set_translation("en_US", "Hello") 41 | 42 | expect(post.translations.find_by(locale: "en-US")).to be_present 43 | expect(post.translations.find_by(locale: "en_US")).to be_nil 44 | end 45 | end 46 | 47 | describe "#translation_for" do 48 | it "returns nil when translation doesn't exist" do 49 | expect(post.translation_for("fr")).to be_nil 50 | end 51 | 52 | it "returns translation when it exists" do 53 | post.set_translation("es", "Hola") 54 | expect(post.translation_for("es")).to eq("Hola") 55 | end 56 | end 57 | 58 | describe "#set_locale" do 59 | it "creates new locale" do 60 | post.set_detected_locale("en-US") 61 | expect(post.content_locale.detected_locale).to eq("en-US") 62 | end 63 | 64 | it "converts underscore to hyphen" do 65 | post.set_detected_locale("en_US") 66 | expect(post.content_locale.detected_locale).to eq("en-US") 67 | end 68 | end 69 | end 70 | 71 | describe "queueing post for language detection" do 72 | fab!(:group) 73 | fab!(:user) { Fabricate(:user, groups: [group]) } 74 | 75 | it "queues the post for language detection when user and posts are in the right group" do 76 | SiteSetting.restrict_translation_by_poster_group = "#{group.id}" 77 | 78 | post = 79 | PostCreator.new( 80 | user, 81 | { 82 | title: "a topic about cats", 83 | raw: "tomtom is a cat", 84 | category: Fabricate(:category).id, 85 | }, 86 | ).create 87 | CookedPostProcessor.new(post).post_process 88 | 89 | expect_job_enqueued( 90 | job: :detect_translatable_language, 91 | args: { 92 | type: "Post", 93 | translatable_id: post.id, 94 | }, 95 | ) 96 | expect_job_enqueued( 97 | job: :detect_translatable_language, 98 | args: { 99 | type: "Topic", 100 | translatable_id: post.topic_id, 101 | }, 102 | ) 103 | end 104 | 105 | it "does not queue bot posts for language detection" do 106 | SiteSetting.restrict_translation_by_poster_group = Group::AUTO_GROUPS[:everyone] 107 | post = 108 | PostCreator.new( 109 | Discourse.system_user, 110 | { title: "hello world topic", raw: "my name is cat", category: Fabricate(:category).id }, 111 | ).create 112 | 113 | expect( 114 | Discourse.redis.sismember(DiscourseTranslator::LANG_DETECT_NEEDED, post.id), 115 | ).to be_falsey 116 | end 117 | 118 | context "when user and posts are not in the right group" do 119 | it "does not queue the post for language detection" do 120 | SiteSetting.restrict_translation_by_poster_group = "#{group.id + 1}" 121 | post = 122 | PostCreator.new( 123 | user, 124 | { 125 | title: "hello world topic", 126 | raw: "my name is fred", 127 | category: Fabricate(:category).id, 128 | }, 129 | ).create 130 | 131 | expect( 132 | Discourse.redis.sismember(DiscourseTranslator::LANG_DETECT_NEEDED, post.id), 133 | ).to be_falsey 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/models/topic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Topic do 6 | describe "translatable" do 7 | fab!(:topic) 8 | 9 | before { SiteSetting.translator_enabled = true } 10 | 11 | it "should reset translation data when topic title has been updated" do 12 | Fabricate(:topic_translation, topic:) 13 | Fabricate(:topic_locale, topic:) 14 | topic.update!(title: "this is an updated title") 15 | 16 | expect(DiscourseTranslator::TopicLocale.where(topic:)).to be_empty 17 | expect(DiscourseTranslator::TopicLocale.find_by(topic:)).to be_nil 18 | end 19 | 20 | describe "#set_translation" do 21 | it "creates new translation" do 22 | topic.set_translation("en", "Hello") 23 | 24 | translation = topic.translations.find_by(locale: "en") 25 | expect(translation.translation).to eq("Hello") 26 | end 27 | 28 | it "updates existing translation" do 29 | topic.set_translation("en", "Hello") 30 | topic.set_translation("en", "Updated hello") 31 | 32 | expect(topic.translations.where(locale: "en").count).to eq(1) 33 | expect(topic.translation_for("en")).to eq("Updated hello") 34 | end 35 | 36 | it "converts underscore to hyphen in locale" do 37 | topic.set_translation("en_US", "Hello") 38 | 39 | expect(topic.translations.find_by(locale: "en-US")).to be_present 40 | expect(topic.translations.find_by(locale: "en_US")).to be_nil 41 | end 42 | end 43 | 44 | describe "#translation_for" do 45 | it "returns nil when translation doesn't exist" do 46 | expect(topic.translation_for("fr")).to be_nil 47 | end 48 | 49 | it "returns translation when it exists" do 50 | topic.set_translation("es", "Hola") 51 | expect(topic.translation_for("es")).to eq("Hola") 52 | end 53 | end 54 | 55 | describe "#set_locale" do 56 | it "creates new locale" do 57 | topic.set_detected_locale("en-US") 58 | expect(topic.content_locale.detected_locale).to eq("en-US") 59 | end 60 | 61 | it "converts underscore to hyphen" do 62 | topic.set_detected_locale("en_US") 63 | expect(topic.content_locale.detected_locale).to eq("en-US") 64 | end 65 | end 66 | 67 | describe "#locale_matches?" do 68 | it "returns false when detected locale is blank" do 69 | expect(topic.locale_matches?("en-US")).to eq(false) 70 | end 71 | 72 | it "returns false when locale is blank" do 73 | topic.set_detected_locale("en-US") 74 | expect(topic.locale_matches?(nil)).to eq(false) 75 | end 76 | 77 | [:en, "en", "en-US", :en_US, "en-GB", "en_GB", :en_GB].each do |locale| 78 | it "returns true when matching normalised #{locale} to \"en\"" do 79 | topic.set_detected_locale("en") 80 | expect(topic.locale_matches?(locale)).to eq(true) 81 | end 82 | end 83 | 84 | ["en-GB", "en_GB", :en_GB].each do |locale| 85 | it "returns true when matching #{locale} to \"en_GB\"" do 86 | topic.set_detected_locale("en_GB") 87 | expect(topic.locale_matches?(locale, normalise_region: false)).to eq(true) 88 | end 89 | end 90 | 91 | [:en, "en", "en-US", :en_US].each do |locale| 92 | it "returns false when matching #{locale} to \"en_GB\"" do 93 | topic.set_detected_locale("en_GB") 94 | expect(topic.locale_matches?(locale, normalise_region: false)).to eq(false) 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/requests/translator_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | module DiscourseTranslator 6 | describe TranslatorController do 7 | fab!(:user) { Fabricate(:trust_level_1) } 8 | 9 | before do 10 | SiteSetting.translator_enabled = true 11 | SiteSetting.translator_provider = "Microsoft" 12 | SiteSetting.restrict_translation_by_group = "#{Group.find_by(name: "trust_level_1").id}" 13 | end 14 | 15 | shared_examples "translation_successful" do 16 | it "returns the translated text" do 17 | DiscourseTranslator::Provider::Microsoft 18 | .expects(:translate) 19 | .with(reply) 20 | .returns(%w[ja ニャン猫]) 21 | if reply.is_first_post? 22 | DiscourseTranslator::Provider::Microsoft 23 | .expects(:translate) 24 | .with(reply.topic) 25 | .returns(%w[ja タイトル]) 26 | end 27 | 28 | post "/translator/translate.json", params: { post_id: reply.id } 29 | 30 | expect(response).to have_http_status(:ok) 31 | expect(response.body).to eq( 32 | { translation: "ニャン猫", detected_lang: "ja", title_translation: "タイトル" }.to_json, 33 | ) 34 | end 35 | end 36 | 37 | shared_examples "deny_request_to_translate" do 38 | it "should deny request to translate" do 39 | post "/translator/translate.json", params: { post_id: reply.id } 40 | 41 | expect(response).to have_http_status(:forbidden) 42 | end 43 | end 44 | 45 | describe "#translate" do 46 | describe "anon user" do 47 | it "should not allow translation of posts" do 48 | post "/translator/translate.json", params: { post_id: 1 } 49 | 50 | expect(response).to have_http_status(:forbidden) 51 | end 52 | end 53 | 54 | describe "logged in user" do 55 | before { sign_in(user) } 56 | 57 | let!(:poster) do 58 | poster = Fabricate(:user) 59 | poster.group_users << Fabricate(:group_user, user: user, group: Group[:trust_level_2]) 60 | poster 61 | end 62 | 63 | describe "when config translator_enabled disabled" do 64 | before { SiteSetting.translator_enabled = false } 65 | 66 | it "should deny request to translate" do 67 | post "/translator/translate.json", params: { post_id: 1 } 68 | 69 | expect(response).to have_http_status(:not_found) 70 | end 71 | end 72 | 73 | describe "when enabled" do 74 | let(:reply) { Fabricate(:post, user: poster) } 75 | 76 | it "raises an error with a missing parameter" do 77 | post "/translator/translate.json" 78 | expect(response).to have_http_status(:bad_request) 79 | end 80 | 81 | it "raises the right error when post_id is invalid" do 82 | post "/translator/translate.json", params: { post_id: -1 } 83 | expect(response).to have_http_status(:bad_request) 84 | end 85 | 86 | it "raises the right error when post is inaccessible" do 87 | mypost = Fabricate(:private_message_post) 88 | post "/translator/translate.json", params: { post_id: mypost.id } 89 | expect(response.status).to eq(403) 90 | end 91 | 92 | it "rescues translator errors" do 93 | DiscourseTranslator::Provider::Microsoft.expects(:translate).raises( 94 | ::DiscourseTranslator::Provider::TranslatorError, 95 | ) 96 | 97 | post "/translator/translate.json", params: { post_id: reply.id } 98 | 99 | expect(response).to have_http_status(:unprocessable_entity) 100 | end 101 | 102 | describe "all groups can translate" do 103 | include_examples "translation_successful" 104 | end 105 | 106 | describe "user is in a allowlisted group" do 107 | fab!(:admin) 108 | 109 | before do 110 | SiteSetting.restrict_translation_by_group = 111 | "#{Group.find_by(name: "admins").id}|not_in_the_list" 112 | 113 | log_in_user(admin) 114 | end 115 | 116 | include_examples "translation_successful" 117 | end 118 | 119 | describe "user is not in a allowlisted group" do 120 | before do 121 | SiteSetting.restrict_translation_by_group = "#{Group::AUTO_GROUPS[:moderators]}" 122 | end 123 | 124 | include_examples "deny_request_to_translate" 125 | end 126 | 127 | describe "restrict_translation_by_poster_group" do 128 | fab!(:group) 129 | fab!(:user) { Fabricate(:user, groups: [group]) } 130 | 131 | before do 132 | SiteSetting.restrict_translation_by_group = "#{group.id}|" 133 | 134 | log_in_user(user) 135 | end 136 | describe "post made by an user in a allowlisted group" do 137 | before do 138 | SiteSetting.restrict_translation_by_poster_group = "#{poster.groups.first.id}" 139 | end 140 | include_examples "translation_successful" 141 | end 142 | 143 | describe "post made by an user not in a allowlisted group" do 144 | before do 145 | SiteSetting.restrict_translation_by_poster_group = 146 | "#{Group::AUTO_GROUPS[:moderators]}" 147 | end 148 | include_examples "deny_request_to_translate" 149 | end 150 | end 151 | end 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/serializers/post_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe PostSerializer do 6 | fab!(:group) 7 | fab!(:user) { Fabricate(:user, locale: "en", groups: [group]) } 8 | 9 | fab!(:post_user_group) { Fabricate(:group) } 10 | fab!(:post_user) { Fabricate(:user, locale: "en", groups: [post_user_group]) } 11 | fab!(:post) { Fabricate(:post, user: post_user) } 12 | 13 | describe "#can_translate" do 14 | it "returns false when translator disabled" do 15 | SiteSetting.translator_enabled = false 16 | serializer = PostSerializer.new(post, scope: Guardian.new) 17 | 18 | expect(serializer.can_translate).to eq(false) 19 | end 20 | 21 | describe "when translator enabled" do 22 | before do 23 | SiteSetting.translator_enabled = true 24 | SiteSetting.restrict_translation_by_group = "#{Group::AUTO_GROUPS[:everyone]}" 25 | SiteSetting.restrict_translation_by_poster_group = "" 26 | end 27 | let(:serializer) { PostSerializer.new(post, scope: Guardian.new) } 28 | 29 | it "cannot translate for anon" do 30 | expect(serializer.can_translate).to eq(false) 31 | end 32 | 33 | describe "logged in user" do 34 | let(:serializer) { PostSerializer.new(post, scope: Guardian.new(user)) } 35 | 36 | it "cannot translate when user is not in restrict_translation_by_group" do 37 | SiteSetting.restrict_translation_by_group = "#{group.id + 1}" 38 | 39 | expect(serializer.can_translate).to eq(false) 40 | end 41 | 42 | describe "user is in restrict_translation_by_group" do 43 | describe "post author in restrict_translation_by_poster_group and locale is :xx" do 44 | it "can translate when post detected locale does not match i18n locale" do 45 | SiteSetting.restrict_translation_by_group = "#{group.id}" 46 | SiteSetting.restrict_translation_by_poster_group = "#{post_user_group.id}" 47 | I18n.stubs(:locale).returns(:pt) 48 | 49 | post.set_detected_locale("jp") 50 | 51 | expect(serializer.can_translate).to eq(true) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/services/amazon_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe DiscourseTranslator::Provider::Amazon do 4 | def new_translate_client 5 | client = Aws::Translate::Client.new(stub_responses: true) 6 | Aws::Translate::Client.stubs(:new).returns(client) 7 | client 8 | end 9 | 10 | describe ".truncate" do 11 | it "truncates text to 10000 bytes" do 12 | text = "こんにちは" * (described_class::MAX_BYTES / 5) 13 | truncated = described_class.truncate(text) 14 | 15 | expect(truncated.bytesize).to be <= described_class::MAX_BYTES 16 | expect(truncated.valid_encoding?).to eq(true) 17 | expect(truncated[-1]).to eq "に" 18 | end 19 | end 20 | 21 | describe ".detect" do 22 | let(:post) { Fabricate(:post) } 23 | let(:text) { described_class.truncate(post.cooked) } 24 | let(:detected_lang) { "en" } 25 | 26 | it "should store the detected language" do 27 | client = new_translate_client 28 | client.stub_responses( 29 | :translate_text, 30 | { 31 | translated_text: "translated text", 32 | source_language_code: detected_lang, 33 | target_language_code: "de", 34 | }, 35 | ) 36 | 37 | expect(described_class.detect(post)).to eq(detected_lang) 38 | 39 | expect(post.detected_locale).to eq(detected_lang) 40 | end 41 | 42 | it "should fail graciously when the cooked translated text is blank" do 43 | post.raw = "" 44 | expect(described_class.detect(post)).to be_nil 45 | end 46 | end 47 | 48 | describe ".translate_post!" do 49 | fab!(:post) { Fabricate(:post, raw: "rawraw rawrawraw", cooked: "coocoo coococooo") } 50 | 51 | before do 52 | post.set_detected_locale("en") 53 | I18n.locale = :de 54 | end 55 | 56 | it "translates post with raw" do 57 | client = new_translate_client 58 | client.stub_responses( 59 | :translate_text, 60 | { 61 | translated_text: "translated text", 62 | source_language_code: "en", 63 | target_language_code: "de", 64 | }, 65 | ) 66 | 67 | expect(described_class.translate_post!(post, :de, { raw: true })).to eq("translated text") 68 | end 69 | 70 | it "translates post with cooked" do 71 | client = new_translate_client 72 | client.stub_responses( 73 | :translate_text, 74 | { 75 | translated_text: "translated text", 76 | source_language_code: "en", 77 | target_language_code: "de", 78 | }, 79 | ) 80 | 81 | expect(described_class.translate_post!(post, :de, { cooked: true })).to eq("translated text") 82 | end 83 | 84 | it "translates post with raw when unspecified" do 85 | client = new_translate_client 86 | client.stub_responses( 87 | :translate_text, 88 | { 89 | translated_text: "translated text", 90 | source_language_code: "en", 91 | target_language_code: "de", 92 | }, 93 | ) 94 | 95 | expect(described_class.translate_post!(post, :de)).to eq("translated text") 96 | end 97 | end 98 | 99 | describe ".translate_topic!" do 100 | fab!(:topic) 101 | 102 | before do 103 | topic.set_detected_locale("en") 104 | I18n.locale = :de 105 | end 106 | 107 | it "translates topic's title" do 108 | client = new_translate_client 109 | client.stub_responses( 110 | :translate_text, 111 | { 112 | translated_text: "translated text", 113 | source_language_code: "en", 114 | target_language_code: "de", 115 | }, 116 | ) 117 | 118 | expect(described_class.translate_topic!(topic, :de)).to eq("translated text") 119 | end 120 | end 121 | 122 | describe ".translate_text!" do 123 | before { I18n.locale = :es } 124 | 125 | it "translates the text" do 126 | client = new_translate_client 127 | client.stub_responses( 128 | :translate_text, 129 | { 130 | translated_text: "translated text", 131 | source_language_code: "en", 132 | target_language_code: "es", 133 | }, 134 | ) 135 | 136 | expect(described_class.translate_text!("derp")).to eq("translated text") 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/services/base_provider_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe DiscourseTranslator::Provider::BaseProvider do 6 | class TestTranslator < DiscourseTranslator::Provider::BaseProvider 7 | SUPPORTED_LANG_MAPPING = { en: "en", ar: "ar", es_MX: "es-MX", pt: "pt" } 8 | end 9 | 10 | class EmptyTranslator < DiscourseTranslator::Provider::BaseProvider 11 | end 12 | 13 | describe ".language_supported?" do 14 | it "raises an error when the method is not implemented" do 15 | expect { EmptyTranslator.language_supported?("en") }.to raise_error(NotImplementedError) 16 | end 17 | 18 | it "returns false when the locale is not supported" do 19 | I18n.stubs(:locale).returns(:xx) 20 | expect(TestTranslator.language_supported?("en")).to eq(false) 21 | end 22 | 23 | it "returns true when the detected language is not the current locale" do 24 | I18n.locale = :pt 25 | expect(TestTranslator.language_supported?("en")).to eq(true) 26 | expect(TestTranslator.language_supported?("ar")).to eq(true) 27 | expect(TestTranslator.language_supported?("es-MX")).to eq(true) 28 | end 29 | 30 | it "returns false when the detected language is the detected locale" do 31 | I18n.locale = :pt 32 | expect(TestTranslator.language_supported?("pt")).to eq(false) 33 | end 34 | end 35 | 36 | describe ".text_for_detection" do 37 | fab!(:topic) { Fabricate(:topic, title: "it is a fine day") } 38 | fab!(:post) { Fabricate(:post, topic:) } 39 | 40 | it "truncates to DETECTION_CHAR_LIMIT of 1000" do 41 | post.raw = "a" * 1001 42 | expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(post).length).to eq( 43 | 1000, 44 | ) 45 | end 46 | 47 | it "returns the text if it's less than DETECTION_CHAR_LIMIT" do 48 | text = "a" * 999 49 | post.raw = text 50 | expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(post)).to eq(text) 51 | end 52 | 53 | it "appends some text from the first post for topics" do 54 | topic.first_post.raw = "a" * 999 55 | expected = (topic.title + " " + topic.first_post.raw).truncate(1000) 56 | expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(topic)).to eq(expected) 57 | end 58 | end 59 | 60 | describe ".text_for_translation" do 61 | fab!(:post) 62 | 63 | it "truncates to max_characters_per_translation" do 64 | post.cooked = "a" * (SiteSetting.max_characters_per_translation + 1) 65 | expect(DiscourseTranslator::Provider::BaseProvider.text_for_translation(post).length).to eq( 66 | SiteSetting.max_characters_per_translation, 67 | ) 68 | end 69 | 70 | it "uses raw if required" do 71 | post.raw = "a" * (SiteSetting.max_characters_per_translation + 1) 72 | expect( 73 | DiscourseTranslator::Provider::BaseProvider.text_for_translation(post, raw: true).length, 74 | ).to eq(SiteSetting.max_characters_per_translation) 75 | end 76 | end 77 | 78 | describe ".detect" do 79 | fab!(:post) 80 | 81 | it "returns nil when text is blank" do 82 | post.raw = "" 83 | expect(TestTranslator.detect(post)).to be_nil 84 | end 85 | 86 | it "returns cached detection if available" do 87 | post.set_detected_locale("en") 88 | 89 | TestTranslator.expects(:detect!).never 90 | expect(TestTranslator.detect(post)).to eq("en") 91 | end 92 | 93 | it "performs detection if no cached result" do 94 | TestTranslator.expects(:detect!).with(post).returns("es") 95 | 96 | expect(TestTranslator.detect(post)).to eq("es") 97 | end 98 | end 99 | 100 | describe ".translate" do 101 | fab!(:post) 102 | 103 | it "returns original text when detected language matches current locale" do 104 | post.set_detected_locale(I18n.locale.to_s) 105 | post.cooked = "hello" 106 | 107 | expect(TestTranslator.translate(post)).to eq(%w[en hello]) 108 | end 109 | 110 | it "returns cached translation if available" do 111 | post.set_detected_locale("es") 112 | post.set_translation(I18n.locale, "hello") 113 | 114 | expect(TestTranslator.translate(post)).to eq(%w[es hello]) 115 | end 116 | 117 | it "raises error when translation not supported" do 118 | post.set_detected_locale("xx") 119 | TestTranslator.expects(:translate_supported?).with("xx", :en).returns(false) 120 | 121 | expect { TestTranslator.translate(post) }.to raise_error( 122 | DiscourseTranslator::Provider::TranslatorError, 123 | ) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/services/google_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe DiscourseTranslator::Provider::Google do 4 | let(:api_key) { "12345" } 5 | let(:mock_response) { Struct.new(:status, :body) } 6 | 7 | before do 8 | SiteSetting.translator_enabled = true 9 | SiteSetting.translator_google_api_key = api_key 10 | end 11 | 12 | def stub_translate_request(text, target_locale, translated_text) 13 | stub_request(:post, DiscourseTranslator::Provider::Google::TRANSLATE_URI).with( 14 | body: URI.encode_www_form({ q: text, target: target_locale, key: api_key }), 15 | headers: { 16 | "Content-Type" => "application/x-www-form-urlencoded", 17 | "Referer" => "http://test.localhost", 18 | }, 19 | ).to_return( 20 | status: 200, 21 | body: %{ { "data": { "translations": [ { "translatedText": "#{translated_text}" } ] } } }, 22 | ) 23 | end 24 | 25 | describe ".access_token" do 26 | describe "when set" do 27 | it "should return back translator_google_api_key" do 28 | expect(described_class.access_token).to eq(api_key) 29 | end 30 | end 31 | end 32 | 33 | describe ".detect" do 34 | fab!(:post) 35 | 36 | it "should store the detected language in a custom field" do 37 | detected_lang = "en" 38 | described_class.expects(:access_token).returns(api_key) 39 | Excon 40 | .expects(:post) 41 | .returns( 42 | mock_response.new( 43 | 200, 44 | %{ { "data": { "detections": [ [ { "language": "#{detected_lang}", "isReliable": false, "confidence": 0.18397073 } ] ] } } }, 45 | ), 46 | ) 47 | .once 48 | 49 | expect(described_class.detect(post)).to eq(detected_lang) 50 | expect(post.detected_locale).to eq(detected_lang) 51 | end 52 | 53 | it "should truncate string to 1000 characters" do 54 | length = 2000 55 | post.raw = rand(36**length).to_s(36) 56 | detected_lang = "en" 57 | 58 | request_url = "#{DiscourseTranslator::Provider::Google::DETECT_URI}" 59 | body = { 60 | q: 61 | post.raw.truncate( 62 | DiscourseTranslator::Provider::Google::DETECTION_CHAR_LIMIT, 63 | omission: nil, 64 | ), 65 | key: api_key, 66 | } 67 | 68 | Excon 69 | .expects(:post) 70 | .with( 71 | request_url, 72 | body: URI.encode_www_form(body), 73 | headers: { 74 | "Content-Type" => "application/x-www-form-urlencoded", 75 | "Referer" => "http://test.localhost", 76 | }, 77 | ) 78 | .returns( 79 | mock_response.new( 80 | 200, 81 | %{ { "data": { "detections": [ [ { "language": "#{detected_lang}", "isReliable": false, "confidence": 0.18397073 } ] ] } } }, 82 | ), 83 | ) 84 | .once 85 | 86 | expect(described_class.detect(post)).to eq(detected_lang) 87 | end 88 | end 89 | 90 | describe ".translate_supported?" do 91 | let(:topic) { Fabricate(:topic, title: "This title is in english") } 92 | 93 | it "equates source language to target" do 94 | source = "en" 95 | target = "fr" 96 | stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( 97 | status: 200, 98 | body: %{ { "data": { "languages": [ { "language": "#{source}" }] } } }, 99 | ) 100 | expect(described_class.translate_supported?(source, target)).to be true 101 | end 102 | 103 | it "checks again without -* when the source language is not supported" do 104 | source = "en" 105 | target = "fr" 106 | stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( 107 | status: 200, 108 | body: %{ { "data": { "languages": [ { "language": "#{source}" }] } } }, 109 | ) 110 | 111 | expect(described_class.translate_supported?("en-GB", target)).to be true 112 | end 113 | end 114 | 115 | describe ".translate_post!" do 116 | fab!(:post) { Fabricate(:post, raw: "rawraw rawrawraw", cooked: "coocoo coococooo") } 117 | 118 | before do 119 | post.set_detected_locale("en") 120 | I18n.locale = :de 121 | end 122 | 123 | it "translates post with raw" do 124 | translated_text = "translated raw" 125 | stub_translate_request(post.raw, "de", translated_text) 126 | 127 | expect(described_class.translate_post!(post, :de, { raw: true })).to eq(translated_text) 128 | end 129 | 130 | it "translates post with cooked" do 131 | translated_text = "translated cooked" 132 | stub_translate_request(post.cooked, "de", translated_text) 133 | 134 | expect(described_class.translate_post!(post, :de, { cooked: true })).to eq(translated_text) 135 | end 136 | 137 | it "translates post with raw when unspecified" do 138 | translated_text = "translated raw" 139 | stub_translate_request(post.raw, "de", translated_text) 140 | 141 | expect(described_class.translate_post!(post, :de)).to eq(translated_text) 142 | end 143 | end 144 | 145 | describe ".translate_topic!" do 146 | fab!(:topic) 147 | 148 | before do 149 | topic.set_detected_locale("en") 150 | I18n.locale = :de 151 | end 152 | 153 | it "translates topic's title" do 154 | translated_text = "translated title" 155 | stub_translate_request(topic.title, "de", translated_text) 156 | 157 | expect(described_class.translate_topic!(topic, :de)).to eq(translated_text) 158 | end 159 | end 160 | 161 | describe ".translate_text!" do 162 | it "translates plain text" do 163 | text = "ABCDEFG" 164 | target_locale = "ja" 165 | translated_text = "あいうえお" 166 | stub_translate_request(text, target_locale, translated_text) 167 | 168 | expect(described_class.translate_text!(text, :ja)).to eq(translated_text) 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/services/libre_translate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe DiscourseTranslator::Provider::LibreTranslate do 6 | let(:mock_response) { Struct.new(:status, :body) } 7 | let(:api_key) { "12345" } 8 | 9 | before { SiteSetting.translator_libretranslate_endpoint = "http://localhost:5000" } 10 | 11 | describe ".access_token" do 12 | describe "when set" do 13 | api_key = "12345" 14 | before { SiteSetting.translator_libretranslate_api_key = api_key } 15 | it "should return back translator_libretranslate_api_key" do 16 | expect(described_class.access_token).to eq(api_key) 17 | end 18 | end 19 | end 20 | 21 | describe ".translate_supported?" do 22 | it "should equate source language to target" do 23 | source = "en" 24 | target = :fr 25 | 26 | data = [{ code: "en" }, { code: "fr" }] 27 | 28 | Excon.expects(:get).returns(mock_response.new(200, data.to_json)) 29 | expect(described_class.translate_supported?(source, target)).to be true 30 | end 31 | end 32 | 33 | describe ".translate" do 34 | fab!(:post) 35 | 36 | before do 37 | SiteSetting.translator_libretranslate_api_key = api_key 38 | Excon 39 | .expects(:get) 40 | .with(SiteSetting.translator_libretranslate_endpoint + "/languages") 41 | .returns(mock_response.new(200, [{ code: "de" }, { code: "en" }].to_json)) 42 | end 43 | 44 | it "truncates text for translation to max_characters_per_translation setting" do 45 | SiteSetting.max_characters_per_translation = 50 46 | post.set_detected_locale("de") 47 | body = { q: post.cooked, source: "de", target: "en", format: "html", api_key: api_key } 48 | 49 | translated_text = "hur dur hur dur" 50 | # https://publicapi.dev/libre-translate-api 51 | Excon 52 | .expects(:post) 53 | .with( 54 | SiteSetting.translator_libretranslate_endpoint + "/translate", 55 | body: URI.encode_www_form(body), 56 | headers: { 57 | "Content-Type" => "application/x-www-form-urlencoded", 58 | }, 59 | ) 60 | .returns(mock_response.new(200, %{ { "translatedText": "#{translated_text}"} })) 61 | .once 62 | 63 | expect(described_class.translate(post)).to eq(["de", translated_text]) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/services/microsoft_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DiscourseTranslator::Provider::Microsoft do 4 | before do 5 | SiteSetting.translator_enabled = true 6 | SiteSetting.translator_azure_subscription_key = "e1bba646088021aaf1ef972a48" 7 | end 8 | after { Discourse.redis.del(described_class.cache_key) } 9 | 10 | def translate_endpoint(to: I18n.locale) 11 | uri = URI(described_class.translate_endpoint) 12 | default_query = described_class.default_query.merge("textType" => "html") 13 | default_query = default_query.merge("to" => to) if to 14 | uri.query = URI.encode_www_form(default_query) 15 | uri.to_s 16 | end 17 | 18 | def stub_translate_request(source_text, target_locale, translated_text) 19 | stub_request(:post, translate_endpoint(to: target_locale)).with( 20 | { body: [{ "Text" => source_text }].to_json }, 21 | ).to_return(status: 200, body: [{ "translations" => [{ "text" => translated_text }] }].to_json) 22 | end 23 | 24 | describe ".detect" do 25 | let(:post) { Fabricate(:post) } 26 | let(:detected_lang) { "en" } 27 | 28 | def detect_endpoint 29 | uri = URI(described_class.detect_endpoint) 30 | uri.query = URI.encode_www_form(described_class.default_query) 31 | uri.to_s 32 | end 33 | 34 | context "with azure key" do 35 | before { SiteSetting.translator_azure_subscription_key = "e1bba646088021aaf1ef972a48" } 36 | 37 | shared_examples "language detected" do 38 | it "stores detected language" do 39 | described_class.detect(post) 40 | 41 | expect(post.detected_locale).to eq(detected_lang) 42 | end 43 | end 44 | 45 | context "with a custom endpoint" do 46 | before do 47 | SiteSetting.translator_azure_custom_subdomain = "translator19191" 48 | 49 | stub_request(:post, detect_endpoint).to_return( 50 | status: 200, 51 | body: [{ "language" => detected_lang }].to_json, 52 | ) 53 | end 54 | 55 | include_examples "language detected" 56 | end 57 | 58 | context "without a custom endpoint" do 59 | before do 60 | stub_request(:post, detect_endpoint).to_return( 61 | status: 200, 62 | body: [{ "language" => detected_lang }].to_json, 63 | ) 64 | end 65 | 66 | include_examples "language detected" 67 | end 68 | 69 | it "raise a error and trigger a problemcheck when the server returns a error" do 70 | stub_request(:post, detect_endpoint).to_return( 71 | status: 429, 72 | body: { 73 | "error" => { 74 | "code" => 429_001, 75 | "message" => 76 | "The server rejected the request because the client has exceeded request limits.", 77 | }, 78 | }.to_json, 79 | ) 80 | 81 | ProblemCheckTracker[:translator_error].no_problem! 82 | 83 | expect { described_class.detect(post) }.to raise_error( 84 | DiscourseTranslator::Provider::ProblemCheckedTranslationError, 85 | ) 86 | 87 | expect(AdminNotice.problem.last.message).to eq( 88 | I18n.t( 89 | "dashboard.problem.translator_error", 90 | locale: "en", 91 | provider: "Microsoft", 92 | code: 429_001, 93 | message: 94 | "The server rejected the request because the client has exceeded request limits.", 95 | ), 96 | ) 97 | end 98 | 99 | it "clean up errors on the admin dashboard when OK" do 100 | stub_request(:post, detect_endpoint).to_return( 101 | status: 200, 102 | body: [{ "language" => detected_lang }].to_json, 103 | ) 104 | 105 | ProblemCheckTracker[:translator_error].problem!( 106 | details: { 107 | provider: "Microsoft", 108 | code: 429_001, 109 | message: "example", 110 | }, 111 | ) 112 | 113 | described_class.detect(post) 114 | 115 | expect(AdminNotice.problem.last&.identifier).not_to eq("translator_error") 116 | end 117 | end 118 | 119 | context "without azure key" do 120 | it "raise a MicrosoftNoAzureKeyError" do 121 | SiteSetting.translator_azure_subscription_key = "" 122 | expect { described_class.detect(post) }.to raise_error( 123 | DiscourseTranslator::Provider::ProblemCheckedTranslationError, 124 | I18n.t("translator.microsoft.missing_key"), 125 | ) 126 | end 127 | end 128 | end 129 | 130 | describe ".translate_post!" do 131 | fab!(:post) { Fabricate(:post, raw: "rawraw rawrawraw", cooked: "coocoo coococooo") } 132 | 133 | before do 134 | post.set_detected_locale("en") 135 | I18n.locale = :de 136 | end 137 | 138 | it "translates post with raw" do 139 | translated_text = "some text" 140 | target_locale = "de" 141 | stub_translate_request(post.raw, target_locale, translated_text) 142 | 143 | expect(described_class.translate_post!(post, :de, { raw: true })).to eq(translated_text) 144 | end 145 | 146 | it "translates post with cooked" do 147 | translated_text = "some text" 148 | target_locale = "de" 149 | stub_translate_request(post.cooked, target_locale, translated_text) 150 | 151 | expect(described_class.translate_post!(post, :de, { cooked: true })).to eq(translated_text) 152 | end 153 | 154 | it "translates post with raw when unspecified" do 155 | translated_text = "some text" 156 | target_locale = "de" 157 | stub_translate_request(post.raw, target_locale, translated_text) 158 | 159 | expect(described_class.translate_post!(post, :de)).to eq(translated_text) 160 | end 161 | end 162 | 163 | describe ".translate_topic!" do 164 | fab!(:topic) 165 | 166 | before do 167 | topic.set_detected_locale("en") 168 | I18n.locale = :de 169 | end 170 | 171 | it "translates topic's title" do 172 | translated_text = "some text" 173 | target_locale = "de" 174 | stub_translate_request(topic.title, target_locale, translated_text) 175 | 176 | expect(described_class.translate_topic!(topic, :de)).to eq(translated_text) 177 | end 178 | end 179 | 180 | describe ".translate_text!" do 181 | it "translates text" do 182 | I18n.locale = :es 183 | 184 | text = "ABCDEFG" 185 | translated_text = "some text" 186 | stub_translate_request(text, "es", translated_text) 187 | 188 | expect(described_class.translate_text!(text)).to eq(translated_text) 189 | end 190 | end 191 | 192 | describe ".translate_supported?" do 193 | it "allows translation of topics in supported languages" do 194 | expect(described_class.translate_supported?(:en, "zh-Hans")).to eq(true) 195 | expect(described_class.translate_supported?("en", :zh_CN)).to eq(true) 196 | expect(described_class.translate_supported?("zh-Hans", :en)).to eq(true) 197 | expect(described_class.translate_supported?(:zh_CN, "en")).to eq(true) 198 | 199 | expect(described_class.translate_supported?(:tr, "en")).to eq(true) 200 | expect(described_class.translate_supported?("tr", "en")).to eq(true) 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/services/problem_check/missing_translator_api_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ProblemCheck::MissingTranslatorApiKey do 4 | subject(:check) { described_class.new } 5 | 6 | describe ".call" do 7 | before { SiteSetting.stubs(translator_enabled: enabled) } 8 | 9 | shared_examples "missing key checker" do |provider, key| 10 | context "when translator is #{provider}" do 11 | before { SiteSetting.translator_provider = provider } 12 | 13 | it "when #{provider} is not provided" do 14 | SiteSetting.set(key, "") 15 | 16 | expect(check).to have_a_problem.with_priority("high").with_message( 17 | I18n.t( 18 | "dashboard.problem.missing_translator_api_key", 19 | locale: "en", 20 | provider:, 21 | key: I18n.t("site_settings.#{key}"), 22 | key_name: key, 23 | ), 24 | ) 25 | end 26 | 27 | it "when #{provider} is provided" do 28 | SiteSetting.set(key, "foo") 29 | 30 | expect(check).to be_chill_about_it 31 | end 32 | end 33 | end 34 | 35 | context "when plugin is disabled" do 36 | let(:enabled) { false } 37 | 38 | it { expect(check).to be_chill_about_it } 39 | end 40 | 41 | context "when plugin is enabled" do 42 | let(:enabled) { true } 43 | 44 | include_examples "missing key checker", "Google", "translator_google_api_key" 45 | include_examples "missing key checker", "Microsoft", "translator_azure_subscription_key" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/services/yandex_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DiscourseTranslator::Provider::Yandex do 4 | fab!(:post) 5 | 6 | def detect_endpoint(text) 7 | described_class.expects(:access_token).returns("12345") 8 | URI(described_class::DETECT_URI) 9 | .tap { |uri| uri.query = URI.encode_www_form({ "key" => "12345", "text" => text }) } 10 | .to_s 11 | end 12 | 13 | def translate_endpoint(text, source_lang, target_lang) 14 | described_class.expects(:access_token).returns("12345") 15 | URI(described_class::TRANSLATE_URI) 16 | .tap do |uri| 17 | uri.query = 18 | URI.encode_www_form( 19 | { 20 | "key" => "12345", 21 | "text" => text, 22 | "lang" => "#{source_lang}-#{target_lang}", 23 | "format" => "html", 24 | }, 25 | ) 26 | end 27 | .to_s 28 | end 29 | 30 | describe ".access_token" do 31 | describe "when set" do 32 | api_key = "12345" 33 | before { SiteSetting.translator_yandex_api_key = api_key } 34 | 35 | it "should return back translator_yandex_api_key" do 36 | expect(described_class.access_token).to eq(api_key) 37 | end 38 | end 39 | end 40 | 41 | describe ".detect!" do 42 | it "gets the detected language" do 43 | detected_lang = "en" 44 | stub_request(:post, detect_endpoint(post.raw)).to_return( 45 | status: 200, 46 | body: { lang: "#{detected_lang}" }.to_json, 47 | ) 48 | expect(described_class.detect!(post)).to eq(detected_lang) 49 | end 50 | end 51 | 52 | describe ".translate_post" do 53 | it "translates the post" do 54 | translated_text = "translated text" 55 | described_class.expects(:detect).at_least_once.returns("de") 56 | 57 | stub_request(:post, translate_endpoint(post.raw, "de", I18n.locale)).to_return( 58 | status: 200, 59 | body: { "text" => [translated_text] }.to_json, 60 | ) 61 | 62 | translation = described_class.translate_post!(post) 63 | expect(translation).to eq(translated_text) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/system/core_features_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Core features", type: :system do 4 | before { enable_current_plugin } 5 | 6 | it_behaves_like "having working core features" 7 | end 8 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@discourse/lint-configs/stylelint"], 3 | }; 4 | -------------------------------------------------------------------------------- /test/javascripts/integration/toggle-translation-button-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import ToggleTranslationButton from "discourse/plugins/discourse-translator/discourse/components/post-menu/toggle-translation-button"; 5 | 6 | module("Integration | Component | toggle-translation-button", function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test("doesn't render when post cannot be translated", async function (assert) { 10 | const self = this; 11 | 12 | this.set("post", { can_translate: false }); 13 | 14 | await render( 15 | 16 | ); 17 | 18 | assert.dom("button").doesNotExist(); 19 | }); 20 | 21 | test("renders translation button with correct states", async function (assert) { 22 | const self = this; 23 | 24 | const post = { 25 | can_translate: true, 26 | isTranslated: false, 27 | isTranslating: false, 28 | }; 29 | 30 | this.set("post", post); 31 | 32 | await render( 33 | 36 | ); 37 | 38 | assert.dom("button").exists(); 39 | assert.dom("button").hasText("View translation"); 40 | assert.dom("button").doesNotHaveClass("translated"); 41 | 42 | post.isTranslating = true; 43 | await render( 44 | 47 | ); 48 | assert.dom("button").hasAttribute("disabled"); 49 | assert.dom("button").hasText("Translating"); 50 | 51 | post.isTranslating = false; 52 | post.isTranslated = true; 53 | await render( 54 | 57 | ); 58 | assert.dom("button").hasClass("translated"); 59 | assert.dom("button").hasText("Hide translation"); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/javascripts/integration/translated-post-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import TranslatedPost from "discourse/plugins/discourse-translator/discourse/components/translated-post"; 5 | 6 | module("Integration | Component | translated-post", function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test("renders translation when post is translated", async function (assert) { 10 | const self = this; 11 | 12 | this.set("outletArgs", { 13 | post: { 14 | isTranslated: true, 15 | isTranslating: false, 16 | translatedText: "こんにちは", 17 | translatedTitle: "良い一日", 18 | detectedLang: "ja", 19 | }, 20 | }); 21 | 22 | this.siteSettings.translator_provider = "Google"; 23 | 24 | await render( 25 | 26 | ); 27 | 28 | assert.dom(".post-translation").exists(); 29 | assert.dom(".topic-attribution").hasText("良い一日"); 30 | assert.dom(".post-attribution").hasText("Translated from ja by Google"); 31 | assert.dom(".cooked").hasText("こんにちは"); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/javascripts/service/translator-test.js: -------------------------------------------------------------------------------- 1 | import { setupTest } from "ember-qunit"; 2 | import { module, test } from "qunit"; 3 | import pretender, { response } from "discourse/tests/helpers/create-pretender"; 4 | 5 | module("Unit | Service | translator", function (hooks) { 6 | setupTest(hooks); 7 | 8 | test("translatePost - standard translation", async function (assert) { 9 | const service = this.owner.lookup("service:translator"); 10 | 11 | pretender.post("/translator/translate", () => { 12 | return response({ 13 | detected_lang: "ja", 14 | translation: "I am a cat", 15 | title_translation: "Surprise!", 16 | }); 17 | }); 18 | 19 | const post = { 20 | id: 1, 21 | post_number: 2, 22 | }; 23 | 24 | await service.translatePost(post); 25 | 26 | assert.strictEqual(post.detectedLang, "ja"); 27 | assert.strictEqual(post.translatedText, "I am a cat"); 28 | assert.strictEqual(post.translatedTitle, "Surprise!"); 29 | }); 30 | 31 | test("clearPostTranslation", function (assert) { 32 | const service = this.owner.lookup("service:translator"); 33 | 34 | const post = { 35 | detectedLang: "ja", 36 | translatedText: "Hello", 37 | translatedTitle: "Title", 38 | }; 39 | 40 | service.clearPostTranslation(post); 41 | 42 | assert.strictEqual(post.detectedLang, null); 43 | assert.strictEqual(post.translatedText, null); 44 | assert.strictEqual(post.translatedTitle, null); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /translator.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for discourse-translator-bot 2 | 3 | files: 4 | - source_path: config/locales/client.en.yml 5 | destination_path: client.yml 6 | - source_path: config/locales/server.en.yml 7 | destination_path: server.yml 8 | --------------------------------------------------------------------------------