rails_i18n_manager 1.0.0

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +7 -0
  3. data/README.md +177 -0
  4. data/Rakefile +18 -0
  5. data/app/controllers/rails_i18n_manager/application_controller.rb +5 -0
  6. data/app/controllers/rails_i18n_manager/translation_apps_controller.rb +71 -0
  7. data/app/controllers/rails_i18n_manager/translations_controller.rb +248 -0
  8. data/app/helpers/rails_i18n_manager/application_helper.rb +74 -0
  9. data/app/helpers/rails_i18n_manager/custom_form_builder.rb +223 -0
  10. data/app/jobs/rails_i18n_manager/application_job.rb +5 -0
  11. data/app/jobs/rails_i18n_manager/translations_import_job.rb +70 -0
  12. data/app/lib/rails_i18n_manager/forms/base.rb +25 -0
  13. data/app/lib/rails_i18n_manager/forms/translation_file_form.rb +55 -0
  14. data/app/lib/rails_i18n_manager/google_translate.rb +72 -0
  15. data/app/models/rails_i18n_manager/application_record.rb +20 -0
  16. data/app/models/rails_i18n_manager/translation_app.rb +108 -0
  17. data/app/models/rails_i18n_manager/translation_key.rb +161 -0
  18. data/app/models/rails_i18n_manager/translation_value.rb +11 -0
  19. data/app/views/layouts/rails_i18n_manager/application.html.slim +55 -0
  20. data/app/views/layouts/rails_i18n_manager/application.js.erb +5 -0
  21. data/app/views/rails_i18n_manager/form_builder/_basic_field.html.erb +108 -0
  22. data/app/views/rails_i18n_manager/form_builder/_error_notification.html.erb +7 -0
  23. data/app/views/rails_i18n_manager/shared/_flash.html.slim +6 -0
  24. data/app/views/rails_i18n_manager/translation_apps/_breadcrumbs.html.slim +12 -0
  25. data/app/views/rails_i18n_manager/translation_apps/_filter_bar.html.slim +9 -0
  26. data/app/views/rails_i18n_manager/translation_apps/form.html.slim +30 -0
  27. data/app/views/rails_i18n_manager/translation_apps/index.html.slim +27 -0
  28. data/app/views/rails_i18n_manager/translations/_breadcrumbs.html.slim +17 -0
  29. data/app/views/rails_i18n_manager/translations/_filter_bar.html.slim +17 -0
  30. data/app/views/rails_i18n_manager/translations/_form.html.slim +29 -0
  31. data/app/views/rails_i18n_manager/translations/_sub_nav.html.slim +7 -0
  32. data/app/views/rails_i18n_manager/translations/_translation_value_fields.html.slim +10 -0
  33. data/app/views/rails_i18n_manager/translations/edit.html.slim +6 -0
  34. data/app/views/rails_i18n_manager/translations/import.html.slim +26 -0
  35. data/app/views/rails_i18n_manager/translations/index.html.slim +50 -0
  36. data/config/locales/en.yml +5 -0
  37. data/config/routes.rb +22 -0
  38. data/db/migrate/20221001001344_add_rails_i18n_manager_tables.rb +26 -0
  39. data/lib/rails_i18n_manager/config.rb +20 -0
  40. data/lib/rails_i18n_manager/engine.rb +42 -0
  41. data/lib/rails_i18n_manager/version.rb +3 -0
  42. data/lib/rails_i18n_manager.rb +60 -0
  43. data/public/rails_i18n_manager/application.css +67 -0
  44. data/public/rails_i18n_manager/application.js +37 -0
  45. data/public/rails_i18n_manager/favicon.ico +0 -0
  46. data/public/rails_i18n_manager/utility.css +99 -0
  47. metadata +292 -0
@@ -0,0 +1,223 @@
1
+ module RailsI18nManager
2
+ class CustomFormBuilder < ActionView::Helpers::FormBuilder
3
+
4
+ ALLOWED_OPTIONS = [
5
+ :value,
6
+ :name,
7
+ :label,
8
+ :field_wrapper_html,
9
+ :label_wrapper_html,
10
+ :label_html,
11
+ :input_wrapper_html,
12
+ :input_html,
13
+ :required,
14
+ :required_text,
15
+ :help_text,
16
+ :errors,
17
+ :field_layout,
18
+ :view_mode,
19
+
20
+ ### SELECT OPTIONS
21
+ :collection,
22
+ :selected,
23
+ :disabled,
24
+ :prompt,
25
+ :include_blank,
26
+ ].freeze
27
+
28
+ def error_notification
29
+ @template.render "rails_i18n_manager/form_builder/error_notification", {f: self}
30
+ end
31
+
32
+ def view_field(label:, value:, **options)
33
+ field(nil, type: :view, **options.merge(label: label, value: value, view_mode: true))
34
+ end
35
+
36
+ def field(method, type:, **options)
37
+ type = type.to_sym
38
+
39
+ options = _transform_options(options, method)
40
+
41
+ if options[:view_mode]
42
+ return _view_field(method, type, options)
43
+ end
44
+
45
+ invalid_options = options.keys - ALLOWED_OPTIONS
46
+ if invalid_options.any?
47
+
48
+ raise "Invalid options provided: #{invalid_options.join(", ")}"
49
+ end
50
+
51
+ if [:select, :textarea].exclude?(type)
52
+ options[:input_html][:type] = type.to_s
53
+ end
54
+
55
+ case type
56
+ when :select
57
+ options[:collection] = _fetch_required_option(:collection, options)
58
+
59
+ options = _transform_select_options(options, method)
60
+ when :checkbox
61
+ options = _transform_checkbox_options(options, method)
62
+ else
63
+ if !options[:input_html].has_key?(:value)
64
+ options[:input_html][:value] = object.send(method)
65
+ end
66
+ end
67
+
68
+ @template.render("rails_i18n_manager/form_builder/basic_field", {
69
+ f: self,
70
+ method: method,
71
+ type: type,
72
+ options: options,
73
+ })
74
+ end
75
+
76
+ private
77
+
78
+ def _view_field(method, type, options)
79
+ options[:input_html][:class] ||= ""
80
+ options[:input_html][:class].concat(" form-control-plaintext").strip!
81
+ options[:input_html][:readonly] = true
82
+ options[:input_html][:type] = "text"
83
+ options[:input_html].delete(:name)
84
+
85
+ if !options[:input_html].has_key?(:value)
86
+ options[:input_html][:value] = _determine_display_value(method, type, options)
87
+ end
88
+
89
+ @template.render("rails_i18n_manager/form_builder/basic_field", {
90
+ f: self,
91
+ method: method,
92
+ type: :view,
93
+ options: options,
94
+ })
95
+ end
96
+
97
+ def _defaults
98
+ @_defaults ||= (@template.instance_variable_get(:@_custom_form_for_defaults) || {}).deep_symbolize_keys!
99
+ end
100
+
101
+ def _attr_presence_required?(attr)
102
+ if attr && object.respond_to?(attr)
103
+ (@object.try(:klass) || @object.class).validators_on(attr).any?{|x| x.kind.to_sym == :presence }
104
+ end
105
+ end
106
+
107
+ def _fetch_required_option(key, options)
108
+ if !options.has_key?(key)
109
+ raise ArgumentError.new("Missing required option :#{key}")
110
+ end
111
+ options[key]
112
+ end
113
+
114
+ def _determine_display_value(method, type, options)
115
+ case type
116
+ when :checkbox
117
+ options[:input_html]&.has_key?(:checked) ? options[:input_html][:checked] : @object.send(method)
118
+ when :select
119
+ if options.has_key?(:selected)
120
+ val = options[:selected]
121
+ else
122
+ if options[:input_html].has_key?(:value)
123
+ val = options[:input_html].delete(:value)
124
+ else
125
+ val = object.send(method)
126
+ end
127
+ end
128
+
129
+ selected_opt = options[:collection].detect { |opt|
130
+ val == (opt.is_a?(Array) ? opt[1] : opt)
131
+ }
132
+
133
+ selected_opt.is_a?(Array) ? selected_opt[0] : selected_opt
134
+ else
135
+ options[:input_html]&.has_key?(:value) ? options[:value] : @object.send(method)
136
+ end
137
+ end
138
+
139
+ def _transform_options(options, method)
140
+ options.deep_symbolize_keys!
141
+
142
+ options = _defaults.merge(options)
143
+
144
+ options[:label] = options.has_key?(:label) ? options[:label] : method.to_s.titleize
145
+
146
+ options[:field_wrapper_html] ||= {}
147
+ options[:label_wrapper_html] ||= {}
148
+ options[:label_html] ||= {}
149
+ options[:input_wrapper_html] ||= {}
150
+ options[:input_html] ||= {}
151
+
152
+ ### Shortcuts for some input_html arguments
153
+ [:value, :name].each do |key|
154
+ if options.has_key?(key) && !options[:input_html].has_key?(key)
155
+ options[:input_html][key] = options[key]
156
+ end
157
+ end
158
+
159
+ options[:field_wrapper_html][:class] ||= ""
160
+ options[:field_wrapper_html][:class].concat(" form-group").strip!
161
+
162
+ if !options.has_key?(:field_layout)
163
+ options[:field_layout] = :vertical
164
+ end
165
+ options[:field_layout] = options[:field_layout].to_sym
166
+
167
+ options[:required] = options.has_key?(:required) ? options[:required] : _attr_presence_required?(method)
168
+
169
+ options[:required_text] ||= "*"
170
+
171
+ options[:field_wrapper_html][:class].concat(" #{method}_field").strip!
172
+
173
+ if method && !options.has_key?(:errors)
174
+ options[:errors] = @object.errors[method]
175
+ end
176
+
177
+ if options[:errors].present?
178
+ options[:input_html][:class] ||= ""
179
+ options[:input_html][:class].concat(" is-invalid")
180
+ end
181
+
182
+ options
183
+ end
184
+
185
+ def _transform_select_options(options, method)
186
+ if !options.has_key?(:selected)
187
+ if options[:input_html].has_key?(:value)
188
+ options[:selected] = options[:input_html].delete(:value)
189
+ else
190
+ options[:selected] = object.send(method)
191
+ end
192
+ end
193
+
194
+ if options[:disabled].is_a?(TrueClass) && !options[:input_html].has_key?(:disabled)
195
+ options.delete(:disabled)
196
+ options[:input_html][:disabled] = true
197
+ end
198
+
199
+ options
200
+ end
201
+
202
+ def _transform_checkbox_options(options, method)
203
+ if options[:input_html].has_key?(:value) && !options[:input_html].has_key?(:checked)
204
+ options[:input_html][:checked] = (object.send(method) == options[:input_html][:value])
205
+ elsif @object.class.respond_to?(:columns_hash) && @object.class.columns_hash[method]&.type == :boolean
206
+ if !options[:input_html].has_key?(:checked)
207
+ if options[:input_html].has_key?(:value)
208
+ options[:input_html][:checked] = (options[:input_html][:value] == true)
209
+ else
210
+ options[:input_html][:checked] = (object.send(method) == true)
211
+ end
212
+ end
213
+
214
+ if !options[:input_html].has_key?(:value)
215
+ options[:input_html][:value] = "1"
216
+ end
217
+ end
218
+
219
+ options
220
+ end
221
+
222
+ end
223
+ end
@@ -0,0 +1,5 @@
1
+ module RailsI18nManager
2
+ class ApplicationJob < ActiveJob::Base
3
+ self.queue_adapter = :async
4
+ end
5
+ end
@@ -0,0 +1,70 @@
1
+ module RailsI18nManager
2
+ class TranslationsImportJob < ApplicationJob
3
+
4
+ class ImportAbortedError < StandardError; end
5
+
6
+ def perform(translation_app_id:, import_file:, overwrite_existing: false, mark_inactive_translations: false)
7
+ app_record = TranslationApp.find(translation_app_id)
8
+
9
+ if import_file.end_with?(".json")
10
+ translations_hash = JSON.parse(File.read(import_file))
11
+ else
12
+ translations_hash = YAML.safe_load(File.read(import_file))
13
+ end
14
+
15
+ new_locales = translations_hash.keys - app_record.all_locales
16
+
17
+ if new_locales.any?
18
+ raise ImportAbortedError.new("Import aborted. Locale not listed in translation app: #{new_locales.join(', ')}")
19
+ end
20
+
21
+ all_keys = RailsI18nManager.fetch_flattened_dot_notation_keys(translations_hash)
22
+
23
+ key_records_by_key = app_record.translation_keys.includes(:translation_values).index_by(&:key)
24
+
25
+ all_keys.each do |key|
26
+ if key_records_by_key[key].nil?
27
+ key_records_by_key[key] = app_record.translation_keys.new(key: key)
28
+ key_records_by_key[key].save!
29
+ end
30
+ end
31
+
32
+ translation_values_to_import = []
33
+
34
+ key_records_by_key.each do |key, key_record|
35
+ app_record.all_locales.each do |locale|
36
+ split_keys = [locale] + key.split(".").map{|x| x}
37
+
38
+ val = translations_hash.dig(*split_keys)
39
+
40
+ if val.present?
41
+ val_record = key_record.translation_values.detect{|x| x.locale == locale.to_s }
42
+
43
+ if val_record.nil?
44
+ translation_values_to_import << key_record.translation_values.new(locale: locale, translation: val)
45
+ elsif val_record.translation.blank? || (overwrite_existing && val_record.translation != val)
46
+ val_record.update!(translation: val)
47
+ next
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ ### We use active_record-import for big speedup, set validate false if more speed required
54
+ TranslationValue.import(translation_values_to_import, validate: true)
55
+
56
+ if mark_inactive_translations
57
+ app_record.translation_keys
58
+ .where.not(key: all_keys)
59
+ .update_all(active: false)
60
+
61
+ app_record.translation_keys
62
+ .where(key: all_keys)
63
+ .update_all(active: true)
64
+ end
65
+
66
+ return true
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,25 @@
1
+ module RailsI18nManager
2
+ module Forms
3
+ class Base
4
+ include ActiveModel::Validations
5
+
6
+ def initialize(attrs={})
7
+ attrs ||= {}
8
+
9
+ attrs.each do |k,v|
10
+ self.send("#{k}=", v) ### Use send so that it checks that attr_accessor has already defined the method so its a valid attribute
11
+ end
12
+ end
13
+
14
+ def to_key
15
+ nil
16
+ end
17
+
18
+ def model_name
19
+ sanitized_class_name = self.class.name.to_s.gsub("Forms::", '').gsub(/Form$/, '')
20
+ ActiveModel::Name.new(self, self.class.superclass, sanitized_class_name)
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ module RailsI18nManager
2
+ module Forms
3
+ class TranslationFileForm < Base
4
+
5
+ attr_accessor :translation_app_id, :file, :overwrite_existing
6
+ attr_reader :overwrite_existing, :mark_inactive_translations
7
+
8
+ validates :translation_app_id, presence: {message: "Must select an App"}
9
+ validates :file, presence: true
10
+ validate :validate_file
11
+
12
+ def overwrite_existing=(val)
13
+ @overwrite_existing = ["1", "true", "t"].include?(val.to_s.downcase)
14
+ end
15
+
16
+ def mark_inactive_translations=(val)
17
+ @mark_inactive_translations = ["1", "true", "t"].include?(val.to_s.downcase)
18
+ end
19
+
20
+ def validate_file
21
+ if file.blank?
22
+ errors.add(:file, "Must upload a valid translation file.")
23
+ return
24
+ end
25
+
26
+ if [".yml", ".json"].exclude?(File.extname(file))
27
+ errors.add(:file, "Invalid file format. Must be yml or json file.")
28
+ return
29
+ end
30
+
31
+ if File.read(file).blank?
32
+ errors.add(:file, "Empty file provided.")
33
+ return
34
+ end
35
+
36
+ case File.extname(file)
37
+ when ".yml"
38
+ if !YAML.safe_load(File.read(file)).is_a?(Hash)
39
+ errors.add(:file, "Invalid yml file.")
40
+ return
41
+ end
42
+
43
+ when ".json"
44
+ begin
45
+ JSON.parse(File.read(file))
46
+ rescue JSON::ParserError
47
+ errors.add(:file, "Invalid json file.")
48
+ return
49
+ end
50
+ end
51
+ end
52
+
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,72 @@
1
+ module RailsI18nManager
2
+ module GoogleTranslate
3
+
4
+ def self.translate(text, from:, to:)
5
+ api_key = RailsI18nManager.config.google_translate_api_key
6
+
7
+ if !supported_locales.include?(to.to_s) || Rails.env.test? || (api_key.blank? && Rails.env.development?)
8
+ return false
9
+ end
10
+
11
+ if text.include?("<") && text.include?(">")
12
+ ### Dont translate any HTML strings
13
+ return nil
14
+ end
15
+
16
+ EasyTranslate.translate(text, from: from, to: to, key: api_key)
17
+
18
+ if translation
19
+ str = translation.to_s
20
+
21
+ if str.present?
22
+ ### Replace single quote html entity with single quote character
23
+ str = str.gsub("&#39;", "'")
24
+
25
+ if to.to_s == "es"
26
+ str = str.gsub("% {", " %{").strip
27
+ end
28
+
29
+ return str
30
+ end
31
+ end
32
+ end
33
+
34
+ ### List retrieved from Google Translate (2022)
35
+ @@supported_locales = ["af", "am", "ar", "az", "be", "bg", "bn", "bs", "ca", "ceb", "co", "cs", "cy", "da", "de", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fr", "fy", "ga", "gd", "gl", "gu", "ha", "haw", "he", "hi", "hmn", "hr", "ht", "hu", "hy", "id", "ig", "is", "it", "iw", "ja", "jw", "ka", "kk", "km", "kn", "ko", "ku", "ky", "la", "lb", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my", "ne", "nl", "no", "ny", "or", "pa", "pl", "ps", "pt", "ro", "ru", "rw", "sd", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "tk", "tl", "tr", "tt", "ug", "uk", "ur", "uz", "vi", "xh", "yi", "yo", "zh", "zh-CN", "zh-TW", "zu"].freeze
36
+ mattr_reader :supported_locales
37
+
38
+ ### FOR official client
39
+ # require "google/cloud/translate/v2" ### Offical Google Translate with API Key
40
+ # def self.client
41
+ # @@client ||= begin
42
+ # api_key = RailsI18nManager.config.google_translate_api_key
43
+ # if Rails.env.test? || (api_key.blank? && Rails.env.development?)
44
+ # ### Skip Client
45
+ # nil
46
+ # else
47
+ # Google::Cloud::Translate::V2.new(key: api_key)
48
+ # end
49
+ # end
50
+ # end
51
+ # def self.supported_locales
52
+ # @@suported_locales ||= begin
53
+ # if client
54
+ # @@supported_locales = client.languages.map{|x| x.code}
55
+ # else
56
+ # []
57
+ # end
58
+ # end
59
+ # end
60
+ # def self.translate(text, from:, to:)
61
+ # if client
62
+ # begin
63
+ # translation = client.translate(text, from: from, to: to)
64
+ # rescue Google::Cloud::InvalidArgumentError
65
+ # ### Error usually caused by an unsupported locale
66
+ # return nil
67
+ # end
68
+ # end
69
+ # end
70
+
71
+ end
72
+ end
@@ -0,0 +1,20 @@
1
+ module RailsI18nManager
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ include ActiveSortOrder
6
+
7
+ scope :multi_search, ->(full_str){
8
+ if full_str.present?
9
+ rel = self
10
+
11
+ full_str.split(' ').each do |q|
12
+ rel = rel.search(q)
13
+ end
14
+
15
+ next rel
16
+ end
17
+ }
18
+
19
+ end
20
+ end
@@ -0,0 +1,108 @@
1
+ module RailsI18nManager
2
+ class TranslationApp < ApplicationRecord
3
+ NAME = "Translated App".freeze
4
+
5
+ has_many :translation_keys, class_name: "RailsI18nManager::TranslationKey", dependent: :destroy
6
+
7
+ before_validation :clean_additional_locales
8
+ after_update :handle_removed_locales
9
+ after_update :handle_added_locales
10
+
11
+ validates :name, presence: true, uniqueness: {case_sensitive: false}
12
+ validates :default_locale, presence: true
13
+ validate :validate_additional_locales
14
+
15
+ scope :search, ->(str){
16
+ fields = [
17
+ "#{table_name}.name",
18
+ ]
19
+
20
+ like = connection.adapter_name.downcase.to_s == "postgres" ? "ILIKE" : "LIKE"
21
+
22
+ sql_conditions = []
23
+
24
+ fields.each do |col|
25
+ sql_conditions << "(#{col} #{like} :search)"
26
+ end
27
+
28
+ self.where(sql_conditions.join(" OR "), search: "%#{str}%")
29
+ }
30
+
31
+ def additional_locales=(val)
32
+ if val.is_a?(Array)
33
+ val = val.map{|x| x.to_s.downcase.strip.presence }.compact.uniq.sort
34
+ val.delete(self.default_locale)
35
+
36
+ self[:additional_locales] = val.join(",")
37
+ else
38
+ self[:additional_locales] = val
39
+ end
40
+ end
41
+
42
+ def additional_locales_array
43
+ additional_locales.to_s.split(",")
44
+ end
45
+
46
+ def all_locales
47
+ [self.default_locale] + additional_locales_array
48
+ end
49
+
50
+ private
51
+
52
+ def validate_additional_locales
53
+ if additional_locales_changed?
54
+ invalid_locales = []
55
+
56
+ additional_locales_array.each do |locale|
57
+ if !RailsI18nManager.config.valid_locales.include?(locale)
58
+ invalid_locales << locale
59
+ end
60
+ end
61
+
62
+ if invalid_locales.any?
63
+ self.errors.add(:additional_locales, "Invalid locales: #{invalid_locales.join(", ")}")
64
+ end
65
+ end
66
+ end
67
+
68
+ def clean_additional_locales
69
+ if additional_locales_changed?
70
+ cleaned_array = additional_locales_array.map{|x| x.to_s.downcase.strip.presence }.compact.uniq.sort
71
+ cleaned_array.delete(self.default_locale)
72
+
73
+ self.additional_locales = cleaned_array.join(",")
74
+ end
75
+ end
76
+
77
+ def handle_removed_locales
78
+ if previous_changes.has_key?("default_locale") || previous_changes.has_key?("additional_locales")
79
+ TranslationValue
80
+ .joins(:translation_key).where(TranslationKey.table_name => {translation_app_id: self.id})
81
+ .where.not(locale: all_locales)
82
+ .delete_all ### instead of destroy_all, use delete_all for speedup
83
+ end
84
+ end
85
+
86
+ def handle_added_locales
87
+ ### ATTEMPTING TO JUST SKIP THIS
88
+
89
+ # ### For new locales, create TranslationValue records
90
+ # value_records_for_import = []
91
+
92
+ # translation_keys.includes(:translation_values).each do |key_record|
93
+ # additional_locales_array.each do |locale|
94
+ # val_record = key_record.translation_values.detect{|x| x.locale == locale }
95
+
96
+ # if val_record.nil?
97
+ # value_records_for_import << key_record.translation_values.new(locale: locale)
98
+ # end
99
+ # end
100
+ # end
101
+
102
+ # ### We use active_record-import for big speedup also using validate: false for more speed
103
+ # TranslationValue.import(value_records_for_import, validate: false)
104
+ # end
105
+ end
106
+
107
+ end
108
+ end