releaf-i18n_database 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +24 -0
- data/app/assets/javascripts/releaf/controllers/releaf/i18n_database/translations.js +22 -0
- data/app/assets/stylesheets/releaf/controllers/releaf/i18n_database/translations.scss +95 -0
- data/app/builders/releaf/i18n_database/translations/builders_common.rb +13 -0
- data/app/builders/releaf/i18n_database/translations/edit_builder.rb +41 -0
- data/app/builders/releaf/i18n_database/translations/index_builder.rb +44 -0
- data/app/builders/releaf/i18n_database/translations/table_builder.rb +31 -0
- data/app/controllers/releaf/i18n_database/translations_controller.rb +171 -0
- data/app/lib/releaf/i18n_database/translations_importer.rb +72 -0
- data/app/lib/releaf/i18n_database/translations_utilities.rb +66 -0
- data/app/models/releaf/i18n_database/translation.rb +17 -0
- data/app/models/releaf/i18n_database/translation_data.rb +11 -0
- data/app/views/releaf/i18n_database/translations/_form_fields.haml +40 -0
- data/app/views/releaf/i18n_database/translations/export.xlsx.axlsx +24 -0
- data/lib/releaf-i18n_database.rb +5 -0
- data/lib/releaf/i18n_database/backend.rb +169 -0
- data/lib/releaf/i18n_database/builders_autoload.rb +10 -0
- data/lib/releaf/i18n_database/engine.rb +36 -0
- data/lib/releaf/i18n_database/humanize_missing_translations.rb +15 -0
- data/releaf-i18n_database.gemspec +21 -0
- data/spec/builders/translations/builder_common_spec.rb +39 -0
- data/spec/builders/translations/edit_builder_spec.rb +93 -0
- data/spec/builders/translations/index_builder_spec.rb +96 -0
- data/spec/builders/translations/table_builder_spec.rb +68 -0
- data/spec/controllers/i18n_backend/translations_controller_spec.rb +157 -0
- data/spec/features/translations_spec.rb +162 -0
- data/spec/fixtures/all_translations_exported.xlsx +0 -0
- data/spec/fixtures/time.formats.xlsx +0 -0
- data/spec/fixtures/translations_import.xlsx +0 -0
- data/spec/fixtures/unsupported_import_file.png +0 -0
- data/spec/lib/i18n_database/backend_spec.rb +337 -0
- data/spec/lib/i18n_database/humanize_missing_translations_spec.rb +18 -0
- data/spec/lib/i18n_database/translations_importer_spec.rb +17 -0
- data/spec/lib/i18n_database/translations_utilities_spec.rb +175 -0
- data/spec/models/i18n_database/translation_data_spec.rb +13 -0
- data/spec/models/i18n_database/translation_spec.rb +49 -0
- 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,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,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
|