releaf-i18n_database 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +24 -0
  3. data/app/assets/javascripts/releaf/controllers/releaf/i18n_database/translations.js +22 -0
  4. data/app/assets/stylesheets/releaf/controllers/releaf/i18n_database/translations.scss +95 -0
  5. data/app/builders/releaf/i18n_database/translations/builders_common.rb +13 -0
  6. data/app/builders/releaf/i18n_database/translations/edit_builder.rb +41 -0
  7. data/app/builders/releaf/i18n_database/translations/index_builder.rb +44 -0
  8. data/app/builders/releaf/i18n_database/translations/table_builder.rb +31 -0
  9. data/app/controllers/releaf/i18n_database/translations_controller.rb +171 -0
  10. data/app/lib/releaf/i18n_database/translations_importer.rb +72 -0
  11. data/app/lib/releaf/i18n_database/translations_utilities.rb +66 -0
  12. data/app/models/releaf/i18n_database/translation.rb +17 -0
  13. data/app/models/releaf/i18n_database/translation_data.rb +11 -0
  14. data/app/views/releaf/i18n_database/translations/_form_fields.haml +40 -0
  15. data/app/views/releaf/i18n_database/translations/export.xlsx.axlsx +24 -0
  16. data/lib/releaf-i18n_database.rb +5 -0
  17. data/lib/releaf/i18n_database/backend.rb +169 -0
  18. data/lib/releaf/i18n_database/builders_autoload.rb +10 -0
  19. data/lib/releaf/i18n_database/engine.rb +36 -0
  20. data/lib/releaf/i18n_database/humanize_missing_translations.rb +15 -0
  21. data/releaf-i18n_database.gemspec +21 -0
  22. data/spec/builders/translations/builder_common_spec.rb +39 -0
  23. data/spec/builders/translations/edit_builder_spec.rb +93 -0
  24. data/spec/builders/translations/index_builder_spec.rb +96 -0
  25. data/spec/builders/translations/table_builder_spec.rb +68 -0
  26. data/spec/controllers/i18n_backend/translations_controller_spec.rb +157 -0
  27. data/spec/features/translations_spec.rb +162 -0
  28. data/spec/fixtures/all_translations_exported.xlsx +0 -0
  29. data/spec/fixtures/time.formats.xlsx +0 -0
  30. data/spec/fixtures/translations_import.xlsx +0 -0
  31. data/spec/fixtures/unsupported_import_file.png +0 -0
  32. data/spec/lib/i18n_database/backend_spec.rb +337 -0
  33. data/spec/lib/i18n_database/humanize_missing_translations_spec.rb +18 -0
  34. data/spec/lib/i18n_database/translations_importer_spec.rb +17 -0
  35. data/spec/lib/i18n_database/translations_utilities_spec.rb +175 -0
  36. data/spec/models/i18n_database/translation_data_spec.rb +13 -0
  37. data/spec/models/i18n_database/translation_spec.rb +49 -0
  38. metadata +151 -0
@@ -0,0 +1,72 @@
1
+ module Releaf::I18nDatabase
2
+ class TranslationsImporter
3
+ class UnsupportedFileFormatError < StandardError; end
4
+
5
+ def initialize file_path, file_extension
6
+ require "roo"
7
+ begin
8
+ @excel = Roo::Spreadsheet.open(file_path, file_warning: :ignore, extension: file_extension)
9
+ @data = []
10
+ @locales = []
11
+ rescue ArgumentError => e
12
+ error_string = "Don't know how to open file"
13
+ if e.message.match(error_string)
14
+ raise UnsupportedFileFormatError
15
+ else
16
+ raise
17
+ end
18
+ end
19
+ end
20
+
21
+ def parsed_output
22
+ @excel.each_with_pagename do |name, sheet|
23
+ detect_sheet_locales(sheet)
24
+ parse_sheet(sheet)
25
+ end
26
+
27
+ @data
28
+ end
29
+
30
+ def detect_sheet_locales sheet
31
+ sheet.row(1).each_with_index do |cell, i|
32
+ if i > 0
33
+ @locales << cell
34
+ end
35
+ end
36
+ end
37
+
38
+ def parse_sheet sheet
39
+ (2..sheet.last_row).each do |row_no|
40
+ key = sheet.row(row_no)[0]
41
+ localizations = sheet.row(row_no)[1..-1]
42
+ if key.present?
43
+ @data << load_translation(key, localizations)
44
+ end
45
+ end
46
+ end
47
+
48
+ def load_translation key, localizations
49
+ translation = Translation.where(key: key).first_or_initialize
50
+ translation.key = key
51
+
52
+ localizations.each_with_index do |localization, i|
53
+ load_translation_data(translation, @locales[i], localization)
54
+ end
55
+
56
+ translation
57
+ end
58
+
59
+ def load_translation_data translation, locale, localization
60
+ translation_data = translation.translation_data.find{ |x| x.lang == locale }
61
+ value = localization.nil? ? '' : localization
62
+
63
+ # replace existing locale value only if new one is not blank
64
+ if translation_data && !value.blank?
65
+ translation_data.localization = value
66
+ # always assign value for new locale
67
+ elsif translation_data.nil?
68
+ translation_data = translation.translation_data.build(lang: locale, localization: value)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,66 @@
1
+ module Releaf::I18nDatabase
2
+ class TranslationsUtilities
3
+
4
+ def self.search(collection, search_string, only_blank)
5
+ collection = filter_by_text(collection, search_string) if search_string.present?
6
+ collection = filter_only_blank_translations(collection) if only_blank == true
7
+ collection
8
+ end
9
+
10
+ def self.filter_only_blank_translations(collection)
11
+ blank_where_collection = Releaf::I18nDatabase::Translation
12
+ search_columns.each do |column|
13
+ blank_where_collection = blank_where_collection.where(column.eq('').or(column.eq(nil)))
14
+ end
15
+
16
+ collection.where(blank_where_collection.where_values.reduce(:or))
17
+ end
18
+
19
+ def self.filter_by_text(collection, lookup_string)
20
+ sql = column_searches(lookup_string).map{|column_search| "(#{column_search})" }.join(' OR ')
21
+ collection.where(sql)
22
+ end
23
+
24
+ def self.column_searches(lookup_string)
25
+ search_columns.map do |column|
26
+ lookup_string.split(' ').map do |part|
27
+ column.matches("%#{escape_search_string(part)}%")
28
+ end.inject(&:and).to_sql
29
+ end
30
+ end
31
+
32
+ def self.search_columns
33
+ [Releaf::I18nDatabase::Translation.arel_table[:key]] + locale_tables.map{|locale, table| table[:localization] }
34
+ end
35
+
36
+ def self.escape_search_string(string)
37
+ string.gsub(/([%|_])/){|r| "\\#{r}" }
38
+ end
39
+
40
+ def self.locale_tables
41
+ Releaf.application.config.all_locales.inject({}) do|h, locale|
42
+ h.update(locale => Releaf::I18nDatabase::TranslationData.arel_table.alias("#{locale}_data"))
43
+ end
44
+ end
45
+
46
+ def self.include_localizations(collection)
47
+ collection.select(localization_include_selects).joins(localization_include_joins)
48
+ end
49
+
50
+ def self.localization_include_joins
51
+ locale_tables.map do |locale, table|
52
+ "LEFT JOIN #{table.relation.name} AS #{table.name} ON #{locale}_data.translation_id = releaf_translations.id AND #{locale}_data.lang = '#{locale}'"
53
+ end
54
+ end
55
+
56
+ def self.localization_include_selects
57
+ (['releaf_translations.*'] + localization_include_locales_columns).join(', ')
58
+ end
59
+
60
+ def self.localization_include_locales_columns
61
+ locale_tables.map do |locale, table|
62
+ ["#{table.name}.localization AS #{locale}_localization", "#{table.name}.id AS #{locale}_localization_id"]
63
+ end.flatten
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,17 @@
1
+ module Releaf::I18nDatabase
2
+ class Translation < ActiveRecord::Base
3
+ self.table_name = "releaf_translations"
4
+
5
+ validates_presence_of :key
6
+ validates_uniqueness_of :key
7
+ validates_length_of :key, maximum: 255
8
+
9
+ has_many :translation_data, dependent: :destroy, class_name: 'Releaf::I18nDatabase::TranslationData', inverse_of: :translation
10
+ accepts_nested_attributes_for :translation_data, allow_destroy: true
11
+
12
+ def locale_value(locale)
13
+ # search against all values to cache
14
+ translation_data.find{ |x| x.lang == locale.to_s }.try(:localization)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Releaf::I18nDatabase
2
+ class TranslationData < ActiveRecord::Base
3
+ self.table_name = "releaf_translation_data"
4
+
5
+ validates_presence_of :translation, :lang
6
+ validates_uniqueness_of :translation_id, :scope => :lang
7
+ validates_length_of :lang, maximum: 5
8
+
9
+ belongs_to :translation, :inverse_of => :translation_data
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ - if @import
2
+ = hidden_field_tag :import, "true"
3
+ - attributes_scope = "activerecord.attributes.#{resource_class.name.underscore}"
4
+ - locales = Releaf.application.config.all_locales
5
+ - @translation_ids_to_destroy ||= []
6
+
7
+ - template_html = capture do
8
+ %tr.item{ data: { name: :translations }, style: 'display:none' }
9
+ %td.translation-name
10
+ .wrap
11
+ %input{type: "text", class: "text", name: "translations[][key]"}
12
+ - locales.each_with_index do |locale, j|
13
+ %td.translationCell{data: {locale: locale}}
14
+ %input{type: "text", class: "text", name: "translations[][localizations][#{locale}]"}
15
+ %td.delete-column.only-icon
16
+ = releaf_button(nil, "times", class: %w(danger remove-nested-item), title: t("Remove item", scope: controller_scope_name))
17
+ %section.nested{data: {name: "translations", releaf: {template: html_escape(template_html)}}}
18
+ %table.table
19
+ %thead
20
+ %tr
21
+ %th.code= t("key", scope: attributes_scope)
22
+ - locales.each do |locale|
23
+ %th{"data-locale" => locale}
24
+ %span.name= builder.translate_locale(locale)
25
+ %th.delete
26
+ %tbody#variables.list
27
+ - @collection.each_with_index do |translation, index|
28
+ - @translation_ids_to_destroy << translation.id
29
+ %tr.item{ id: "translation_#{translation.id}", data: { name: :translations, index: index } }
30
+ %td.translation-name{title: translation.key}
31
+ .wrap
32
+ %input{type: "text", class: "text", name: "translations[][key]", value: translation.key }
33
+ - locales.each_with_index do |locale, j|
34
+ %td.translationCell{data: {locale: locale}}
35
+ %input{type: "text", class: "text", name: "translations[][localizations][#{locale}]", value: translation.locale_value(locale) }
36
+ %td.delete-column.only-icon
37
+ = releaf_button(nil, "times", class: %w(danger remove-nested-item), title: t("Remove item", scope: controller_scope_name))
38
+ %div.tools
39
+ = releaf_button(t('Add item', :scope => 'admin.releaf_translations'), "plus", class: "add-nested-item")
40
+ = hidden_field_tag :existing_translations, @translation_ids_to_destroy.join(",")
@@ -0,0 +1,24 @@
1
+ wb = xlsx_package.workbook
2
+ wb.use_autowidth = true
3
+ header = wb.styles.add_style b: true
4
+ wb.add_worksheet(name: "localization") do |sheet|
5
+ locales = Releaf.application.config.all_locales
6
+
7
+ sheet.add_row [''] + locales, style: header
8
+
9
+ cell_styles = [nil] * (locales.size + 1)
10
+ cell_types = [:string] * (locales.size + 1)
11
+
12
+ if @collection.present?
13
+ @collection.each do |translation|
14
+ columns = [translation.key]
15
+ locales.each do|locale|
16
+ columns << translation.locale_value(locale)
17
+ end
18
+
19
+ sheet.add_row columns, style: cell_styles, types: cell_types
20
+ end
21
+ end
22
+ end
23
+
24
+ # vim: set ft=ruby:
@@ -0,0 +1,5 @@
1
+ require 'twitter_cldr'
2
+ require 'i18n'
3
+ require 'releaf/i18n_database/engine'
4
+ require 'releaf/i18n_database/humanize_missing_translations'
5
+ require 'releaf/i18n_database/backend'
@@ -0,0 +1,169 @@
1
+ require 'i18n/backend/base'
2
+ # TODO convert to arel
3
+ module Releaf
4
+ module I18nDatabase
5
+ class Backend
6
+ include ::I18n::Backend::Base, ::I18n::Backend::Flatten
7
+ CACHE = {updated_at: nil, translations: {}, missing: {}}
8
+ UPDATED_AT_KEY = 'releaf.i18n_database.translations.updated_at'
9
+
10
+ def reload_cache
11
+ CACHE[:translations] = translations || {}
12
+ CACHE[:missing] = {}
13
+ CACHE[:updated_at] = self.class.translations_updated_at
14
+ end
15
+
16
+ def reload_cache?
17
+ CACHE[:updated_at] != self.class.translations_updated_at
18
+ end
19
+
20
+ def self.translations_updated_at
21
+ Releaf::Settings[UPDATED_AT_KEY]
22
+ end
23
+
24
+ def self.translations_updated_at= value
25
+ Releaf::Settings[UPDATED_AT_KEY] = value
26
+ end
27
+
28
+ def store_translations locale, data, options = {}
29
+ new_hash = {}
30
+ new_hash[locale] = data
31
+
32
+ CACHE[:translations].deep_merge!(new_hash)
33
+ CACHE[:missing] = {}
34
+ end
35
+
36
+ protected
37
+
38
+ # Return all non-empty localizations
39
+ def localization_data
40
+ TranslationData.where("localization <> ''").
41
+ joins("LEFT JOIN releaf_translations ON releaf_translations.id = translation_id").
42
+ pluck("LOWER(CONCAT(lang, '.', releaf_translations.key)) AS translation_key", "localization").
43
+ to_h
44
+ end
45
+
46
+ # Return translation hash for each releaf locales
47
+ def translations
48
+ localization_cache = localization_data
49
+
50
+ Translation.order(:key).pluck("LOWER(releaf_translations.key)").map do |translation_key|
51
+ key_hash(translation_key, localization_cache)
52
+ end.inject(&:deep_merge)
53
+ end
54
+
55
+ def cache_lookup keys, locale, options, first_lookup
56
+ result = keys.inject(CACHE[:translations]) { |h, key| h.is_a?(Hash) && h.try(:[], key.downcase.to_sym) }
57
+
58
+ # when non-first match, non-pluralized and hash - return nil
59
+ if !first_lookup && result.is_a?(Hash) && !options.has_key?(:count)
60
+ result = nil
61
+ # return nil as we don't have valid pluralized translation
62
+ elsif result.is_a?(Hash) && options.has_key?(:count) && !valid_pluralized_result?(result, locale, options[:count])
63
+ result = nil
64
+ end
65
+
66
+ result
67
+ end
68
+
69
+ def valid_pluralized_result? result, locale, count
70
+ valid = false
71
+
72
+ if TwitterCldr.supported_locale?(locale)
73
+ rule = TwitterCldr::Formatters::Plurals::Rules.rule_for(count, locale)
74
+ valid = result.has_key? rule
75
+ end
76
+
77
+ valid
78
+ end
79
+
80
+ # Lookup translation from database
81
+ def lookup(locale, key, scope = [], options = {})
82
+ # reload cache if cache timestamp differs from last translations update
83
+ reload_cache if reload_cache?
84
+
85
+ key = normalize_flat_keys(locale, key, scope, options[:separator])
86
+ locale_key = "#{locale}.#{key}"
87
+
88
+ # do not process further if key already marked as missing
89
+ return nil if CACHE[:missing].has_key? locale_key
90
+
91
+ chain = locale_key.split('.')
92
+ chain_initial_length = chain.length
93
+ inherit_scopes = options.fetch(:inherit_scopes, true)
94
+
95
+ while (chain.length > 1) do
96
+ result = cache_lookup(chain, locale, options, chain_initial_length == chain.length)
97
+ return result if result.present?
98
+ break if inherit_scopes == false
99
+
100
+ # remove second last value
101
+ chain.delete_at(chain.length - 2)
102
+ end
103
+
104
+ # mark translation as missing
105
+ CACHE[:missing][locale_key] = true
106
+ create_missing_translation(locale, key, options) if create_missing_translation?(options)
107
+
108
+ return nil
109
+ end
110
+
111
+ def default(locale, object, subject, options = {})
112
+ if options[:create_default] == false
113
+ options = options.reject { |key, value| key == :create_default }
114
+ options[:create_missing] = false
115
+ end
116
+ super
117
+ end
118
+
119
+ def get_all_pluralizations
120
+ keys = []
121
+
122
+ ::Releaf.application.config.all_locales.each do|locale|
123
+ if TwitterCldr.supported_locale? locale
124
+ keys += TwitterCldr::Formatters::Plurals::Rules.all_for(locale)
125
+ end
126
+ end
127
+
128
+ keys.uniq
129
+ end
130
+
131
+ def create_missing_translation?(options)
132
+ Releaf::I18nDatabase.create_missing_translations == true && options[:create_missing] != false
133
+ end
134
+
135
+ def create_missing_translation(locale, key, options)
136
+ begin
137
+ if options.has_key?(:count) && options[:create_plurals] == true
138
+ get_all_pluralizations.each do|pluralization|
139
+ Translation.create(key: "#{key}.#{pluralization}")
140
+ end
141
+ else
142
+ Translation.create(key: key)
143
+ end
144
+ rescue ActiveRecord::RecordNotUnique
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def key_hash key, localization_cache
151
+ hash = {}
152
+
153
+ ::Releaf.application.config.all_locales.each do |locale|
154
+ localized_key = "#{locale}.#{key}"
155
+ locale_hash = locale_hash(localized_key, localization_cache[localized_key])
156
+ hash.merge! locale_hash
157
+ end
158
+
159
+ hash
160
+ end
161
+
162
+ def locale_hash localized_key, localization
163
+ localized_key.to_s.split(".").reverse.inject(localization) do |value, key|
164
+ {key.to_sym => value}
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,10 @@
1
+ root_path = File.expand_path('../..', File.dirname(__dir__))
2
+ files = %w(
3
+ builders_common
4
+ edit_builder
5
+ table_builder
6
+ index_builder
7
+ )
8
+ files.each do|file|
9
+ require "#{root_path}/app/builders/releaf/i18n_database/translations/#{file}"
10
+ end
@@ -0,0 +1,36 @@
1
+ require 'axlsx_rails'
2
+
3
+ module Releaf::I18nDatabase
4
+ require 'releaf/i18n_database/builders_autoload'
5
+ mattr_accessor :create_missing_translations
6
+ @@create_missing_translations = true
7
+
8
+ class Engine < ::Rails::Engine
9
+ initializer 'precompile', group: :all do |app|
10
+ app.config.assets.precompile += %w(releaf/controllers/releaf/i18n_database/*)
11
+ end
12
+ end
13
+
14
+ def self.components
15
+ [Releaf::I18nDatabase::HumanizeMissingTranslations]
16
+ end
17
+
18
+ def self.initialize_component
19
+ I18n.backend = Releaf::I18nDatabase::Backend.new
20
+ end
21
+
22
+ def self.draw_component_routes router
23
+ router.namespace :releaf, path: nil do
24
+ router.namespace :i18n_database, path: nil do
25
+ router.resources :translations, only: [:index] do
26
+ router.collection do
27
+ router.get :edit
28
+ router.post :update
29
+ router.get :export
30
+ router.post :import
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end