diff --git a/app/controllers/discourse_translator/translator_controller.rb b/app/controllers/discourse_translator/translator_controller.rb index 92178af..bffe9ff 100644 --- a/app/controllers/discourse_translator/translator_controller.rb +++ b/app/controllers/discourse_translator/translator_controller.rb @@ -41,17 +41,19 @@ def translate begin title_json = {} detected_lang, translation = - "DiscourseTranslator::#{SiteSetting.translator_provider}".constantize.translate(post) + "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize.translate( + post, + ) if post.is_first_post? _, title_translation = - "DiscourseTranslator::#{SiteSetting.translator_provider}".constantize.translate( + "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize.translate( post.topic, ) title_json = { title_translation: title_translation } end render json: { translation: translation, detected_lang: detected_lang }.merge(title_json), status: 200 - rescue ::DiscourseTranslator::TranslatorError => e + rescue ::DiscourseTranslator::Provider::TranslatorError => e render_json_error e.message, status: 422 end end diff --git a/app/jobs/regular/detect_translatable_language.rb b/app/jobs/regular/detect_translatable_language.rb index daf8a8b..033a062 100644 --- a/app/jobs/regular/detect_translatable_language.rb +++ b/app/jobs/regular/detect_translatable_language.rb @@ -11,9 +11,9 @@ def execute(args) translatable = args[:type].constantize.find_by(id: args[:translatable_id]) return if translatable.blank? begin - translator = "DiscourseTranslator::#{SiteSetting.translator_provider}".constantize + translator = "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize translator.detect(translatable) - rescue ::DiscourseTranslator::ProblemCheckedTranslationError + rescue ::DiscourseTranslator::Provider::ProblemCheckedTranslationError # problem-checked translation errors gracefully end end diff --git a/app/jobs/regular/translate_translatable.rb b/app/jobs/regular/translate_translatable.rb index 032a8a4..bdf4d7d 100644 --- a/app/jobs/regular/translate_translatable.rb +++ b/app/jobs/regular/translate_translatable.rb @@ -11,7 +11,7 @@ def execute(args) target_locales = SiteSetting.automatic_translation_target_languages.split("|") target_locales.each do |target_locale| - "DiscourseTranslator::#{SiteSetting.translator_provider}".constantize.translate( + "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize.translate( translatable, target_locale.to_sym, ) diff --git a/app/jobs/scheduled/automatic_translation_backfill.rb b/app/jobs/scheduled/automatic_translation_backfill.rb index 0366a50..dfb9e1e 100644 --- a/app/jobs/scheduled/automatic_translation_backfill.rb +++ b/app/jobs/scheduled/automatic_translation_backfill.rb @@ -67,7 +67,8 @@ def backfill_locales end def translator - @translator_klass ||= "DiscourseTranslator::#{SiteSetting.translator_provider}".constantize + @translator_klass ||= + "DiscourseTranslator::Provider::#{SiteSetting.translator_provider}".constantize end def translate_records(type, record_ids, target_locale) diff --git a/app/services/discourse_translator/amazon.rb b/app/services/discourse_translator/amazon.rb deleted file mode 100644 index 3694f5f..0000000 --- a/app/services/discourse_translator/amazon.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" - -module DiscourseTranslator - class Amazon < Base - require "aws-sdk-translate" - - MAX_BYTES = 10_000 - - # Hash which maps Discourse's locale code to Amazon Translate's language code found in - # https://docs.aws.amazon.com/translate/latest/dg/what-is-languages.html - SUPPORTED_LANG_MAPPING = { - af: "af", - am: "am", - ar: "ar", - az: "az", - bg: "bg", - bn: "bn", - bs: "bs", - bs_BA: "bs", - ca: "ca", - cs: "cs", - cy: "cy", - da: "da", - de: "de", - el: "el", - en: "en", - en_GB: "en", - es: "es", - es_MX: "es-MX", - et: "et", - fa: "fa", - fa_AF: "fa-AF", - fa_IR: "fa-AF", - fi: "fi", - fr: "fr", - fr_CA: "fr-CA", - ga: "ga", - gu: "gu", - ha: "ha", - he: "he", - hi: "hi", - hr: "hr", - ht: "ht", - hu: "hu", - hy: "hy", - id: "id", - is: "is", - it: "it", - ja: "ja", - ka: "ka", - kk: "kk", - kn: "kn", - ko: "ko", - lt: "lt", - lv: "lv", - mk: "mk", - ml: "ml", - mn: "mn", - mr: "mr", - ms: "ms", - mt: "mt", - nl: "nl", - no: "no", - pa: "pa", - pl: "pl", - pl_PL: "pl", - ps: "ps", - pt: "pt", - pt_PT: "pt-PT", - pt_BR: "pt", - ro: "ro", - ru: "ru", - si: "si", - sk: "sk", - sl: "sl", - so: "so", - sq: "sq", - sr: "sr", - sv: "sv", - sw: "sw", - ta: "ta", - te: "te", - th: "th", - tl: "tl", - tr: "tr", - tr_TR: "tr_TR", - uk: "uk", - ur: "ur", - uz: "uz", - vi: "vi", - zh: "zh", - zh_CN: "zh", - zh_TW: "zh-TW", - } - - # The API expects a maximum of 10k __bytes__ of text - def self.truncate(text) - return text if text.bytesize <= MAX_BYTES - text = text.byteslice(...MAX_BYTES) - text = text.byteslice(...text.bytesize - 1) until text.valid_encoding? - text - end - - def self.access_token_key - "aws-translator" - end - - def self.detect!(topic_or_post) - begin - client.translate_text( - { - text: truncate(text_for_detection(topic_or_post)), - source_language_code: "auto", - target_language_code: SUPPORTED_LANG_MAPPING[I18n.locale], - }, - )&.source_language_code - rescue Aws::Errors::MissingCredentialsError - raise I18n.t("translator.amazon.invalid_credentials") - end - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - detected_lang = detect(translatable) - - begin - client.translate_text( - { - text: truncate(text_for_translation(translatable)), - source_language_code: "auto", - target_language_code: SUPPORTED_LANG_MAPPING[target_locale_sym], - }, - ) - rescue Aws::Translate::Errors::UnsupportedLanguagePairException - raise I18n.t( - "translator.failed.#{translatable.class.name.downcase}", - source_locale: detected_lang, - target_locale: target_locale_sym, - ) - end - end - - def self.client - opts = { region: SiteSetting.translator_aws_region } - - if SiteSetting.translator_aws_key_id.present? && - SiteSetting.translator_aws_secret_access.present? - opts[:access_key_id] = SiteSetting.translator_aws_key_id - opts[:secret_access_key] = SiteSetting.translator_aws_secret_access - elsif SiteSetting.translator_aws_iam_role.present? - sts_client = Aws::STS::Client.new(region: SiteSetting.translator_aws_region) - - opts[:credentials] = Aws::AssumeRoleCredentials.new( - client: sts_client, - role_arn: SiteSetting.translator_aws_iam_role, - role_session_name: "discourse-aws-translator", - ) - end - - @client ||= Aws::Translate::Client.new(opts) - end - end -end diff --git a/app/services/discourse_translator/base.rb b/app/services/discourse_translator/base.rb deleted file mode 100644 index 96cf01b..0000000 --- a/app/services/discourse_translator/base.rb +++ /dev/null @@ -1,152 +0,0 @@ -# frozen_string_literal: true - -module DiscourseTranslator - extend ActiveSupport::Concern - - class TranslatorError < ::StandardError - end - - class ProblemCheckedTranslationError < TranslatorError - end - - class Base - DETECTION_CHAR_LIMIT = 1000 - - def self.key_prefix - "#{PLUGIN_NAME}:".freeze - end - - def self.access_token_key - raise "Not Implemented" - end - - def self.cache_key - "#{key_prefix}#{access_token_key}" - end - - # Returns the stored translation of a post or topic. - # If the translation does not exist yet, it will be translated first via the API then stored. - # If the detected language is the same as the target language, the original text will be returned. - # @param translatable [Post|Topic] - def self.translate(translatable, target_locale_sym = I18n.locale) - return if text_for_translation(translatable).blank? - detected_lang = detect(translatable) - - if translatable.locale_matches?(target_locale_sym) - return detected_lang, get_untranslated(translatable) - end - - translation = translatable.translation_for(target_locale_sym) - return detected_lang, translation if translation.present? - - unless translate_supported?(detected_lang, target_locale_sym) - raise TranslatorError.new( - I18n.t( - "translator.failed.#{translatable.class.name.downcase}", - source_locale: detected_lang, - target_locale: target_locale_sym, - ), - ) - end - - translated = translate!(translatable, target_locale_sym) - save_translation(translatable, target_locale_sym) { translated } - [detected_lang, translated] - end - - # Subclasses must implement this method to translate the text of a - # post or topic and return only the translated text. - # Subclasses should use text_for_translation - # @param translatable [Post|Topic] - # @param target_locale_sym [Symbol] - # @return [String] - def self.translate!(translatable, target_locale_sym = I18n.locale) - raise "Not Implemented" - end - - # Returns the stored detected locale of a post or topic. - # If the locale does not exist yet, it will be detected first via the API then stored. - # @param translatable [Post|Topic] - def self.detect(translatable) - return if text_for_detection(translatable).blank? - get_detected_locale(translatable) || - save_detected_locale(translatable) { detect!(translatable) } - end - - # Subclasses must implement this method to detect the text of a post or topic - # and return only the detected locale. - # Subclasses should use text_for_detection - # @param translatable [Post|Topic] - # @return [String] - def self.detect!(translatable) - raise "Not Implemented" - end - - def self.access_token - raise "Not Implemented" - end - - def self.save_translation(translatable, target_locale_sym = I18n.locale) - begin - translation = yield - rescue Timeout::Error - raise TranslatorError.new(I18n.t("translator.api_timeout")) - end - translatable.set_translation(target_locale_sym, translation) - translation - end - - def self.get_detected_locale(translatable) - translatable.detected_locale - end - - def self.save_detected_locale(translatable) - # sometimes we may have a user post that is just an emoji - # in that case, we will just indicate the post is in the default locale - detected_locale = yield.presence || SiteSetting.default_locale - translatable.set_detected_locale(detected_locale) - - detected_locale - end - - def self.language_supported?(detected_lang) - raise NotImplementedError unless self.const_defined?(:SUPPORTED_LANG_MAPPING) - supported_lang = const_get(:SUPPORTED_LANG_MAPPING) - return false if supported_lang[I18n.locale].nil? - detected_lang != supported_lang[I18n.locale] - end - - def self.translate_supported?(detected_lang, target_lang) - true - end - - private - - def self.text_for_detection(translatable) - text = get_untranslated(translatable, raw: true) - - if translatable.class.name == "Topic" - # due to topics having short titles, - # we need to add the first post to the detection text - first_post = get_untranslated(translatable.first_post, raw: true) - text = text + " " + first_post if first_post - end - - text.truncate(DETECTION_CHAR_LIMIT, omission: nil) - end - - def self.text_for_translation(translatable, raw: false) - max_char = SiteSetting.max_characters_per_translation - get_untranslated(translatable, raw:).truncate(max_char, omission: nil) - end - - def self.get_untranslated(translatable, raw: false) - case translatable.class.name - when "Post" - raw ? translatable.raw : translatable.cooked - when "Topic" - translatable.title - end - end - end -end diff --git a/app/services/discourse_translator/discourse_ai.rb b/app/services/discourse_translator/discourse_ai.rb deleted file mode 100644 index 947a3cb..0000000 --- a/app/services/discourse_translator/discourse_ai.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module DiscourseTranslator - class DiscourseAi < Base - MAX_DETECT_LOCALE_TEXT_LENGTH = 1000 - def self.language_supported?(detected_lang) - locale_without_region = I18n.locale.to_s.split("_").first - detected_lang != locale_without_region - end - - def self.detect!(topic_or_post) - unless required_settings_enabled - raise TranslatorError.new( - I18n.t( - "translator.discourse_ai.ai_helper_required", - { base_url: Discourse.base_url }, - ), - ) - end - - ::DiscourseAi::LanguageDetector.new(text_for_detection(topic_or_post)).detect - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - unless required_settings_enabled - raise TranslatorError.new( - I18n.t( - "translator.discourse_ai.ai_helper_required", - { base_url: Discourse.base_url }, - ), - ) - end - - language = get_language_name(target_locale_sym) - translated = - case translatable.class.name - when "Post" - text = text_for_translation(translatable, raw: true) - chunks = DiscourseTranslator::ContentSplitter.split(text) - chunks - .map { |chunk| ::DiscourseAi::PostTranslator.new(chunk, target_locale_sym).translate } - .join("") - when "Topic" - ::DiscourseAi::TopicTranslator.new(text_for_translation(translatable), language).translate - end - - DiscourseTranslator::TranslatedContentNormalizer.normalize(translatable, translated) - end - - private - - def self.required_settings_enabled - SiteSetting.translator_enabled && SiteSetting.translator_provider == "DiscourseAi" && - SiteSetting.discourse_ai_enabled && SiteSetting.ai_helper_enabled - end - - def self.get_language_name(target_locale_sym) - LocaleSiteSetting.language_names.dig(target_locale_sym.to_s, "name") || - "locale \"#{target_locale_sym}\"" - end - end -end diff --git a/app/services/discourse_translator/google.rb b/app/services/discourse_translator/google.rb deleted file mode 100644 index 43d9ba4..0000000 --- a/app/services/discourse_translator/google.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" -require "json" - -module DiscourseTranslator - class Google < Base - TRANSLATE_URI = "https://www.googleapis.com/language/translate/v2".freeze - DETECT_URI = "https://www.googleapis.com/language/translate/v2/detect".freeze - SUPPORT_URI = "https://www.googleapis.com/language/translate/v2/languages".freeze - - # Hash which maps Discourse's locale code to Google Translate's locale code found in - # https://cloud.google.com/translate/docs/languages - SUPPORTED_LANG_MAPPING = { - en: "en", - en_GB: "en", - en_US: "en", - ar: "ar", - bg: "bg", - bs_BA: "bs", - ca: "ca", - cs: "cs", - da: "da", - de: "de", - el: "el", - es: "es", - et: "et", - fi: "fi", - fr: "fr", - he: "iw", - hi: "hi", - hr: "hr", - hu: "hu", - hy: "hy", - id: "id", - it: "it", - ja: "ja", - ka: "ka", - kk: "kk", - ko: "ko", - ky: "ky", - lv: "lv", - mk: "mk", - nl: "nl", - pt: "pt", - ro: "ro", - ru: "ru", - sk: "sk", - sl: "sl", - sq: "sq", - sr: "sr", - sv: "sv", - tg: "tg", - te: "te", - th: "th", - uk: "uk", - uz: "uz", - zh_CN: "zh-CN", - zh_TW: "zh-TW", - tr_TR: "tr", - pt_BR: "pt", - pl_PL: "pl", - no_NO: "no", - nb_NO: "no", - fa_IR: "fa", - } - CHINESE_LOCALE = "zh" - - def self.access_token_key - "google-translator" - end - - def self.access_token - return SiteSetting.translator_google_api_key if SiteSetting.translator_google_api_key.present? - raise ProblemCheckedTranslationError.new("NotFound: Google Api Key not set.") - end - - def self.detect!(topic_or_post) - result(DETECT_URI, q: text_for_detection(topic_or_post))["detections"][0].max do |a, b| - a.confidence <=> b.confidence - end[ - "language" - ] - end - - def self.translate_supported?(source, target) - res = result(SUPPORT_URI, target: SUPPORTED_LANG_MAPPING[target]) - supported = res["languages"].any? { |obj| obj["language"] == source } - return true if supported - - normalized_source = source.split("-").first - if (source.include?("-") && normalized_source != CHINESE_LOCALE) - res["languages"].any? { |obj| obj["language"] == normalized_source } - else - false - end - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - res = - result( - TRANSLATE_URI, - q: text_for_translation(translatable), - target: SUPPORTED_LANG_MAPPING[target_locale_sym], - ) - res["translations"][0]["translatedText"] - end - - def self.result(url, body) - body[:key] = access_token - - response = - Excon.post( - url, - body: URI.encode_www_form(body), - headers: { - "Content-Type" => "application/x-www-form-urlencoded", - "Referer" => Discourse.base_url, - }, - ) - - body = nil - begin - body = JSON.parse(response.body) - rescue JSON::ParserError - end - - if response.status != 200 - if body && body["error"] - ProblemCheckTracker[:translator_error].problem!( - details: { - provider: "Google", - code: body["error"]["code"], - message: body["error"]["message"], - }, - ) - raise ProblemCheckedTranslationError.new(body["error"]["message"]) - else - raise TranslatorError.new(response.inspect) - end - else - ProblemCheckTracker[:translator_error].no_problem! - body["data"] - end - end - end -end diff --git a/app/services/discourse_translator/libre_translate.rb b/app/services/discourse_translator/libre_translate.rb deleted file mode 100644 index 24eda2d..0000000 --- a/app/services/discourse_translator/libre_translate.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" -require "json" - -module DiscourseTranslator - class LibreTranslate < Base - SUPPORTED_LANG_MAPPING = { - en: "en", - en_GB: "en", - en_US: "en", - ar: "ar", - bg: "bg", - bs_BA: "bs", - ca: "ca", - cs: "cs", - da: "da", - de: "de", - el: "el", - es: "es", - et: "et", - fi: "fi", - fr: "fr", - he: "iw", - hr: "hr", - hu: "hu", - hy: "hy", - id: "id", - it: "it", - ja: "ja", - ka: "ka", - kk: "kk", - ko: "ko", - ky: "ky", - lv: "lv", - mk: "mk", - nl: "nl", - pt: "pt", - ro: "ro", - ru: "ru", - sk: "sk", - sl: "sl", - sq: "sq", - sr: "sr", - sv: "sv", - tg: "tg", - te: "te", - th: "th", - uk: "uk", - uz: "uz", - zh_CN: "zh", - zh_TW: "zh", - tr_TR: "tr", - pt_BR: "pt", - pl_PL: "pl", - no_NO: "no", - nb_NO: "no", - fa_IR: "fa", - } - - def self.translate_uri - SiteSetting.translator_libretranslate_endpoint + "/translate" - end - - def self.detect_uri - SiteSetting.translator_libretranslate_endpoint + "/detect" - end - - def self.support_uri - SiteSetting.translator_libretranslate_endpoint + "/languages" - end - - def self.access_token_key - "libretranslate-translator" - end - - def self.access_token - SiteSetting.translator_libretranslate_api_key - end - - def self.detect!(topic_or_post) - res = - result( - detect_uri, - q: ActionController::Base.helpers.strip_tags(text_for_detection(topic_or_post)), - ) - !res.empty? ? res[0]["language"] : "en" - end - - def self.translate_supported?(source, target) - lang = SUPPORTED_LANG_MAPPING[target] - res = get(support_uri) - res.any? { |obj| obj["code"] == source } && res.any? { |obj| obj["code"] == lang } - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - detected_lang = detect(translatable) - - res = - result( - translate_uri, - q: text_for_translation(translatable), - source: detected_lang, - target: SUPPORTED_LANG_MAPPING[target_locale_sym], - format: "html", - ) - res["translatedText"] - end - - def self.get(url) - begin - response = Excon.get(url) - body = JSON.parse(response.body) - status = response.status - rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout - body = I18n.t("translator.not_available") - status = 500 - end - - if status != 200 - raise TranslatorError.new(body || response.inspect) - else - body - end - end - - def self.result(url, body) - begin - body[:api_key] = access_token - - response = - Excon.post( - url, - body: URI.encode_www_form(body), - headers: { - "Content-Type" => "application/x-www-form-urlencoded", - }, - ) - - body = JSON.parse(response.body) - status = response.status - rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout - body = I18n.t("translator.not_available") - status = 500 - end - - if status != 200 - raise TranslatorError.new(body || response.inspect) - else - body - end - end - end -end diff --git a/app/services/discourse_translator/microsoft.rb b/app/services/discourse_translator/microsoft.rb deleted file mode 100644 index 8148b22..0000000 --- a/app/services/discourse_translator/microsoft.rb +++ /dev/null @@ -1,254 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" - -module DiscourseTranslator - class Microsoft < Base - TRANSLATE_URI = "https://api.cognitive.microsofttranslator.com/translate" - DETECT_URI = "https://api.cognitive.microsofttranslator.com/detect" - CUSTOM_URI_SUFFIX = "cognitiveservices.azure.com/translator/text/v3.0" - LENGTH_LIMIT = 50_000 - - # Hash which maps Discourse's locale code to Microsoft Translator's language code found in - # https://docs.microsoft.com/en-us/azure/cognitive-services/translator/language-support - # Format: Discourse Language Code: Azure Language Code - SUPPORTED_LANG_MAPPING = { - af: "af", - ar: "ar", - az: "az", - bg: "bg", - bn: "bn", - bo: "bo", - bs_BA: "bs", - ca: "ca", - cs: "cs", - cy: "cy", - da: "da", - de: "de", - el: "el", - en: "en", - en_GB: "en", - en_US: "en", - es: "es", - et: "et", - eu: "eu", - fa_IR: "fa", - fi: "fi", - fr: "fr", - ga: "ga", - gl: "gl", - gom: "gom", - gu: "gu", - ha: "ha", - he: "he", - hi: "hi", - hr: "hr", - hsb: "hsb", - ht: "ht", - hu: "hu", - hy: "hy", - id: "id", - ig: "ig", - ikt: "ikt", - is: "is", - it: "it", - iu: "iu", - iu_Latn: "iu-Latn", - ja: "ja", - ka: "ka", - kk: "kk", - km: "km", - kmr: "kmr", - kn: "kn", - ko: "ko", - ks: "ks", - ku: "ku", - ky: "ky", - ln: "ln", - lo: "lo", - lt: "lt", - lug: "lug", - lv: "lv", - lzh: "lzh", - mai: "mai", - mg: "mg", - mi: "mi", - mk: "mk", - ml: "ml", - mn: "mn-Cyrl", - mn_Cyrl: "mn-Cyrl", - mn_Mong: "mn-Mong", - mr: "mr", - ms: "ms", - mt: "mt", - mww: "mww", - my: "my", - nb: "nb", - nb_NO: "nb", - ne: "ne", - nl: "nl", - nso: "nso", - nya: "nya", - or: "or", - otq: "otq", - pa: "pa", - pl: "pl", - pl_PL: "pl", - prs: "prs", - ps: "ps", - pt: "pt", - pt_BR: "pt", - pt_pt: "pt", - ro: "ro", - ru: "ru", - run: "run", - rw: "rw", - sd: "sd", - si: "si", - sk: "sk", - sl: "sl", - sm: "sm", - sn: "sn", - so: "so", - sq: "sq", - sr: "sr-Cyrl", - sr_Cyrl: "sr-Cyrl", - sr_Latn: "sr-Latn", - st: "st", - sv: "sv", - sw: "sw", - ta: "ta", - te: "te", - th: "th", - ti: "ti", - tk: "tk", - tlh_Latn: "tlh-Latn", - tlh_Piqd: "tlh-Piqd", - tn: "tn", - to: "to", - tr: "tr", - tr_TR: "tr", - tt: "tt", - ty: "ty", - ug: "ug", - uk: "uk", - ur: "ur", - uz: "uz", - vi: "vi", - xh: "xh", - yo: "yo", - yua: "yua", - yue: "yue", - zh_CN: "zh-Hans", - zh_TW: "zh-Hant", - zu: "zu", - } - - def self.access_token_key - "microsoft-translator" - end - - def self.detect!(topic_or_post) - body = [{ "Text" => text_for_detection(topic_or_post) }].to_json - uri = URI(detect_endpoint) - uri.query = URI.encode_www_form(self.default_query) - result(uri.to_s, body, default_headers).first["language"] - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - detected_lang = detect(translatable) - - if text_for_translation(translatable).length > LENGTH_LIMIT - raise TranslatorError.new(I18n.t("translator.too_long")) - end - locale = - SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported")) - - query = default_query.merge("from" => detected_lang, "to" => locale, "textType" => "html") - body = [{ "Text" => text_for_translation(translatable) }].to_json - uri = URI(translate_endpoint) - uri.query = URI.encode_www_form(query) - response_body = result(uri.to_s, body, default_headers) - response_body.first["translations"].first["text"] - end - - def self.translate_supported?(detected_lang, target_lang) - SUPPORTED_LANG_MAPPING.keys.include?(detected_lang.to_sym) && - SUPPORTED_LANG_MAPPING.values.include?(detected_lang.to_s) - end - - private - - def self.detect_endpoint - custom_endpoint? ? custom_detect_endpoint : DETECT_URI - end - - def self.translate_endpoint - custom_endpoint? ? custom_translate_endpoint : TRANSLATE_URI - end - - def self.custom_base_endpoint - "https://#{SiteSetting.translator_azure_custom_subdomain}.#{CUSTOM_URI_SUFFIX}" - end - - def self.custom_detect_endpoint - "#{custom_base_endpoint}/detect" - end - - def self.custom_translate_endpoint - "#{custom_base_endpoint}/translate" - end - - def self.custom_endpoint? - SiteSetting.translator_azure_custom_subdomain.present? - end - - def self.post(uri, body, headers = {}) - connection = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - connection.post(uri, body, headers) - end - - def self.result(uri, body, headers) - response = post(uri, body, headers) - response_body = JSON.parse(response.body) - - if response.status != 200 - if response_body["error"] && response_body["error"]["code"] - ProblemCheckTracker[:translator_error].problem!( - details: { - provider: "Microsoft", - code: response_body["error"]["code"], - message: response_body["error"]["message"], - }, - ) - raise ProblemCheckedTranslationError.new(response_body) - end - raise TranslatorError.new(response_body) - else - ProblemCheckTracker[:translator_error].no_problem! - response_body - end - end - - def self.default_headers - if SiteSetting.translator_azure_subscription_key.blank? - raise ProblemCheckedTranslationError.new(I18n.t("translator.microsoft.missing_key")) - end - - headers = { - "Content-Type" => "application/json", - "Ocp-Apim-Subscription-Key" => SiteSetting.translator_azure_subscription_key, - } - - if SiteSetting.translator_azure_region != "global" - headers["Ocp-Apim-Subscription-Region"] = SiteSetting.translator_azure_region - end - - headers - end - - def self.default_query - { "api-version" => "3.0" } - end - end -end diff --git a/app/services/discourse_translator/provider/amazon.rb b/app/services/discourse_translator/provider/amazon.rb new file mode 100644 index 0000000..a8ee7f2 --- /dev/null +++ b/app/services/discourse_translator/provider/amazon.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class Amazon < BaseProvider + require "aws-sdk-translate" + + MAX_BYTES = 10_000 + + # Hash which maps Discourse's locale code to Amazon Translate's language code found in + # https://docs.aws.amazon.com/translate/latest/dg/what-is-languages.html + SUPPORTED_LANG_MAPPING = { + af: "af", + am: "am", + ar: "ar", + az: "az", + bg: "bg", + bn: "bn", + bs: "bs", + bs_BA: "bs", + ca: "ca", + cs: "cs", + cy: "cy", + da: "da", + de: "de", + el: "el", + en: "en", + en_GB: "en", + es: "es", + es_MX: "es-MX", + et: "et", + fa: "fa", + fa_AF: "fa-AF", + fa_IR: "fa-AF", + fi: "fi", + fr: "fr", + fr_CA: "fr-CA", + ga: "ga", + gu: "gu", + ha: "ha", + he: "he", + hi: "hi", + hr: "hr", + ht: "ht", + hu: "hu", + hy: "hy", + id: "id", + is: "is", + it: "it", + ja: "ja", + ka: "ka", + kk: "kk", + kn: "kn", + ko: "ko", + lt: "lt", + lv: "lv", + mk: "mk", + ml: "ml", + mn: "mn", + mr: "mr", + ms: "ms", + mt: "mt", + nl: "nl", + no: "no", + pa: "pa", + pl: "pl", + pl_PL: "pl", + ps: "ps", + pt: "pt", + pt_PT: "pt-PT", + pt_BR: "pt", + ro: "ro", + ru: "ru", + si: "si", + sk: "sk", + sl: "sl", + so: "so", + sq: "sq", + sr: "sr", + sv: "sv", + sw: "sw", + ta: "ta", + te: "te", + th: "th", + tl: "tl", + tr: "tr", + tr_TR: "tr_TR", + uk: "uk", + ur: "ur", + uz: "uz", + vi: "vi", + zh: "zh", + zh_CN: "zh", + zh_TW: "zh-TW", + } + + # The API expects a maximum of 10k __bytes__ of text + def self.truncate(text) + return text if text.bytesize <= MAX_BYTES + text = text.byteslice(...MAX_BYTES) + text = text.byteslice(...text.bytesize - 1) until text.valid_encoding? + text + end + + def self.access_token_key + "aws-translator" + end + + def self.detect!(topic_or_post) + begin + client.translate_text( + { + text: truncate(text_for_detection(topic_or_post)), + source_language_code: "auto", + target_language_code: SUPPORTED_LANG_MAPPING[I18n.locale], + }, + )&.source_language_code + rescue Aws::Errors::MissingCredentialsError + raise I18n.t("translator.amazon.invalid_credentials") + end + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + detected_lang = detect(translatable) + + begin + client.translate_text( + { + text: truncate(text_for_translation(translatable)), + source_language_code: "auto", + target_language_code: SUPPORTED_LANG_MAPPING[target_locale_sym], + }, + ) + rescue Aws::Translate::Errors::UnsupportedLanguagePairException + raise I18n.t( + "translator.failed.#{translatable.class.name.downcase}", + source_locale: detected_lang, + target_locale: target_locale_sym, + ) + end + end + + def self.client + opts = { region: SiteSetting.translator_aws_region } + + if SiteSetting.translator_aws_key_id.present? && + SiteSetting.translator_aws_secret_access.present? + opts[:access_key_id] = SiteSetting.translator_aws_key_id + opts[:secret_access_key] = SiteSetting.translator_aws_secret_access + elsif SiteSetting.translator_aws_iam_role.present? + sts_client = Aws::STS::Client.new(region: SiteSetting.translator_aws_region) + + opts[:credentials] = Aws::AssumeRoleCredentials.new( + client: sts_client, + role_arn: SiteSetting.translator_aws_iam_role, + role_session_name: "discourse-aws-translator", + ) + end + + @client ||= Aws::Translate::Client.new(opts) + end + end + end +end diff --git a/app/services/discourse_translator/provider/base_provider.rb b/app/services/discourse_translator/provider/base_provider.rb new file mode 100644 index 0000000..48b53dd --- /dev/null +++ b/app/services/discourse_translator/provider/base_provider.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + extend ActiveSupport::Concern + + class TranslatorError < ::StandardError + end + + class ProblemCheckedTranslationError < TranslatorError + end + + class BaseProvider + DETECTION_CHAR_LIMIT = 1000 + + def self.key_prefix + "#{PLUGIN_NAME}:".freeze + end + + def self.access_token_key + raise "Not Implemented" + end + + def self.cache_key + "#{key_prefix}#{access_token_key}" + end + + # Returns the stored translation of a post or topic. + # If the translation does not exist yet, it will be translated first via the API then stored. + # If the detected language is the same as the target language, the original text will be returned. + # @param translatable [Post|Topic] + def self.translate(translatable, target_locale_sym = I18n.locale) + return if text_for_translation(translatable).blank? + detected_lang = detect(translatable) + + if translatable.locale_matches?(target_locale_sym) + return detected_lang, get_untranslated(translatable) + end + + translation = translatable.translation_for(target_locale_sym) + return detected_lang, translation if translation.present? + + unless translate_supported?(detected_lang, target_locale_sym) + raise TranslatorError.new( + I18n.t( + "translator.failed.#{translatable.class.name.downcase}", + source_locale: detected_lang, + target_locale: target_locale_sym, + ), + ) + end + + translated = translate_translatable!(translatable, target_locale_sym) + save_translation(translatable, target_locale_sym) { translated } + [detected_lang, translated] + end + + # Subclasses must implement this method to translate the text of a + # post or topic and return only the translated text. + # Subclasses should use text_for_translation + # @param translatable [Post|Topic] + # @param target_locale_sym [Symbol] + # @return [String] + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + raise "Not Implemented" + end + + # Returns the stored detected locale of a post or topic. + # If the locale does not exist yet, it will be detected first via the API then stored. + # @param translatable [Post|Topic] + def self.detect(translatable) + return if text_for_detection(translatable).blank? + get_detected_locale(translatable) || + save_detected_locale(translatable) { detect!(translatable) } + end + + # Subclasses must implement this method to detect the text of a post or topic + # and return only the detected locale. + # Subclasses should use text_for_detection + # @param translatable [Post|Topic] + # @return [String] + def self.detect!(translatable) + raise "Not Implemented" + end + + def self.access_token + raise "Not Implemented" + end + + def self.save_translation(translatable, target_locale_sym = I18n.locale) + begin + translation = yield + rescue Timeout::Error + raise TranslatorError.new(I18n.t("translator.api_timeout")) + end + translatable.set_translation(target_locale_sym, translation) + translation + end + + def self.get_detected_locale(translatable) + translatable.detected_locale + end + + def self.save_detected_locale(translatable) + # sometimes we may have a user post that is just an emoji + # in that case, we will just indicate the post is in the default locale + detected_locale = yield.presence || SiteSetting.default_locale + translatable.set_detected_locale(detected_locale) + + detected_locale + end + + def self.language_supported?(detected_lang) + raise NotImplementedError unless self.const_defined?(:SUPPORTED_LANG_MAPPING) + supported_lang = const_get(:SUPPORTED_LANG_MAPPING) + return false if supported_lang[I18n.locale].nil? + detected_lang != supported_lang[I18n.locale] + end + + def self.translate_supported?(detected_lang, target_lang) + true + end + + private + + def self.text_for_detection(translatable) + text = get_untranslated(translatable, raw: true) + + if translatable.class.name == "Topic" + # due to topics having short titles, + # we need to add the first post to the detection text + first_post = get_untranslated(translatable.first_post, raw: true) + text = text + " " + first_post if first_post + end + + text.truncate(DETECTION_CHAR_LIMIT, omission: nil) + end + + def self.text_for_translation(translatable, raw: false) + max_char = SiteSetting.max_characters_per_translation + get_untranslated(translatable, raw:).truncate(max_char, omission: nil) + end + + def self.get_untranslated(translatable, raw: false) + case translatable.class.name + when "Post" + raw ? translatable.raw : translatable.cooked + when "Topic" + translatable.title + end + end + end + end +end diff --git a/app/services/discourse_translator/provider/discourse_ai.rb b/app/services/discourse_translator/provider/discourse_ai.rb new file mode 100644 index 0000000..d7b1a0c --- /dev/null +++ b/app/services/discourse_translator/provider/discourse_ai.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class DiscourseAi < BaseProvider + MAX_DETECT_LOCALE_TEXT_LENGTH = 1000 + def self.language_supported?(detected_lang) + locale_without_region = I18n.locale.to_s.split("_").first + detected_lang != locale_without_region + end + + def self.detect!(topic_or_post) + unless required_settings_enabled + raise TranslatorError.new( + I18n.t( + "translator.discourse_ai.ai_helper_required", + { base_url: Discourse.base_url }, + ), + ) + end + + ::DiscourseAi::LanguageDetector.new(text_for_detection(topic_or_post)).detect + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + unless required_settings_enabled + raise TranslatorError.new( + I18n.t( + "translator.discourse_ai.ai_helper_required", + { base_url: Discourse.base_url }, + ), + ) + end + + language = get_language_name(target_locale_sym) + translated = + case translatable.class.name + when "Post" + text = text_for_translation(translatable, raw: true) + chunks = DiscourseTranslator::ContentSplitter.split(text) + chunks + .map { |chunk| ::DiscourseAi::PostTranslator.new(chunk, target_locale_sym).translate } + .join("") + when "Topic" + ::DiscourseAi::TopicTranslator.new( + text_for_translation(translatable), + language, + ).translate + end + + DiscourseTranslator::TranslatedContentNormalizer.normalize(translatable, translated) + end + + private + + def self.required_settings_enabled + SiteSetting.translator_enabled && SiteSetting.translator_provider == "DiscourseAi" && + SiteSetting.discourse_ai_enabled && SiteSetting.ai_helper_enabled + end + + def self.get_language_name(target_locale_sym) + LocaleSiteSetting.language_names.dig(target_locale_sym.to_s, "name") || + "locale \"#{target_locale_sym}\"" + end + end + end +end diff --git a/app/services/discourse_translator/provider/google.rb b/app/services/discourse_translator/provider/google.rb new file mode 100644 index 0000000..1af178e --- /dev/null +++ b/app/services/discourse_translator/provider/google.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class Google < BaseProvider + TRANSLATE_URI = "https://www.googleapis.com/language/translate/v2".freeze + DETECT_URI = "https://www.googleapis.com/language/translate/v2/detect".freeze + SUPPORT_URI = "https://www.googleapis.com/language/translate/v2/languages".freeze + + # Hash which maps Discourse's locale code to Google Translate's locale code found in + # https://cloud.google.com/translate/docs/languages + SUPPORTED_LANG_MAPPING = { + en: "en", + en_GB: "en", + en_US: "en", + ar: "ar", + bg: "bg", + bs_BA: "bs", + ca: "ca", + cs: "cs", + da: "da", + de: "de", + el: "el", + es: "es", + et: "et", + fi: "fi", + fr: "fr", + he: "iw", + hi: "hi", + hr: "hr", + hu: "hu", + hy: "hy", + id: "id", + it: "it", + ja: "ja", + ka: "ka", + kk: "kk", + ko: "ko", + ky: "ky", + lv: "lv", + mk: "mk", + nl: "nl", + pt: "pt", + ro: "ro", + ru: "ru", + sk: "sk", + sl: "sl", + sq: "sq", + sr: "sr", + sv: "sv", + tg: "tg", + te: "te", + th: "th", + uk: "uk", + uz: "uz", + zh_CN: "zh-CN", + zh_TW: "zh-TW", + tr_TR: "tr", + pt_BR: "pt", + pl_PL: "pl", + no_NO: "no", + nb_NO: "no", + fa_IR: "fa", + } + CHINESE_LOCALE = "zh" + + def self.access_token_key + "google-translator" + end + + def self.access_token + if SiteSetting.translator_google_api_key.present? + return SiteSetting.translator_google_api_key + end + raise ProblemCheckedTranslationError.new("NotFound: Google Api Key not set.") + end + + def self.detect!(topic_or_post) + result(DETECT_URI, q: text_for_detection(topic_or_post))["detections"][0].max do |a, b| + a.confidence <=> b.confidence + end[ + "language" + ] + end + + def self.translate_supported?(source, target) + res = result(SUPPORT_URI, target: SUPPORTED_LANG_MAPPING[target]) + supported = res["languages"].any? { |obj| obj["language"] == source } + return true if supported + + normalized_source = source.split("-").first + if (source.include?("-") && normalized_source != CHINESE_LOCALE) + res["languages"].any? { |obj| obj["language"] == normalized_source } + else + false + end + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + res = + result( + TRANSLATE_URI, + q: text_for_translation(translatable), + target: SUPPORTED_LANG_MAPPING[target_locale_sym], + ) + res["translations"][0]["translatedText"] + end + + def self.result(url, body) + body[:key] = access_token + + response = + Excon.post( + url, + body: URI.encode_www_form(body), + headers: { + "Content-Type" => "application/x-www-form-urlencoded", + "Referer" => Discourse.base_url, + }, + ) + + body = nil + begin + body = JSON.parse(response.body) + rescue JSON::ParserError + end + + if response.status != 200 + if body && body["error"] + ProblemCheckTracker[:translator_error].problem!( + details: { + provider: "Google", + code: body["error"]["code"], + message: body["error"]["message"], + }, + ) + raise ProblemCheckedTranslationError.new(body["error"]["message"]) + else + raise TranslatorError.new(response.inspect) + end + else + ProblemCheckTracker[:translator_error].no_problem! + body["data"] + end + end + end + end +end diff --git a/app/services/discourse_translator/provider/libre_translate.rb b/app/services/discourse_translator/provider/libre_translate.rb new file mode 100644 index 0000000..e30c4b2 --- /dev/null +++ b/app/services/discourse_translator/provider/libre_translate.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class LibreTranslate < BaseProvider + SUPPORTED_LANG_MAPPING = { + en: "en", + en_GB: "en", + en_US: "en", + ar: "ar", + bg: "bg", + bs_BA: "bs", + ca: "ca", + cs: "cs", + da: "da", + de: "de", + el: "el", + es: "es", + et: "et", + fi: "fi", + fr: "fr", + he: "iw", + hr: "hr", + hu: "hu", + hy: "hy", + id: "id", + it: "it", + ja: "ja", + ka: "ka", + kk: "kk", + ko: "ko", + ky: "ky", + lv: "lv", + mk: "mk", + nl: "nl", + pt: "pt", + ro: "ro", + ru: "ru", + sk: "sk", + sl: "sl", + sq: "sq", + sr: "sr", + sv: "sv", + tg: "tg", + te: "te", + th: "th", + uk: "uk", + uz: "uz", + zh_CN: "zh", + zh_TW: "zh", + tr_TR: "tr", + pt_BR: "pt", + pl_PL: "pl", + no_NO: "no", + nb_NO: "no", + fa_IR: "fa", + } + + def self.translate_uri + SiteSetting.translator_libretranslate_endpoint + "/translate" + end + + def self.detect_uri + SiteSetting.translator_libretranslate_endpoint + "/detect" + end + + def self.support_uri + SiteSetting.translator_libretranslate_endpoint + "/languages" + end + + def self.access_token_key + "libretranslate-translator" + end + + def self.access_token + SiteSetting.translator_libretranslate_api_key + end + + def self.detect!(topic_or_post) + res = + result( + detect_uri, + q: ActionController::Base.helpers.strip_tags(text_for_detection(topic_or_post)), + ) + !res.empty? ? res[0]["language"] : "en" + end + + def self.translate_supported?(source, target) + lang = SUPPORTED_LANG_MAPPING[target] + res = get(support_uri) + res.any? { |obj| obj["code"] == source } && res.any? { |obj| obj["code"] == lang } + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + detected_lang = detect(translatable) + + res = + result( + translate_uri, + q: text_for_translation(translatable), + source: detected_lang, + target: SUPPORTED_LANG_MAPPING[target_locale_sym], + format: "html", + ) + res["translatedText"] + end + + def self.get(url) + begin + response = Excon.get(url) + body = JSON.parse(response.body) + status = response.status + rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout + body = I18n.t("translator.not_available") + status = 500 + end + + if status != 200 + raise TranslatorError.new(body || response.inspect) + else + body + end + end + + def self.result(url, body) + begin + body[:api_key] = access_token + + response = + Excon.post( + url, + body: URI.encode_www_form(body), + headers: { + "Content-Type" => "application/x-www-form-urlencoded", + }, + ) + + body = JSON.parse(response.body) + status = response.status + rescue JSON::ParserError, Excon::Error::Socket, Excon::Error::Timeout + body = I18n.t("translator.not_available") + status = 500 + end + + if status != 200 + raise TranslatorError.new(body || response.inspect) + else + body + end + end + end + end +end diff --git a/app/services/discourse_translator/provider/microsoft.rb b/app/services/discourse_translator/provider/microsoft.rb new file mode 100644 index 0000000..51bda3d --- /dev/null +++ b/app/services/discourse_translator/provider/microsoft.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class Microsoft < BaseProvider + TRANSLATE_URI = "https://api.cognitive.microsofttranslator.com/translate" + DETECT_URI = "https://api.cognitive.microsofttranslator.com/detect" + CUSTOM_URI_SUFFIX = "cognitiveservices.azure.com/translator/text/v3.0" + LENGTH_LIMIT = 50_000 + + # Hash which maps Discourse's locale code to Microsoft Translator's language code found in + # https://docs.microsoft.com/en-us/azure/cognitive-services/translator/language-support + # Format: Discourse Language Code: Azure Language Code + SUPPORTED_LANG_MAPPING = { + af: "af", + ar: "ar", + az: "az", + bg: "bg", + bn: "bn", + bo: "bo", + bs_BA: "bs", + ca: "ca", + cs: "cs", + cy: "cy", + da: "da", + de: "de", + el: "el", + en: "en", + en_GB: "en", + en_US: "en", + es: "es", + et: "et", + eu: "eu", + fa_IR: "fa", + fi: "fi", + fr: "fr", + ga: "ga", + gl: "gl", + gom: "gom", + gu: "gu", + ha: "ha", + he: "he", + hi: "hi", + hr: "hr", + hsb: "hsb", + ht: "ht", + hu: "hu", + hy: "hy", + id: "id", + ig: "ig", + ikt: "ikt", + is: "is", + it: "it", + iu: "iu", + iu_Latn: "iu-Latn", + ja: "ja", + ka: "ka", + kk: "kk", + km: "km", + kmr: "kmr", + kn: "kn", + ko: "ko", + ks: "ks", + ku: "ku", + ky: "ky", + ln: "ln", + lo: "lo", + lt: "lt", + lug: "lug", + lv: "lv", + lzh: "lzh", + mai: "mai", + mg: "mg", + mi: "mi", + mk: "mk", + ml: "ml", + mn: "mn-Cyrl", + mn_Cyrl: "mn-Cyrl", + mn_Mong: "mn-Mong", + mr: "mr", + ms: "ms", + mt: "mt", + mww: "mww", + my: "my", + nb: "nb", + nb_NO: "nb", + ne: "ne", + nl: "nl", + nso: "nso", + nya: "nya", + or: "or", + otq: "otq", + pa: "pa", + pl: "pl", + pl_PL: "pl", + prs: "prs", + ps: "ps", + pt: "pt", + pt_BR: "pt", + pt_pt: "pt", + ro: "ro", + ru: "ru", + run: "run", + rw: "rw", + sd: "sd", + si: "si", + sk: "sk", + sl: "sl", + sm: "sm", + sn: "sn", + so: "so", + sq: "sq", + sr: "sr-Cyrl", + sr_Cyrl: "sr-Cyrl", + sr_Latn: "sr-Latn", + st: "st", + sv: "sv", + sw: "sw", + ta: "ta", + te: "te", + th: "th", + ti: "ti", + tk: "tk", + tlh_Latn: "tlh-Latn", + tlh_Piqd: "tlh-Piqd", + tn: "tn", + to: "to", + tr: "tr", + tr_TR: "tr", + tt: "tt", + ty: "ty", + ug: "ug", + uk: "uk", + ur: "ur", + uz: "uz", + vi: "vi", + xh: "xh", + yo: "yo", + yua: "yua", + yue: "yue", + zh_CN: "zh-Hans", + zh_TW: "zh-Hant", + zu: "zu", + } + + def self.access_token_key + "microsoft-translator" + end + + def self.detect!(topic_or_post) + body = [{ "Text" => text_for_detection(topic_or_post) }].to_json + uri = URI(detect_endpoint) + uri.query = URI.encode_www_form(self.default_query) + result(uri.to_s, body, default_headers).first["language"] + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + detected_lang = detect(translatable) + + if text_for_translation(translatable).length > LENGTH_LIMIT + raise TranslatorError.new(I18n.t("translator.too_long")) + end + locale = + SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported")) + + query = default_query.merge("from" => detected_lang, "to" => locale, "textType" => "html") + body = [{ "Text" => text_for_translation(translatable) }].to_json + uri = URI(translate_endpoint) + uri.query = URI.encode_www_form(query) + response_body = result(uri.to_s, body, default_headers) + response_body.first["translations"].first["text"] + end + + def self.translate_supported?(detected_lang, target_lang) + SUPPORTED_LANG_MAPPING.keys.include?(detected_lang.to_sym) && + SUPPORTED_LANG_MAPPING.values.include?(detected_lang.to_s) + end + + private + + def self.detect_endpoint + custom_endpoint? ? custom_detect_endpoint : DETECT_URI + end + + def self.translate_endpoint + custom_endpoint? ? custom_translate_endpoint : TRANSLATE_URI + end + + def self.custom_base_endpoint + "https://#{SiteSetting.translator_azure_custom_subdomain}.#{CUSTOM_URI_SUFFIX}" + end + + def self.custom_detect_endpoint + "#{custom_base_endpoint}/detect" + end + + def self.custom_translate_endpoint + "#{custom_base_endpoint}/translate" + end + + def self.custom_endpoint? + SiteSetting.translator_azure_custom_subdomain.present? + end + + def self.post(uri, body, headers = {}) + connection = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } + connection.post(uri, body, headers) + end + + def self.result(uri, body, headers) + response = post(uri, body, headers) + response_body = JSON.parse(response.body) + + if response.status != 200 + if response_body["error"] && response_body["error"]["code"] + ProblemCheckTracker[:translator_error].problem!( + details: { + provider: "Microsoft", + code: response_body["error"]["code"], + message: response_body["error"]["message"], + }, + ) + raise ProblemCheckedTranslationError.new(response_body) + end + raise TranslatorError.new(response_body) + else + ProblemCheckTracker[:translator_error].no_problem! + response_body + end + end + + def self.default_headers + if SiteSetting.translator_azure_subscription_key.blank? + raise ProblemCheckedTranslationError.new(I18n.t("translator.microsoft.missing_key")) + end + + headers = { + "Content-Type" => "application/json", + "Ocp-Apim-Subscription-Key" => SiteSetting.translator_azure_subscription_key, + } + + if SiteSetting.translator_azure_region != "global" + headers["Ocp-Apim-Subscription-Region"] = SiteSetting.translator_azure_region + end + + headers + end + + def self.default_query + { "api-version" => "3.0" } + end + end + end +end diff --git a/app/services/discourse_translator/provider/yandex.rb b/app/services/discourse_translator/provider/yandex.rb new file mode 100644 index 0000000..657e3a5 --- /dev/null +++ b/app/services/discourse_translator/provider/yandex.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module DiscourseTranslator + module Provider + class Yandex < BaseProvider + TRANSLATE_URI = "https://translate.yandex.net/api/v1.5/tr.json/translate" + DETECT_URI = "https://translate.yandex.net/api/v1.5/tr.json/detect" + + # Hash which maps Discourse's locale code to Yandex Translate's language code found in + # https://yandex.com/dev/translate/doc/dg/concepts/api-overview.html + SUPPORTED_LANG_MAPPING = { + pt_BR: "pt", + pl_PL: "pl", + no_NO: "no", + fa_IR: "fa", + zh_CN: "zh", + zh_TW: "zh", + tr_TR: "tr", + en: "en", + en_US: "en", + en_GB: "en", + az: "az", + ml: "ml", + sq: "sq", + mt: "mt", + am: "am", + mk: "mk", + mi: "mi", + ar: "ar", + mr: "mr", + hy: "hy", + mhr: "mhr", + af: "af", + mn: "mn", + eu: "eu", + de: "de", + ba: "ba", + ne: "ne", + be: "be", + no: "no", + bn: "bn", + pa: "pa", + my: "my", + pap: "pap", + bg: "bg", + fa: "fa", + bs: "bs", + pl: "pl", + cy: "cy", + pt: "pt", + hu: "hu", + ro: "ro", + vi: "vi", + ru: "ru", + ht: "ht", + ceb: "ceb", + gl: "gl", + sr: "sr", + nl: "nl", + si: "si", + mrj: "mrj", + sk: "sk", + el: "el", + sl: "sl", + ka: "ka", + sw: "sw", + gu: "gu", + su: "su", + da: "da", + tg: "tg", + he: "he", + th: "th", + yi: "yi", + tl: "tl", + id: "id", + ta: "ta", + ga: "ga", + tt: "tt", + it: "it", + te: "te", + is: "is", + tr: "tr", + es: "es", + udm: "udm", + kk: "kk", + uz: "uz", + kn: "kn", + uk: "uk", + ca: "ca", + ur: "ur", + ky: "ky", + fi: "fi", + zh: "zh", + fr: "fr", + ko: "ko", + hi: "hi", + xh: "xh", + hr: "hr", + km: "km", + cs: "cs", + lo: "lo", + sv: "sv", + la: "la", + gd: "gd", + lv: "lv", + et: "et", + lt: "lt", + eo: "eo", + lb: "lb", + jv: "jv", + mg: "mg", + ja: "ja", + ms: "ms", + } + + def self.access_token_key + "yandex-translator" + end + + def self.access_token + SiteSetting.translator_yandex_api_key || + (raise TranslatorError.new("NotFound: Yandex API Key not set.")) + end + + def self.detect!(topic_or_post) + query = default_query.merge("text" => text_for_detection(topic_or_post)) + uri = URI(DETECT_URI) + uri.query = URI.encode_www_form(query) + result(uri.to_s, "", default_headers)["lang"] + end + + def self.translate_translatable!(translatable, target_locale_sym = I18n.locale) + detected_lang = detect(translatable) + locale = + SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported")) + + query = + default_query.merge( + "lang" => "#{detected_lang}-#{locale}", + "text" => text_for_translation(translatable), + "format" => "html", + ) + + uri = URI(TRANSLATE_URI) + uri.query = URI.encode_www_form(query) + + response_body = result(uri.to_s, "", default_headers) + response_body["text"][0] + end + + def self.translate_supported?(detected_lang, target_lang) + SUPPORTED_LANG_MAPPING.keys.include?(detected_lang.to_sym) && + SUPPORTED_LANG_MAPPING.values.include?(detected_lang.to_s) + end + + private + + def self.post(uri, body, headers = {}) + Excon.post(uri, body: body, headers: headers) + end + + def self.result(uri, body, headers) + response = post(uri, body, headers) + response_body = JSON.parse(response.body) + + if response.status != 200 + raise TranslatorError.new(response_body) + else + response_body + end + end + + def self.default_headers + { "Content-Type" => "application/x-www-form-urlencoded" } + end + + def self.default_query + { key: access_token } + end + end + end +end diff --git a/app/services/discourse_translator/yandex.rb b/app/services/discourse_translator/yandex.rb deleted file mode 100644 index f12efc0..0000000 --- a/app/services/discourse_translator/yandex.rb +++ /dev/null @@ -1,182 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" - -module DiscourseTranslator - class Yandex < Base - TRANSLATE_URI = "https://translate.yandex.net/api/v1.5/tr.json/translate" - DETECT_URI = "https://translate.yandex.net/api/v1.5/tr.json/detect" - - # Hash which maps Discourse's locale code to Yandex Translate's language code found in - # https://yandex.com/dev/translate/doc/dg/concepts/api-overview.html - SUPPORTED_LANG_MAPPING = { - pt_BR: "pt", - pl_PL: "pl", - no_NO: "no", - fa_IR: "fa", - zh_CN: "zh", - zh_TW: "zh", - tr_TR: "tr", - en: "en", - en_US: "en", - en_GB: "en", - az: "az", - ml: "ml", - sq: "sq", - mt: "mt", - am: "am", - mk: "mk", - mi: "mi", - ar: "ar", - mr: "mr", - hy: "hy", - mhr: "mhr", - af: "af", - mn: "mn", - eu: "eu", - de: "de", - ba: "ba", - ne: "ne", - be: "be", - no: "no", - bn: "bn", - pa: "pa", - my: "my", - pap: "pap", - bg: "bg", - fa: "fa", - bs: "bs", - pl: "pl", - cy: "cy", - pt: "pt", - hu: "hu", - ro: "ro", - vi: "vi", - ru: "ru", - ht: "ht", - ceb: "ceb", - gl: "gl", - sr: "sr", - nl: "nl", - si: "si", - mrj: "mrj", - sk: "sk", - el: "el", - sl: "sl", - ka: "ka", - sw: "sw", - gu: "gu", - su: "su", - da: "da", - tg: "tg", - he: "he", - th: "th", - yi: "yi", - tl: "tl", - id: "id", - ta: "ta", - ga: "ga", - tt: "tt", - it: "it", - te: "te", - is: "is", - tr: "tr", - es: "es", - udm: "udm", - kk: "kk", - uz: "uz", - kn: "kn", - uk: "uk", - ca: "ca", - ur: "ur", - ky: "ky", - fi: "fi", - zh: "zh", - fr: "fr", - ko: "ko", - hi: "hi", - xh: "xh", - hr: "hr", - km: "km", - cs: "cs", - lo: "lo", - sv: "sv", - la: "la", - gd: "gd", - lv: "lv", - et: "et", - lt: "lt", - eo: "eo", - lb: "lb", - jv: "jv", - mg: "mg", - ja: "ja", - ms: "ms", - } - - def self.access_token_key - "yandex-translator" - end - - def self.access_token - SiteSetting.translator_yandex_api_key || - (raise TranslatorError.new("NotFound: Yandex API Key not set.")) - end - - def self.detect!(topic_or_post) - query = default_query.merge("text" => text_for_detection(topic_or_post)) - uri = URI(DETECT_URI) - uri.query = URI.encode_www_form(query) - result(uri.to_s, "", default_headers)["lang"] - end - - def self.translate!(translatable, target_locale_sym = I18n.locale) - detected_lang = detect(translatable) - locale = - SUPPORTED_LANG_MAPPING[target_locale_sym] || (raise I18n.t("translator.not_supported")) - - query = - default_query.merge( - "lang" => "#{detected_lang}-#{locale}", - "text" => text_for_translation(translatable), - "format" => "html", - ) - - uri = URI(TRANSLATE_URI) - uri.query = URI.encode_www_form(query) - - response_body = result(uri.to_s, "", default_headers) - response_body["text"][0] - end - - def self.translate_supported?(detected_lang, target_lang) - SUPPORTED_LANG_MAPPING.keys.include?(detected_lang.to_sym) && - SUPPORTED_LANG_MAPPING.values.include?(detected_lang.to_s) - end - - private - - def self.post(uri, body, headers = {}) - Excon.post(uri, body: body, headers: headers) - end - - def self.result(uri, body, headers) - response = post(uri, body, headers) - response_body = JSON.parse(response.body) - - if response.status != 200 - raise TranslatorError.new(response_body) - else - response_body - end - end - - def self.default_headers - { "Content-Type" => "application/x-www-form-urlencoded" } - end - - def self.default_query - { key: access_token } - end - end -end diff --git a/spec/jobs/automatic_translation_backfill_spec.rb b/spec/jobs/automatic_translation_backfill_spec.rb index 56dc036..4281924 100644 --- a/spec/jobs/automatic_translation_backfill_spec.rb +++ b/spec/jobs/automatic_translation_backfill_spec.rb @@ -10,7 +10,7 @@ def expect_google_check_language Excon .expects(:post) - .with(DiscourseTranslator::Google::SUPPORT_URI, anything, anything) + .with(DiscourseTranslator::Provider::Google::SUPPORT_URI, anything, anything) .returns( Struct.new(:status, :body).new( 200, @@ -23,7 +23,7 @@ def expect_google_check_language def expect_google_detect(locale) Excon .expects(:post) - .with(DiscourseTranslator::Google::DETECT_URI, anything, anything) + .with(DiscourseTranslator::Provider::Google::DETECT_URI, anything, anything) .returns( Struct.new(:status, :body).new( 200, @@ -36,7 +36,7 @@ def expect_google_detect(locale) def expect_google_translate(text) Excon .expects(:post) - .with(DiscourseTranslator::Google::TRANSLATE_URI, body: anything, headers: anything) + .with(DiscourseTranslator::Provider::Google::TRANSLATE_URI, body: anything, headers: anything) .returns( Struct.new(:status, :body).new( 200, diff --git a/spec/jobs/translate_translatable_spec.rb b/spec/jobs/translate_translatable_spec.rb index 0755ba1..7fdf904 100644 --- a/spec/jobs/translate_translatable_spec.rb +++ b/spec/jobs/translate_translatable_spec.rb @@ -10,7 +10,7 @@ SiteSetting.translator_provider = "Google" SiteSetting.automatic_translation_backfill_rate = 100 SiteSetting.automatic_translation_target_languages = "es|fr" - allow(DiscourseTranslator::Google).to receive(:translate) + allow(DiscourseTranslator::Provider::Google).to receive(:translate) end describe "#execute" do @@ -19,7 +19,7 @@ job.execute(type: "Post", translatable_id: post.id) - expect(DiscourseTranslator::Google).not_to have_received(:translate) + expect(DiscourseTranslator::Provider::Google).not_to have_received(:translate) end it "does nothing when target languages are empty" do @@ -27,7 +27,7 @@ job.execute(type: "Post", translatable_id: post.id) - expect(DiscourseTranslator::Google).not_to have_received(:translate) + expect(DiscourseTranslator::Provider::Google).not_to have_received(:translate) end it "translates posts to configured target languages" do @@ -38,8 +38,8 @@ job.execute(type: "Post", translatable_id: post.id) - expect(DiscourseTranslator::Google).to have_received(:translate).with(post, :es) - expect(DiscourseTranslator::Google).to have_received(:translate).with(post, :fr) + expect(DiscourseTranslator::Provider::Google).to have_received(:translate).with(post, :es) + expect(DiscourseTranslator::Provider::Google).to have_received(:translate).with(post, :fr) end it "translates topics to configured target languages" do @@ -47,8 +47,8 @@ job.execute(type: "Topic", translatable_id: topic.id) - expect(DiscourseTranslator::Google).to have_received(:translate).with(topic, :es) - expect(DiscourseTranslator::Google).to have_received(:translate).with(topic, :fr) + expect(DiscourseTranslator::Provider::Google).to have_received(:translate).with(topic, :es) + expect(DiscourseTranslator::Provider::Google).to have_received(:translate).with(topic, :fr) end it "does nothing when translatable is not found" do @@ -56,7 +56,7 @@ job.execute(type: "Post", translatable_id: -1) - expect(DiscourseTranslator::Google).not_to have_received(:translate) + expect(DiscourseTranslator::Provider::Google).not_to have_received(:translate) end end end diff --git a/spec/requests/translator_controller_spec.rb b/spec/requests/translator_controller_spec.rb index aa96d6a..6bbdb0d 100644 --- a/spec/requests/translator_controller_spec.rb +++ b/spec/requests/translator_controller_spec.rb @@ -14,9 +14,15 @@ module DiscourseTranslator shared_examples "translation_successful" do it "returns the translated text" do - DiscourseTranslator::Microsoft.expects(:translate).with(reply).returns(%w[ja ニャン猫]) + DiscourseTranslator::Provider::Microsoft + .expects(:translate) + .with(reply) + .returns(%w[ja ニャン猫]) if reply.is_first_post? - DiscourseTranslator::Microsoft.expects(:translate).with(reply.topic).returns(%w[ja タイトル]) + DiscourseTranslator::Provider::Microsoft + .expects(:translate) + .with(reply.topic) + .returns(%w[ja タイトル]) end post "/translator/translate.json", params: { post_id: reply.id } @@ -84,8 +90,8 @@ module DiscourseTranslator end it "rescues translator errors" do - DiscourseTranslator::Microsoft.expects(:translate).raises( - ::DiscourseTranslator::TranslatorError, + DiscourseTranslator::Provider::Microsoft.expects(:translate).raises( + ::DiscourseTranslator::Provider::TranslatorError, ) post "/translator/translate.json", params: { post_id: reply.id } diff --git a/spec/services/amazon_spec.rb b/spec/services/amazon_spec.rb index 2182e23..f69ea49 100644 --- a/spec/services/amazon_spec.rb +++ b/spec/services/amazon_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -RSpec.describe DiscourseTranslator::Amazon do +RSpec.describe DiscourseTranslator::Provider::Amazon do let(:mock_response) { Struct.new(:status, :body) } describe ".truncate" do diff --git a/spec/services/base_spec.rb b/spec/services/base_provider_spec.rb similarity index 79% rename from spec/services/base_spec.rb rename to spec/services/base_provider_spec.rb index 19f9542..122b8f4 100644 --- a/spec/services/base_spec.rb +++ b/spec/services/base_provider_spec.rb @@ -2,12 +2,12 @@ require "rails_helper" -describe DiscourseTranslator::Base do - class TestTranslator < DiscourseTranslator::Base +describe DiscourseTranslator::Provider::BaseProvider do + class TestTranslator < DiscourseTranslator::Provider::BaseProvider SUPPORTED_LANG_MAPPING = { en: "en", ar: "ar", es_MX: "es-MX", pt: "pt" } end - class EmptyTranslator < DiscourseTranslator::Base + class EmptyTranslator < DiscourseTranslator::Provider::BaseProvider end describe ".language_supported?" do @@ -39,19 +39,21 @@ class EmptyTranslator < DiscourseTranslator::Base it "truncates to DETECTION_CHAR_LIMIT of 1000" do post.raw = "a" * 1001 - expect(DiscourseTranslator::Base.text_for_detection(post).length).to eq(1000) + expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(post).length).to eq( + 1000, + ) end it "returns the text if it's less than DETECTION_CHAR_LIMIT" do text = "a" * 999 post.raw = text - expect(DiscourseTranslator::Base.text_for_detection(post)).to eq(text) + expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(post)).to eq(text) end it "appends some text from the first post for topics" do topic.first_post.raw = "a" * 999 expected = (topic.title + " " + topic.first_post.raw).truncate(1000) - expect(DiscourseTranslator::Base.text_for_detection(topic)).to eq(expected) + expect(DiscourseTranslator::Provider::BaseProvider.text_for_detection(topic)).to eq(expected) end end @@ -60,16 +62,16 @@ class EmptyTranslator < DiscourseTranslator::Base it "truncates to max_characters_per_translation" do post.cooked = "a" * (SiteSetting.max_characters_per_translation + 1) - expect(DiscourseTranslator::Base.text_for_translation(post).length).to eq( + expect(DiscourseTranslator::Provider::BaseProvider.text_for_translation(post).length).to eq( SiteSetting.max_characters_per_translation, ) end it "uses raw if required" do post.raw = "a" * (SiteSetting.max_characters_per_translation + 1) - expect(DiscourseTranslator::Base.text_for_translation(post, raw: true).length).to eq( - SiteSetting.max_characters_per_translation, - ) + expect( + DiscourseTranslator::Provider::BaseProvider.text_for_translation(post, raw: true).length, + ).to eq(SiteSetting.max_characters_per_translation) end end @@ -129,12 +131,14 @@ class EmptyTranslator < DiscourseTranslator::Base TestTranslator.save_detected_locale(post) { "xx" } TestTranslator.expects(:translate_supported?).with("xx", :en).returns(false) - expect { TestTranslator.translate(post) }.to raise_error(DiscourseTranslator::TranslatorError) + expect { TestTranslator.translate(post) }.to raise_error( + DiscourseTranslator::Provider::TranslatorError, + ) end it "performs translation when needed" do TestTranslator.save_detected_locale(post) { "es" } - TestTranslator.expects(:translate!).returns("hello") + TestTranslator.expects(:translate_translatable!).returns("hello") expect(TestTranslator.translate(post)).to eq(%w[es hello]) end diff --git a/spec/services/discourse_ai_spec.rb b/spec/services/discourse_ai_spec.rb index 5a8c14b..9d7e26f 100644 --- a/spec/services/discourse_ai_spec.rb +++ b/spec/services/discourse_ai_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe DiscourseTranslator::DiscourseAi do +describe DiscourseTranslator::Provider::DiscourseAi do fab!(:post) fab!(:topic) @@ -29,7 +29,7 @@ it "returns the detected language" do locale = "de" DiscourseAi::Completions::Llm.with_prepared_responses([locale_json(locale)]) do - expect(DiscourseTranslator::DiscourseAi.detect!(post)).to eq locale + expect(DiscourseTranslator::Provider::DiscourseAi.detect!(post)).to eq locale end end end @@ -44,7 +44,7 @@ DiscourseAi::Completions::Llm.with_prepared_responses( [translation_json("some translated text")], ) do - locale, translated_text = DiscourseTranslator::DiscourseAi.translate(post) + locale, translated_text = DiscourseTranslator::Provider::DiscourseAi.translate(post) expect(locale).to eq "de" expect(translated_text).to eq "
some translated text
" end @@ -55,7 +55,7 @@ DiscourseAi::Completions::Llm.with_prepared_responses( [translation_json("some translated text")], ) do - locale, translated_text = DiscourseTranslator::DiscourseAi.translate(topic) + locale, translated_text = DiscourseTranslator::Provider::DiscourseAi.translate(topic) expect(locale).to eq "de" expect(translated_text).to eq "some translated text" end @@ -65,7 +65,11 @@ post.update(raw: "#{"a" * 3000} #{"b" * 3000}") DiscourseAi::Completions::Llm.with_prepared_responses( %w[lol wut].map { |content| translation_json(content) }, - ) { expect(DiscourseTranslator::DiscourseAi.translate!(post)).to eq "lolwut
" } + ) do + expect( + DiscourseTranslator::Provider::DiscourseAi.translate_translatable!(post), + ).to eq "lolwut
" + end end end diff --git a/spec/services/google_spec.rb b/spec/services/google_spec.rb index dcabe08..4a82d06 100644 --- a/spec/services/google_spec.rb +++ b/spec/services/google_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -RSpec.describe DiscourseTranslator::Google do +RSpec.describe DiscourseTranslator::Provider::Google do let(:api_key) { "12345" } before do SiteSetting.translator_enabled = true @@ -43,9 +43,13 @@ post.raw = rand(36**length).to_s(36) detected_lang = "en" - request_url = "#{DiscourseTranslator::Google::DETECT_URI}" + request_url = "#{DiscourseTranslator::Provider::Google::DETECT_URI}" body = { - q: post.raw.truncate(DiscourseTranslator::Google::DETECTION_CHAR_LIMIT, omission: nil), + q: + post.raw.truncate( + DiscourseTranslator::Provider::Google::DETECTION_CHAR_LIMIT, + omission: nil, + ), key: api_key, } @@ -77,7 +81,7 @@ it "equates source language to target" do source = "en" target = "fr" - stub_request(:post, DiscourseTranslator::Google::SUPPORT_URI).to_return( + stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( status: 200, body: %{ { "data": { "languages": [ { "language": "#{source}" }] } } }, ) @@ -87,7 +91,7 @@ it "checks again without -* when the source language is not supported" do source = "en" target = "fr" - stub_request(:post, DiscourseTranslator::Google::SUPPORT_URI).to_return( + stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( status: 200, body: %{ { "data": { "languages": [ { "language": "#{source}" }] } } }, ) @@ -96,14 +100,14 @@ end end - describe ".translate!" do + describe ".translate_translatable!" do let(:post) { Fabricate(:post) } it "raises an error and warns admin on failure" do described_class.expects(:access_token).returns(api_key) described_class.expects(:detect).returns("__") - stub_request(:post, DiscourseTranslator::Google::SUPPORT_URI).to_return( + stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( status: 400, body: { error: { @@ -116,7 +120,7 @@ ProblemCheckTracker[:translator_error].no_problem! expect { described_class.translate(post) }.to raise_error( - DiscourseTranslator::ProblemCheckedTranslationError, + DiscourseTranslator::Provider::ProblemCheckedTranslationError, ) expect(AdminNotice.problem.last.message).to eq( @@ -136,7 +140,9 @@ Excon.expects(:post).returns(mock_response.new(413, "some html")) - expect { described_class.translate(post) }.to raise_error DiscourseTranslator::TranslatorError + expect { + described_class.translate(post) + }.to raise_error DiscourseTranslator::Provider::TranslatorError end it "returns error with source and target locale when translation is not supported" do @@ -163,11 +169,11 @@ } translated_text = "hur dur hur dur" - stub_request(:post, DiscourseTranslator::Google::SUPPORT_URI).to_return( + stub_request(:post, DiscourseTranslator::Provider::Google::SUPPORT_URI).to_return( status: 200, body: %{ { "data": { "languages": [ { "language": "de" }] } } }, ) - stub_request(:post, DiscourseTranslator::Google::TRANSLATE_URI).with( + stub_request(:post, DiscourseTranslator::Provider::Google::TRANSLATE_URI).with( body: URI.encode_www_form(body), headers: { "Content-Type" => "application/x-www-form-urlencoded", @@ -178,7 +184,7 @@ body: %{ { "data": { "translations": [ { "translatedText": "#{translated_text}" } ] } } }, ) - expect(described_class.translate!(post)).to eq(translated_text) + expect(described_class.translate_translatable!(post)).to eq(translated_text) end end end diff --git a/spec/services/libre_translate_spec.rb b/spec/services/libre_translate_spec.rb index 536fb4a..020da6d 100644 --- a/spec/services/libre_translate_spec.rb +++ b/spec/services/libre_translate_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -RSpec.describe DiscourseTranslator::LibreTranslate do +RSpec.describe DiscourseTranslator::Provider::LibreTranslate do let(:mock_response) { Struct.new(:status, :body) } let(:api_key) { "12345" } diff --git a/spec/services/microsoft_spec.rb b/spec/services/microsoft_spec.rb index 463b5f5..9f530c3 100644 --- a/spec/services/microsoft_spec.rb +++ b/spec/services/microsoft_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - -RSpec.describe DiscourseTranslator::Microsoft do +RSpec.describe DiscourseTranslator::Provider::Microsoft do before { SiteSetting.translator_enabled = true } after { Discourse.redis.del(described_class.cache_key) } @@ -66,7 +64,7 @@ def detect_endpoint ProblemCheckTracker[:translator_error].no_problem! expect { described_class.detect(post) }.to raise_error( - DiscourseTranslator::ProblemCheckedTranslationError, + DiscourseTranslator::Provider::ProblemCheckedTranslationError, ) expect(AdminNotice.problem.last.message).to eq( @@ -104,7 +102,7 @@ def detect_endpoint context "without azure key" do it "raise a MicrosoftNoAzureKeyError" do expect { described_class.detect(post) }.to raise_error( - DiscourseTranslator::ProblemCheckedTranslationError, + DiscourseTranslator::Provider::ProblemCheckedTranslationError, I18n.t("translator.microsoft.missing_key"), ) end @@ -168,7 +166,7 @@ def translate_endpoint post.set_detected_locale("donkey") expect { described_class.translate(post) }.to raise_error( - DiscourseTranslator::TranslatorError, + DiscourseTranslator::Provider::TranslatorError, I18n.t("translator.failed.post", source_locale: "donkey", target_locale: I18n.locale), ) end @@ -176,10 +174,12 @@ def translate_endpoint it "raises an error if the post is too long to be translated" do I18n.locale = "ja" SiteSetting.max_characters_per_translation = 100_000 - post.update_columns(cooked: "*" * (DiscourseTranslator::Microsoft::LENGTH_LIMIT + 1)) + post.update_columns( + cooked: "*" * (DiscourseTranslator::Provider::Microsoft::LENGTH_LIMIT + 1), + ) expect { described_class.translate(post) }.to raise_error( - DiscourseTranslator::TranslatorError, + DiscourseTranslator::Provider::TranslatorError, I18n.t("translator.too_long"), ) end @@ -204,7 +204,7 @@ def translate_endpoint ) expect { described_class.translate(post) }.to raise_error( - DiscourseTranslator::TranslatorError, + DiscourseTranslator::Provider::TranslatorError, ) end end diff --git a/spec/services/yandex_spec.rb b/spec/services/yandex_spec.rb index 3a59ecf..85fe6b5 100644 --- a/spec/services/yandex_spec.rb +++ b/spec/services/yandex_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - -RSpec.describe DiscourseTranslator::Yandex do +RSpec.describe DiscourseTranslator::Provider::Yandex do let(:mock_response) { Struct.new(:status, :body) } describe ".access_token" do @@ -49,7 +47,9 @@ ), ) - expect { described_class.translate(post) }.to raise_error DiscourseTranslator::TranslatorError + expect { + described_class.translate(post) + }.to raise_error DiscourseTranslator::Provider::TranslatorError end end end