Skip to content

Instantly share code, notes, and snippets.

@vsvld
Last active February 18, 2019 10:12
Show Gist options
  • Save vsvld/e1db73baa8637ed00aace5e5ccd219b4 to your computer and use it in GitHub Desktop.
Save vsvld/e1db73baa8637ed00aace5e5ccd219b4 to your computer and use it in GitHub Desktop.
##
# Mixin for classes that should have locales and ability for their import/export.
#
module LocalizableDocument
extend ActiveSupport::Concern
included do
field :default_locale, type: String
field :locales, type: Hash, default: {} # { "fr" => { fields_of_document_to_be_translated } }
end
# OPTIMIZE add constants for headers of csv file ("Section", "Field to translate")
##
# Adds available language for translation.
#
# @param [String] language
#
def add_locale(language)
self.locales[language] = {}
end
##
# Get translation for particular field.
#
# @param [String] language
# @param [Array<String>] keys
#
# @example self.translate("fr", ["invitations", "email", "body"])
#
# @return [String, nil] translation or nil
#
def translate(language, keys)
locales[language].try(:dig, *keys) unless locales.blank?
end
##
# Get translation for particular field, returns original value if not found.
#
# @param [String] language
# @param [Array<String>] keys
#
# @example self.translate_or_default("fr", ["invitations", "email", "body"])
#
# @return [String] translation or original value
#
def translate_or_original(language, keys)
translate(language, keys) || original_field_by_keys(keys)
end
##
# Get particular field by its path keys.
#
# @param [Array<String>] keys
#
# @example self.original_field_by_keys(["invitations", "email", "body"])
#
# @return [String, nil] value or nil if not found
#
def original_field_by_keys(keys)
keys_array = keys.clone
result =
if keys_array.size > 1
self[keys_array.shift].dig(*keys_array)
else
self[keys_array.first]
end
result || ""
end
##
# Get translations for particular field for several languages.
#
# @param [Array<String>] languages
# @param [Array<String>] keys
# @param [Boolean] original_first does original value stand in the beginning of resulting array?
#
# @example self.translate_multiple(["fr", "en"], ["invitations", "email", "body"])
#
# @return [Array] translations
#
def translate_multiple(languages, keys, original_first=false)
array = original_first ? [original_field_by_keys(keys)] : []
languages.each { |language| array << (translate(language, keys) || "") }
array
end
##
# Get translations for all available languages including default one.
#
# @param [Array<String>] keys
# @example self.translate_for_all_langs(["invitations", "email", "body"])
# @return [Array] translations
#
def translate_for_all_langs(keys)
translate_multiple(locales.keys, keys, true)
end
##
# Add translation field to locale.
#
# @param [String] language
# @param [Array<String>] keys
# @param [String] value
#
def add_translation(language, keys, value)
add_locale(language) if locales[language].nil?
# create nested hash from "keys" array with "value" as value for last nested key
# ex: keys ["invitations", "email", "body"] with value "bla" => {"invitations" => {"email" => {"body" => "bla"}}}
hash = keys.reverse.inject(value) { |a, n| { n => a } }
# add new translation field to existing hash of translations
locales[language].deep_merge!(hash)
end
##
# Assign value for master locale. Do not assign if blank.
#
# @param [Array<String>] keys
# @param [String] value
#
def assign_value_for_master_lang(keys, value)
if value.present?
# create nested hash from "keys" array with "value" as value for last nested key
# ex: keys ["invitations", "email", "body"] with value "bla" => {"invitations" => {"email" => {"body" => "bla"}}}
hash = keys.reverse.inject(value) { |a, n| { n => a } }
# in case of updating one value in hash, we need to merge it
# with its other values and then pass it as an attribute to update
field = keys.first
hash[field] = self[field].deep_merge(hash.values.first) if self[field].is_a? Hash
# add new translation field to existing hash of translations
self.assign_attributes(hash)
end
end
##
# Add translations from a collection containing translations.
#
# @param [Array<String>] keys
# @param [Object] collection hash or collection has #to_hash and structure like: { "lang" => "transl" }
# @param [Boolean] update_master_lang do we update master language, found in default_locale_name column
#
def add_translations(keys, collection, update_master_lang = false)
headers_to_exclude = ["Section", "Field to translate"]
headers_to_exclude << default_locale_name unless update_master_lang
translations = collection.to_hash.except(*headers_to_exclude)
is_any_blank = nil
translations.each do |lang, value|
if lang == default_locale_name
assign_value_for_master_lang(keys, value)
else
add_translation(lang, keys, value)
is_any_blank ||= (value.blank? || nil)
end
end
# true if if any of translations is blank while master language is not; stay nil otherwise
@import_info[:are_translations_blank] ||=
(is_any_blank && original_field_by_keys(keys).present?)
end
##
# Get default locale name or "Default".
#
# @return [String]
#
def default_locale_name
default_locale || "Default"
end
##
# Import locales for one field from collection.
#
# @param [Object] data collection with structure with keys "Section", "Field to translate", "lang1", "lang2"...
# @param [Hash] options additional and class-specific variables for import
# @raise [NotImplementedError]
#
def import_locales_for_field(data, options = {})
raise NotImplementedError
end
##
# Prepare additional and class-specific variables for import.
# To be overridden by specific class.
#
# @return [Hash]
#
def prepare_options_for_import
{}
end
##
# Method to save non-existent section and fields to instance variable while importing translations.
#
# @param [Object] data collection with structure with keys "Section", "Field to translate", "lang1", "lang2"...
# @param [Boolean] section_only is non-existent section? (do not save fields)
# @raise [NotImplementedError]
#
def save_nonexistent_field(data, section_only = false)
@import_info[:nonexistent_fields][data["Section"]].tap do |array|
array << data["Field to translate"] unless section_only
end
end
##
# Generate locales hash to export it later as CSV.
#
# @raise [NotImplementedError]
# @return [Hash] nested hash with format like: { "Section" => { "Field" => ["tr_for_fr", "tr_for_en"] } }
#
def locales_hash_for_export
raise NotImplementedError
end
##
# Export translations to CSV file.
# OPTIMIZE: separate to multiple methods
#
# @return [String] file path
#
def export_translations
class_name =
case self.class
when Msurvey then "survey"
when MongoCampaign then "campaign"
else self.class.to_s.underscore
end
object_title =
if %w(survey campaign).include? class_name
send(class_name.to_sym).title.tr(" ", "_")
else
id.to_s
end
file_name = "#{Time.current.strftime("%Y%m%d%H%M%S%N")}_#{object_title}_translations.csv"
directory = "public/tmp/#{class_name.pluralize}_translations/"
# create directory if does not exist
FileUtils.mkdir_p(directory) unless File.directory?(directory)
file = File.new("#{directory}#{file_name}", "w+")
# add BOM marker
File.open(file, "w") { |f| f.write "\uFEFF" }
# set survey locales for campaign if not set
if class_name == "campaign"
if default_locale.blank? && msurvey.default_locale
self.default_locale = msurvey.default_locale
end
if locales.blank? && (ms_locales = msurvey.locales.keys).any?
ms_locales.each { |l| add_locale(l) }
end
self.save
end
separator = (User.current.try(:lang) == "fr") ? ";" : ","
CSV.open(file, "a+", { col_sep: separator, force_quotes: true }) do |csv|
# header
csv << ["Section", "Field to translate", default_locale_name, *locales.keys]
locales_hash_for_export.each do |section, fields|
fields.each do |field, translations|
csv << [section, field, *translations]
end
end
end
file.path
end
##
# Import translations from CSV file.
# OPTIMIZE: separate to multiple methods
#
# @param [String] filename
# @param [String] original_filename
# @raise [IOError] when the file is not ok
# @return [Boolean, Array<String>] true if everything is ok or array of warnings
#
def import_translations(filename, original_filename)
raise IOError, "import_translations.not_csv" unless (File.extname(original_filename).downcase == ".csv")
raise IOError, "import_translations.not_utf8" unless (`file -b --mime-encoding #{filename}`.strip == "utf-8")
available_langs = locales.keys
separator = (User.current.try(:lang) == "fr") ? ";" : ","
csv = CSV.read(filename, "r:bom|utf-8", headers: true, col_sep: separator)
# csv is empty if we have no row after header, if headers or first line exist but filled with empty values
if csv.size < 1 || csv.headers.all?(&:nil?) || csv.first.fields.all?(&:nil?)
raise IOError, "import_translations.no_rows"
end
# set default locale as the name of third column header (which should be replaced of "Default")
self.default_locale = csv.headers.third
# read languages names that go after "Section", "Field to translate" and default language headers
langs_to_import = csv.headers.drop(3)
raise IOError, "import_translations.no_translation_column" if langs_to_import.blank?
raise IOError, "import_translations.translation_header_blank" if langs_to_import.any?(&:blank?)
# delete language if exists in locales but is not present in csv
(available_langs - langs_to_import).each { |lang| locales.delete(lang) }
@import_info = {
# sections and fields names that we were unable to recognize
nonexistent_fields: Hash.new { |hash, key| hash[key] = [] },
# if any translation is blank while master language is not
are_translations_blank: nil
}
# additional and class-specific variables
options = prepare_options_for_import
# import locales
csv.each { |row| import_locales_for_field(row, options) }
# collect warnings that should be shown to user after import
warnings = []
warnings << "import_translations.translation_blank" if @import_info[:are_translations_blank]
if @import_info[:nonexistent_fields].present?
warnings << "import_translations.inexistent_fields"
logger.info "non-existent fields on import of #{self.class} #{id.to_s}: #{@import_info[:nonexistent_fields]}"
end
warnings.present? ? warnings : true
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment