Last active
February 18, 2019 10:12
-
-
Save vsvld/e1db73baa8637ed00aace5e5ccd219b4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## | |
# 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