i18n_admin 0.3.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +78 -0
  4. data/Rakefile +23 -0
  5. data/app/assets/images/i18n_admin/spinner.gif +0 -0
  6. data/app/assets/javascripts/i18n_admin/application.js +21 -0
  7. data/app/assets/javascripts/i18n_admin/src/i18n-admin-job-monitor.coffee +37 -0
  8. data/app/assets/javascripts/i18n_admin/src/import.coffee +23 -0
  9. data/app/assets/javascripts/i18n_admin/src/translation.coffee +45 -0
  10. data/app/assets/stylesheets/i18n_admin/application.css.scss +88 -0
  11. data/app/controllers/i18n_admin/application_controller.rb +21 -0
  12. data/app/controllers/i18n_admin/exports_controller.rb +19 -0
  13. data/app/controllers/i18n_admin/imports_controller.rb +28 -0
  14. data/app/controllers/i18n_admin/translations_controller.rb +30 -0
  15. data/app/helpers/i18n_admin/application_helper.rb +40 -0
  16. data/app/models/i18n_admin/export_file.rb +4 -0
  17. data/app/models/i18n_admin/import_file.rb +4 -0
  18. data/app/models/i18n_admin/import_job.rb +4 -0
  19. data/app/models/i18n_admin/resource_file.rb +8 -0
  20. data/app/models/i18n_admin/translations_set.rb +5 -0
  21. data/app/models/i18n_admin/whitelisted_resource.rb +9 -0
  22. data/app/views/i18n_admin/exports/queued.html.haml +18 -0
  23. data/app/views/i18n_admin/imports/errors/_resource_invalid.html.erb +0 -0
  24. data/app/views/i18n_admin/imports/errors/_resource_not_found.html.erb +0 -0
  25. data/app/views/i18n_admin/imports/new.html.erb +61 -0
  26. data/app/views/i18n_admin/imports/processing.html.erb +33 -0
  27. data/app/views/i18n_admin/imports/queued.html.haml +0 -0
  28. data/app/views/i18n_admin/translations/_pagination.html.erb +3 -0
  29. data/app/views/i18n_admin/translations/_translation.html.erb +9 -0
  30. data/app/views/i18n_admin/translations/index.html.erb +81 -0
  31. data/app/views/layouts/i18n_admin/application.html.haml +11 -0
  32. data/config/locales/i18n_admin.en.yml +38 -0
  33. data/config/locales/i18n_admin.fr.yml +52 -0
  34. data/config/routes.rb +6 -0
  35. data/db/migrate/20140829094415_create_i18n_admin_translations_sets.rb +12 -0
  36. data/db/migrate/20150825133437_create_i18n_admin_import_jobs.rb +11 -0
  37. data/db/migrate/20150828074645_create_i18n_admin_whitelisted_resources.rb +12 -0
  38. data/db/migrate/20160404161658_create_i18n_admin_resource_files.rb +11 -0
  39. data/lib/ext/paperclip.rb +5 -0
  40. data/lib/generators/i18n_admin/install/install_generator.rb +31 -0
  41. data/lib/generators/i18n_admin/install/templates/initializer.rb +28 -0
  42. data/lib/i18n_admin.rb +51 -0
  43. data/lib/i18n_admin/engine.rb +28 -0
  44. data/lib/i18n_admin/errors.rb +10 -0
  45. data/lib/i18n_admin/errors/base.rb +23 -0
  46. data/lib/i18n_admin/errors/collection.rb +24 -0
  47. data/lib/i18n_admin/errors/resource_invalid.rb +7 -0
  48. data/lib/i18n_admin/errors/resource_not_found.rb +7 -0
  49. data/lib/i18n_admin/export.rb +14 -0
  50. data/lib/i18n_admin/export/base.rb +129 -0
  51. data/lib/i18n_admin/export/xls.rb +110 -0
  52. data/lib/i18n_admin/hstore_backend.rb +68 -0
  53. data/lib/i18n_admin/import.rb +16 -0
  54. data/lib/i18n_admin/import/base.rb +64 -0
  55. data/lib/i18n_admin/import/job.rb +17 -0
  56. data/lib/i18n_admin/import/xls.rb +58 -0
  57. data/lib/i18n_admin/request_store.rb +11 -0
  58. data/lib/i18n_admin/translation.rb +27 -0
  59. data/lib/i18n_admin/translation_collection.rb +34 -0
  60. data/lib/i18n_admin/translations.rb +96 -0
  61. data/lib/i18n_admin/version.rb +3 -0
  62. data/lib/tasks/i18n_admin_tasks.rake +35 -0
  63. metadata +307 -0
@@ -0,0 +1,68 @@
1
+ require 'i18n/backend/key_value'
2
+
3
+ module I18nAdmin
4
+ class HstoreBackend < I18n::Backend::KeyValue
5
+ def available_locales
6
+ @available_locales ||= I18nAdmin::TranslationsSet.pluck(:locale)
7
+ end
8
+
9
+ class Store
10
+ include I18nAdmin::RequestStore
11
+
12
+ def [](path)
13
+ locale, key = locale_and_key_from(path)
14
+ cached_translations_for(locale).translations[key]
15
+ end
16
+
17
+ def store_translations(locale, key, value)
18
+ translations_set = translations_set_for(locale)
19
+ translations_set.translations[key] = value
20
+ translations_set.translations_will_change!
21
+ translations_set.save
22
+ value
23
+ end
24
+
25
+ def translations_for(locale)
26
+ translations_set_for(locale).translations
27
+ end
28
+
29
+ private
30
+
31
+ def locale_and_key_from(path)
32
+ path.split('.', 2)
33
+ end
34
+
35
+ def translations_set_for(locale)
36
+ model.where(locale: locale).first_or_initialize do |set|
37
+ set.translations ||= {}
38
+ end
39
+ end
40
+
41
+ def cached_translations_for(locale)
42
+ store_key = store_key_for(locale, :set)
43
+ request_store.store[store_key] ||= translations_set_for(locale)
44
+ end
45
+
46
+ def model
47
+ @model ||= I18nAdmin::TranslationsSet
48
+ end
49
+ end
50
+
51
+ def initialize
52
+ @store = HstoreBackend::Store.new
53
+ end
54
+
55
+ def store_translations(locale, data, options = {})
56
+ data.each do |key, value|
57
+ store.store_translations(locale, key, value)
58
+ end
59
+ end
60
+
61
+ protected
62
+
63
+ def lookup(locale, key, scope = [], options = {})
64
+ key = normalize_flat_keys(locale, key, scope, options[:separator])
65
+ store["#{locale}.#{key}"]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ module I18nAdmin
2
+ module Import
3
+ def self.types
4
+ @types ||= {}
5
+ end
6
+
7
+ def self.for(format)
8
+ types[format]
9
+ end
10
+ end
11
+ end
12
+
13
+ require 'i18n_admin/import/base'
14
+ require 'i18n_admin/import/xls'
15
+
16
+ require 'i18n_admin/import/job'
@@ -0,0 +1,64 @@
1
+ module I18nAdmin
2
+ module Import
3
+ class Base
4
+ attr_reader :locale
5
+
6
+ def self.register(type, import)
7
+ Import.types[type] = import
8
+ end
9
+
10
+ def errors
11
+ @errors ||= I18nAdmin::Errors::Collection.new
12
+ end
13
+
14
+ private
15
+
16
+ def save_updated_models
17
+ # Save all updated model translations
18
+ ActiveRecord::Base.transaction do
19
+ updated_models.each do |key, resource|
20
+ unless resource.save
21
+ errors.add(:resource_invalid, key: key, resource: resource)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def update_translation(key, value)
28
+ case key
29
+ when /^models\-/ then update_model_translation(key, value)
30
+ else update_static_translation(key, value)
31
+ end
32
+ end
33
+
34
+ def update_model_translation(key, value)
35
+ _, model_name, id, field = key.split('-')
36
+ update_cache_key = [model_name, id].join('-')
37
+
38
+ # Find resource from update cache, or in database
39
+ unless (resource = updated_models[update_cache_key])
40
+ model = model_name.constantize
41
+ resource = model.where(id: id).first
42
+ end
43
+
44
+ # Only update found resources
45
+ if resource
46
+ resource.send(:"#{ field }=", value)
47
+ updated_models[update_cache_key] ||= resource
48
+ else
49
+ errors.add(:resource_not_found, {
50
+ key: update_cache_key, model_name: model_name, id: id
51
+ })
52
+ end
53
+ end
54
+
55
+ def update_static_translation(key, value)
56
+ I18n.backend.store_translations(locale, key => value)
57
+ end
58
+
59
+ def updated_models
60
+ @updated_models ||= {}
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,17 @@
1
+ module I18nAdmin
2
+ module Import
3
+ class Job
4
+ include SuckerPunch::Job
5
+
6
+ def perform(locale, file_path, job_id)
7
+ ActiveRecord::Base.connection_pool.with_connection do
8
+ import = Import::XLS.new(locale, file_path)
9
+ job = I18nAdmin::ImportJob.find(job_id)
10
+
11
+ state = import.run ? 'success' : 'error'
12
+ job.update(state: state)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ module I18nAdmin
2
+ module Import
3
+ class XLS < Import::Base
4
+ # Used to split cells with large content
5
+ PAGINATION_PATTERN = /\(\d+ \/ (\d+)\)$/
6
+
7
+ register :xls, self
8
+
9
+ attr_reader :file, :spreadsheet, :sheet
10
+
11
+ def self.import(locale, file)
12
+ import = new(locale, file)
13
+ import.run
14
+ ensure
15
+ file.close
16
+ end
17
+
18
+ def initialize(locale, file)
19
+ @locale = locale
20
+ @spreadsheet = Spreadsheet.open(file.path)
21
+ @sheet = spreadsheet.worksheet(0)
22
+ end
23
+
24
+ def run
25
+ I18n.with_locale(locale) do
26
+ index = 0
27
+
28
+ while (index += 1) < sheet.row_count
29
+ key, value, index = extract_translation_at(index)
30
+ update_translation(key, value)
31
+ end
32
+
33
+ save_updated_models
34
+ end
35
+
36
+ errors.empty?
37
+ end
38
+
39
+ private
40
+
41
+ def extract_translation_at(index)
42
+ key, _, value = sheet.row(index)
43
+
44
+ if (pagination = key.match(PAGINATION_PATTERN))
45
+ pages = pagination[1].to_i
46
+ key = key.gsub(PAGINATION_PATTERN, '').strip
47
+
48
+ (pages - 1).times do |page|
49
+ index += 1
50
+ value += sheet.row(index).pop.to_s
51
+ end
52
+ end
53
+
54
+ [key, value, index]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,11 @@
1
+ module I18nAdmin
2
+ module RequestStore
3
+ def request_store
4
+ ::RequestStore
5
+ end
6
+
7
+ def store_key_for(*args)
8
+ [:i18n_admin, *args].join(':')
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ require 'base64'
2
+
3
+ module I18nAdmin
4
+ class Translation
5
+ include ActiveModel::Model
6
+
7
+ attr_accessor :key, :original, :value, :locale
8
+
9
+ def persisted?
10
+ true
11
+ end
12
+
13
+ def to_param
14
+ Base64.strict_encode64(key)
15
+ end
16
+
17
+ def matches?(search)
18
+ [:key, :original, :value].any? do |key|
19
+ send(key).to_s.match(search)
20
+ end
21
+ end
22
+
23
+ def self.key_from(encoded_key)
24
+ Base64.decode64(encoded_key)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ module I18nAdmin
2
+ class TranslationCollection
3
+ include Enumerable
4
+
5
+ attr_writer :translations
6
+
7
+ delegate :<<, :each, :[], :[]=, :length, to: :translations
8
+
9
+ def translations
10
+ @translations ||= []
11
+ end
12
+
13
+ def page(page_index)
14
+ Kaminari.paginate_array(self).page(page_index)
15
+ end
16
+
17
+ def search(query)
18
+ regex = /#{ query.split(' ').join('|') }/i
19
+
20
+ # Duplicate and filter translations
21
+ updated = dup
22
+ updated.translations.select! { |translation| translation.matches?(regex) }
23
+ updated
24
+ end
25
+
26
+ def find(encoded_key)
27
+ key = I18nAdmin::Translation.key_from(encoded_key)
28
+
29
+ translations.find do |translation|
30
+ translation.key == key
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,96 @@
1
+ module I18nAdmin
2
+ class Translations
3
+ include I18nAdmin::RequestStore
4
+
5
+ def self.translations_for(locale)
6
+ translations = TranslationCollection.new
7
+
8
+ for_locale(locale).each do |key, value|
9
+ translations << Translation.new(
10
+ key: key,
11
+ original: for_locale(I18n.default_locale)[key],
12
+ value: value,
13
+ locale: locale
14
+ )
15
+ end
16
+
17
+ translations
18
+ end
19
+
20
+ def self.for_locale(locale)
21
+ new.for_locale(locale.to_sym)
22
+ end
23
+
24
+ # Save a translation object
25
+ def self.update(translation)
26
+ I18n.backend.store_translations(
27
+ translation.locale,
28
+ translation.key => translation.value
29
+ )
30
+
31
+ if translation.locale == I18n.default_locale
32
+ translation.original = translation.value
33
+ end
34
+ end
35
+
36
+ def for_locale(locale = I18n.locale)
37
+ translations = with_empty_keys_for(locale, all_translations_for(locale))
38
+
39
+ translations.keys.sort.each_with_object({}) do |key, hash|
40
+ hash[key] = translations[key]
41
+ end
42
+ end
43
+
44
+ def all_translations_for(locale)
45
+ request_store.store[store_key_for(locale, :hash)] ||= backends.map do |backend|
46
+ translations_for(locale, backend)
47
+ end.reduce(&:reverse_merge)
48
+ end
49
+
50
+ def backends
51
+ @backends ||= I18n.backend.backends
52
+ end
53
+
54
+ def translations_for(locale, backend)
55
+ translations = if backend.protected_methods.include?(:translations)
56
+ parse_from_deep_hash(locale, backend.send(:translations))
57
+ elsif backend.respond_to?(:store)
58
+ backend.store.translations_for(locale)
59
+ end
60
+ end
61
+
62
+ def parse_from_deep_hash(locale, translations)
63
+ flatten_translation_hash(translations[locale] || {})
64
+ end
65
+
66
+ def flatten_translation_hash(hash, scope = nil, translations = {})
67
+ hash.each_with_object(translations) do |(key, value), buffer|
68
+ scoped_key = [scope, key.to_s].compact.join('.')
69
+
70
+ if value.is_a?(Hash)
71
+ flatten_translation_hash(value, scoped_key, buffer)
72
+ elsif elegible_key?(scoped_key)
73
+ buffer[scoped_key] = value
74
+ end
75
+ end
76
+ end
77
+
78
+ def elegible_key?(key)
79
+ if (pattern = I18nAdmin.excluded_keys_pattern)
80
+ !key.match(pattern)
81
+ else
82
+ true
83
+ end
84
+ end
85
+
86
+ def with_empty_keys_for(locale, hash)
87
+ return hash if I18n.default_locale == locale
88
+
89
+ all_translations_for(I18n.default_locale).keys.each do |key|
90
+ hash[key] = "" unless hash.key?(key)
91
+ end
92
+
93
+ hash
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,3 @@
1
+ module I18nAdmin
2
+ VERSION = "0.3.1"
3
+ end
@@ -0,0 +1,35 @@
1
+ # desc "Explaining what the task does"
2
+ # task :i18n_admin do
3
+ # # Task goes here
4
+ # end
5
+
6
+ namespace :i18n do
7
+ namespace :translations do
8
+ task export: :environment do
9
+ format = (ENV['format'] || 'xls').to_sym
10
+ locale = (ENV['locale'] || I18n.default_locale).to_sym
11
+
12
+ data = I18nAdmin::Export.for(format).export(locale)
13
+
14
+ file_name = ['translations', locale, format].join('.')
15
+ file_path = Rails.root.join('tmp', file_name)
16
+
17
+ File.open(file_path, 'wb') do |file|
18
+ file.write(data)
19
+ end
20
+
21
+ puts "Exported translations to : #{ file_path }"
22
+ end
23
+
24
+ task import: :environment do
25
+ format = (ENV['format'] || 'xls').to_sym
26
+ locale = (ENV['locale'] || I18n.default_locale).to_sym
27
+ file_path = ENV['file']
28
+ file = File.open(file_path, 'rb')
29
+
30
+ data = I18nAdmin::Import.for(format).import(locale, file)
31
+
32
+ puts "Imported translations from : #{ file_path }"
33
+ end
34
+ end
35
+ end