i18n_admin 0.3.1

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