releaf-i18n_database 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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