synchronizes_from_po.rb 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. module Translation::SynchronizesFromPo
  3. extend ActiveSupport::Concern
  4. # Represents an entry to import to the Translation table.
  5. class TranslationEntry
  6. attr_reader :source, :translation_file, :skip_sync
  7. attr_accessor :translation
  8. def self.create(locale, file, entry)
  9. source = unescape_po(entry.msgid.to_s)
  10. # Make sure to ignore fuzzy entries.
  11. translation = entry.translated? ? unescape_po(entry.msgstr.to_s) : ''
  12. # For 'en-*' locales, treat source as translation as well, to indicate that nothing is missing.
  13. translation = source if translation.empty? && locale.start_with?('en')
  14. # Some strings are not needed in the database, because changes will take no effect and
  15. # also keep storage small.
  16. skip_sync = entry.reference.present? && [entry.reference].flatten.all? do |ref|
  17. # Ignore strings that come only from the chat, form and view_template extractors.
  18. # We tried avoiding this by using gettext flags in the pot file, but they don't propagate
  19. # correctly to the translation files.
  20. ref.to_s.start_with?(%r{public/assets/(?:chat|form)/|app/views/(?:mailer|messaging)/[^/]+/})
  21. end
  22. new(source: source, translation: translation, translation_file: file, skip_sync: skip_sync)
  23. end
  24. def self.unescape_po(string)
  25. string.gsub(%r{\\n}, "\n").gsub(%r{\\"}, '"').gsub(%r{\\\\}, '\\')
  26. end
  27. def skip_sync?
  28. @skip_sync
  29. end
  30. private
  31. def initialize(source:, translation:, translation_file:, skip_sync:)
  32. @source = source
  33. @translation = translation
  34. @translation_file = translation_file
  35. @skip_sync = skip_sync
  36. end
  37. end
  38. class_methods do # rubocop:disable Metrics/BlockLength
  39. def sync
  40. Locale.to_sync.each do |locale|
  41. ActiveRecord::Base.transaction do
  42. sync_locale_from_po locale.locale
  43. end
  44. end
  45. end
  46. def sync_locale_from_po(locale) # rubocop:disable Metrics/AbcSize
  47. previous_translation_map = Translation.where(locale: locale).index_by(&:source)
  48. previous_unmodified_translations = Translation.where(locale: locale, is_synchronized_from_codebase: true).select { |t| t.target.eql?(t.target_initial) }
  49. updated_translation_ids = Set[]
  50. importable_translations = []
  51. strings_for_locale(locale).each_pair do |source, entry| # rubocop:disable Metrics/BlockLength
  52. next if entry.skip_sync?
  53. if source.length > 3000 || entry.translation.length > 3000
  54. Rails.logger.error "Cannot import translation for locale #{locale} because it exceeds maximum string length of 3000: source: '#{source}', translation: '#{entry.translation}'"
  55. next
  56. end
  57. t = previous_translation_map[source]
  58. # New string
  59. if !t
  60. importable_translations << Translation.new(
  61. locale: locale,
  62. source: source,
  63. target: entry.translation,
  64. target_initial: entry.translation,
  65. is_synchronized_from_codebase: true,
  66. synchronized_from_translation_file: entry.translation_file,
  67. created_by_id: 1,
  68. updated_by_id: 1
  69. )
  70. next
  71. end
  72. # Existing string
  73. # Only change the target if it was not modified by the user
  74. t.target = entry.translation if t.target.eql? t.target_initial
  75. t.is_synchronized_from_codebase = true
  76. t.synchronized_from_translation_file = entry.translation_file
  77. t.target_initial = entry.translation
  78. if t.changed.present?
  79. t.updated_by_id = 1
  80. t.save!
  81. end
  82. updated_translation_ids.add t.id
  83. end
  84. Translation.bulk_import importable_translations
  85. # Remove any unmodified & synchronized strings that are not present in the data any more.
  86. previous_unmodified_translations.reject { |t| updated_translation_ids.member? t.id }.each(&:destroy!)
  87. true
  88. end
  89. def cached_strings_for_locale(locale)
  90. @cached_strings_for_locale ||= {}
  91. @cached_strings_for_locale[locale] ||= strings_for_locale(locale).freeze
  92. end
  93. def strings_for_locale(locale)
  94. result = {}
  95. po_files_for_locale(locale).each do |file|
  96. require 'poparser' # Only load when it is actually used
  97. PoParser.parse_file(Rails.root.join(file)).entries.each do |entry|
  98. TranslationEntry.create(locale, file, entry).tap do |translation_entry|
  99. # In case of Serbian Latin locale, transliterate the translation string to latin alphabet on-the-fly.
  100. if locale == 'sr-latn-rs'
  101. require 'byk/safe' # Only load when it is actually used
  102. translation_entry.translation = Byk.to_latin(translation_entry.translation)
  103. end
  104. result[translation_entry.source] = translation_entry
  105. end
  106. end
  107. end
  108. result
  109. end
  110. # Returns all po files for a locale with zammad.*.po as first entry,
  111. # followed by all other files in alphabetical order
  112. # For en-us, i18n/zammad.pot will be returned instead.
  113. def po_files_for_locale(locale)
  114. return ['i18n/zammad.pot'] if locale.eql? 'en-us'
  115. locale_name = locale
  116. # In case of Serbian Latin locale, return Serbian Cyrillic po files instead.
  117. locale_name = 'sr-cyrl-rs' if locale_name == 'sr-latn-rs'
  118. files = Dir.glob "i18n/*.#{locale_name}.po", base: Rails.root
  119. if files.exclude?("i18n/zammad.#{locale_name}.po")
  120. Rails.logger.error "No translation found for locale '#{locale_name}'."
  121. return []
  122. end
  123. [
  124. files.delete("i18n/zammad.#{locale_name}.po"),
  125. *files.sort
  126. ]
  127. end
  128. end
  129. end