synchronizes_from_po.rb 5.4 KB

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