translate_status_service.rb 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. # frozen_string_literal: true
  2. class TranslateStatusService < BaseService
  3. CACHE_TTL = 1.day.freeze
  4. include ERB::Util
  5. include FormattingHelper
  6. def call(status, target_language)
  7. @status = status
  8. @source_texts = source_texts
  9. target_language = target_language.split(/[_-]/).first unless target_languages.include?(target_language)
  10. @target_language = target_language
  11. raise Mastodon::NotPermittedError unless permitted?
  12. status_translation = Rails.cache.fetch("v2:translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do
  13. translations = translation_backend.translate(@source_texts.values, @status.language, @target_language)
  14. build_status_translation(translations)
  15. end
  16. status_translation.status = @status
  17. status_translation
  18. end
  19. private
  20. def translation_backend
  21. @translation_backend ||= TranslationService.configured
  22. end
  23. def permitted?
  24. return false unless @status.distributable? && TranslationService.configured?
  25. target_languages.include?(@target_language)
  26. end
  27. def languages
  28. Rails.cache.fetch('translation_service/languages', expires_in: 7.days, race_condition_ttl: 1.hour) { translation_backend.languages }
  29. end
  30. def target_languages
  31. languages[@status.language] || []
  32. end
  33. def content_hash
  34. Digest::SHA256.base64digest(@source_texts.transform_keys { |key| key.respond_to?(:id) ? "#{key.class}-#{key.id}" : key }.to_json)
  35. end
  36. def source_texts
  37. texts = {}
  38. texts[:content] = wrap_emoji_shortcodes(status_content_format(@status)) if @status.content.present?
  39. texts[:spoiler_text] = wrap_emoji_shortcodes(html_escape(@status.spoiler_text)) if @status.spoiler_text.present?
  40. @status.preloadable_poll&.loaded_options&.each do |option|
  41. texts[option] = wrap_emoji_shortcodes(html_escape(option.title))
  42. end
  43. @status.media_attachments.each do |media_attachment|
  44. texts[media_attachment] = html_escape(media_attachment.description)
  45. end
  46. texts
  47. end
  48. def build_status_translation(translations)
  49. status_translation = Translation.new(
  50. detected_source_language: translations.first&.detected_source_language,
  51. language: @target_language,
  52. provider: translations.first&.provider,
  53. content: '',
  54. spoiler_text: '',
  55. poll_options: [],
  56. media_attachments: []
  57. )
  58. @source_texts.keys.each_with_index do |source, index|
  59. translation = translations[index]
  60. case source
  61. when :content
  62. node = unwrap_emoji_shortcodes(translation.text)
  63. Sanitize.node!(node, Sanitize::Config::MASTODON_STRICT)
  64. status_translation.content = node.to_html
  65. when :spoiler_text
  66. status_translation.spoiler_text = unwrap_emoji_shortcodes(translation.text).content
  67. when Poll::Option
  68. status_translation.poll_options << Translation::Option.new(
  69. title: unwrap_emoji_shortcodes(translation.text).content
  70. )
  71. when MediaAttachment
  72. status_translation.media_attachments << Translation::MediaAttachment.new(
  73. id: source.id,
  74. description: html_entities.decode(translation.text)
  75. )
  76. end
  77. end
  78. status_translation
  79. end
  80. def wrap_emoji_shortcodes(text)
  81. EmojiFormatter.new(text, @status.emojis, { raw_shortcode: true }).to_s
  82. end
  83. def unwrap_emoji_shortcodes(html)
  84. fragment = Nokogiri::HTML5.fragment(html)
  85. fragment.css('span[translate="no"]').each do |element|
  86. element.remove_attribute('translate')
  87. element.replace(element.children) if element.attributes.empty?
  88. end
  89. fragment
  90. end
  91. def html_entities
  92. HTMLEntities.new
  93. end
  94. end