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.
- 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
|