translate_status_service.rb 3.4 KB

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