ultra_settings 2.8.0 → 2.9.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/MIT-LICENSE.txt +1 -1
  4. data/README.md +108 -1
  5. data/VERSION +1 -1
  6. data/app/AGENTS.md +7 -0
  7. data/app/_config_description.html.erb +22 -25
  8. data/app/_config_list.html.erb +2 -14
  9. data/app/_data_source.html.erb +53 -0
  10. data/app/application.css +1078 -259
  11. data/app/application.js +818 -91
  12. data/app/application_vars.css.erb +136 -81
  13. data/app/configuration.html.erb +60 -107
  14. data/app/index.html.erb +164 -20
  15. data/app/layout.css +81 -16
  16. data/app/layout.html.erb +67 -5
  17. data/app/layout_vars.css.erb +29 -5
  18. data/app/locales/ar.json +71 -0
  19. data/app/locales/cs.json +71 -0
  20. data/app/locales/da.json +71 -0
  21. data/app/locales/de.json +71 -0
  22. data/app/locales/el.json +71 -0
  23. data/app/locales/en.json +85 -0
  24. data/app/locales/es.json +71 -0
  25. data/app/locales/fa.json +71 -0
  26. data/app/locales/fr.json +71 -0
  27. data/app/locales/gd.json +71 -0
  28. data/app/locales/he.json +71 -0
  29. data/app/locales/hi.json +71 -0
  30. data/app/locales/it.json +71 -0
  31. data/app/locales/ja.json +71 -0
  32. data/app/locales/ko.json +71 -0
  33. data/app/locales/lt.json +71 -0
  34. data/app/locales/nb.json +71 -0
  35. data/app/locales/nl.json +71 -0
  36. data/app/locales/pl.json +71 -0
  37. data/app/locales/pt-br.json +71 -0
  38. data/app/locales/pt.json +71 -0
  39. data/app/locales/ru.json +71 -0
  40. data/app/locales/sv.json +71 -0
  41. data/app/locales/ta.json +71 -0
  42. data/app/locales/tr.json +71 -0
  43. data/app/locales/uk.json +71 -0
  44. data/app/locales/ur.json +71 -0
  45. data/app/locales/vi.json +71 -0
  46. data/app/locales/zh-cn.json +71 -0
  47. data/app/locales/zh-tw.json +71 -0
  48. data/lib/ultra_settings/application_view.rb +21 -3
  49. data/lib/ultra_settings/audit_data_sources.rb +98 -0
  50. data/lib/ultra_settings/coerce.rb +0 -6
  51. data/lib/ultra_settings/config_helper.rb +4 -4
  52. data/lib/ultra_settings/configuration.rb +28 -7
  53. data/lib/ultra_settings/configuration_view.rb +117 -56
  54. data/lib/ultra_settings/mini_i18n.rb +110 -0
  55. data/lib/ultra_settings/rack_app.rb +51 -1
  56. data/lib/ultra_settings/railtie.rb +8 -0
  57. data/lib/ultra_settings/tasks/audit_data_sources.rake +76 -0
  58. data/lib/ultra_settings/tasks/utils.rb +23 -0
  59. data/lib/ultra_settings/version.rb +1 -1
  60. data/lib/ultra_settings/web_view.rb +33 -2
  61. data/lib/ultra_settings.rb +56 -22
  62. data/ultra_settings.gemspec +3 -0
  63. metadata +38 -3
  64. data/app/_select_menu.html.erb +0 -53
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltraSettings
4
+ class AuditDataSources
5
+ class << self
6
+ # Find environment variables that are set but have the same value as their default.
7
+ # These environment variables could potentially be removed since they're not changing behavior.
8
+ #
9
+ # @return [Array<Array<(String, Object)>>] An array of tuples containing environment variable names and their default values
10
+ def unnecessary_env_vars
11
+ env_vars_at_default = []
12
+ each_configuration do |config|
13
+ each_field_using_source(config, :env) do |field|
14
+ value = config[field.name]
15
+ default_value = default_config_value(config, field)
16
+ env_vars_at_default << [field.env_var, default_value] if default_value == value
17
+ end
18
+ end
19
+ env_vars_at_default
20
+ end
21
+
22
+ # Find runtime settings that are set but have the same value as their default.
23
+ # These runtime settings could potentially be removed since they're not changing behavior.
24
+ #
25
+ # @return [Array<Array<(String, Object)>>] An array of tuples containing runtime setting names and their default values
26
+ def unnecessary_runtime_settings
27
+ unnecessary_runtime_settings = []
28
+ each_configuration do |config|
29
+ each_field_using_source(config, :settings) do |field|
30
+ value = config[field.name]
31
+ default_value = default_config_value(config, field)
32
+ unnecessary_runtime_settings << [field.runtime_setting, default_value] if default_value == value
33
+ end
34
+ end
35
+ unnecessary_runtime_settings
36
+ end
37
+
38
+ # Find environment variables that could be moved to runtime settings.
39
+ # These are non-default environment variable values where a runtime setting is also available.
40
+ #
41
+ # @return [Array<Array<(String, String, Object)>>] An array of tuples containing environment variable name, runtime setting name, and current value
42
+ def env_vars_can_be_runtime_setting
43
+ env_vars_can_be_runtime = []
44
+ each_configuration do |config|
45
+ each_field_using_source(config, :env) do |field|
46
+ value = config[field.name]
47
+ default_value = default_config_value(config, field)
48
+ next unless field.runtime_setting && value != default_value
49
+
50
+ env_vars_can_be_runtime << [field.env_var, field.runtime_setting, value]
51
+ end
52
+ end
53
+ env_vars_can_be_runtime
54
+ end
55
+
56
+ # Find environment variables being used that don't have default values defined.
57
+ # These configurations require an environment variable to be set.
58
+ #
59
+ # @return [Array<Array<(String, Symbol, String, Object)>>] An array of tuples containing class name, field name, environment variable name, and current value
60
+ def env_vars_without_default
61
+ no_default_env_var_fields = []
62
+ each_configuration do |config|
63
+ each_field_using_source(config, :env) do |field|
64
+ value = default_config_value(config, field)
65
+ if value.nil?
66
+ no_default_env_var_fields << [config.class.name, field.name, field.env_var, config[field.name]]
67
+ end
68
+ end
69
+ end
70
+ no_default_env_var_fields
71
+ end
72
+
73
+ private
74
+
75
+ def each_configuration(&_block)
76
+ UltraSettings::Configuration.descendant_configurations.each do |config_class|
77
+ config = config_class.instance
78
+ yield config
79
+ end
80
+ end
81
+
82
+ def each_field_using_source(config, source, &_block)
83
+ config.class.fields.each do |field|
84
+ next if field.secret?
85
+ next unless config.__source__(field.name) == source
86
+
87
+ yield field
88
+ end
89
+ end
90
+
91
+ def default_config_value(config, field)
92
+ yaml_value = config.__value_from_source__(field.name, :yaml)
93
+ default_value = config.__value_from_source__(field.name, :default)
94
+ yaml_value || default_value
95
+ end
96
+ end
97
+ end
98
+ end
@@ -39,12 +39,6 @@ module UltraSettings
39
39
  array(value).map(&:to_s)
40
40
  when :symbol
41
41
  value.to_s.to_sym
42
- when :rollout
43
- if numeric?(value)
44
- value.to_f
45
- else
46
- boolean(value)
47
- end
48
42
  else
49
43
  value.to_s
50
44
  end
@@ -15,13 +15,13 @@ module UltraSettings
15
15
  #
16
16
  # @param config_class [Class] The configuration class to use.
17
17
  # @return [void]
18
- def configuration_class(config_class)
19
- define_singleton_method :config do
18
+ def configuration_class(config_class, config_alias: :config)
19
+ define_singleton_method config_alias do
20
20
  config_class.instance
21
21
  end
22
22
 
23
- define_method :config do
24
- self.class.config
23
+ define_method config_alias do
24
+ self.class.send(config_alias)
25
25
  end
26
26
  end
27
27
  end
@@ -10,6 +10,7 @@ module UltraSettings
10
10
  @env_var_prefix = nil
11
11
  @runtime_setting_prefix = nil
12
12
  @description = nil
13
+ @descendants = []
13
14
 
14
15
  class << self
15
16
  # Set a description for the configuration. This is optional. It will be displayed
@@ -84,7 +85,8 @@ module UltraSettings
84
85
  secret: secret
85
86
  )
86
87
 
87
- class_eval <<~RUBY, __FILE__, __LINE__ + 1 # rubocop:disable Security/Eval
88
+ caller_location = caller_locations(1, 1).first
89
+ class_eval <<~RUBY, caller_location.path, caller_location.lineno # rubocop:disable Security/Eval, Style/EvalWithLocation
88
90
  def #{name}
89
91
  __get_value__(#{name.inspect})
90
92
  end
@@ -110,7 +112,7 @@ module UltraSettings
110
112
  name = name.to_s
111
113
  return true if defined_fields.include?(name)
112
114
 
113
- if superclass <= Configuration
115
+ if superclass < Configuration
114
116
  superclass.include_field?(name)
115
117
  else
116
118
  false
@@ -180,7 +182,7 @@ module UltraSettings
180
182
 
181
183
  @configuration_file = default_configuration_file
182
184
  end
183
- return nil? unless @configuration_file
185
+ return nil unless @configuration_file
184
186
 
185
187
  path = @configuration_file
186
188
  if path.relative? && yaml_config_path
@@ -369,12 +371,30 @@ module UltraSettings
369
371
  YamlConfig.new(configuration_file, yaml_config_env).to_h
370
372
  end
371
373
 
374
+ # Get all descendant configuration classes (subclasses and their subclasses, recursively).
375
+ #
376
+ # @return [Array<Class>] All classes that inherit from this class.
377
+ def descendant_configurations
378
+ @descendants ||= []
379
+ @descendants.flat_map { |subclass| [subclass] + subclass.descendant_configurations }
380
+ end
381
+
372
382
  private
373
383
 
384
+ # Hook called when this class is inherited. Tracks all descendant classes.
385
+ #
386
+ # @param subclass [Class] The subclass that is inheriting from this class.
387
+ # @return [void]
388
+ def inherited(subclass)
389
+ super
390
+ @descendants ||= []
391
+ @descendants << subclass
392
+ end
393
+
374
394
  def defined_fields
375
395
  unless defined?(@defined_fields)
376
396
  fields = {}
377
- if superclass <= Configuration
397
+ if superclass < Configuration
378
398
  fields = superclass.send(:defined_fields).dup
379
399
  end
380
400
  @defined_fields = fields
@@ -540,7 +560,7 @@ module UltraSettings
540
560
 
541
561
  sources = []
542
562
  sources << :env if field.env_var
543
- sources << :settings if field.runtime_setting && UltraSettings.__runtime_settings__
563
+ sources << :settings if !field.static? && field.runtime_setting && UltraSettings.__runtime_settings__
544
564
  sources << :yaml if field.yaml_key && self.class.configuration_file
545
565
  sources << :default unless field.default.nil?
546
566
  sources
@@ -572,7 +592,8 @@ module UltraSettings
572
592
  field = self.class.send(:defined_fields)[name]
573
593
  return nil unless field
574
594
 
575
- use_override = @ultra_settings_override_values[Thread.current.object_id]&.include?(name)
595
+ override_values = @ultra_settings_mutex.synchronize { @ultra_settings_override_values[Thread.current.object_id] }
596
+ use_override = override_values&.include?(name)
576
597
 
577
598
  if field.static? && !use_override && @ultra_settings_memoized_values.include?(name)
578
599
  return @ultra_settings_memoized_values[name]
@@ -580,7 +601,7 @@ module UltraSettings
580
601
 
581
602
  value = nil
582
603
  if use_override
583
- value = field.coerce(@ultra_settings_override_values[Thread.current.object_id][name])
604
+ value = field.coerce(override_values[name])
584
605
  else
585
606
  env = ENV if field.env_var
586
607
  settings = UltraSettings.__runtime_settings__ if field.runtime_setting
@@ -16,16 +16,18 @@ module UltraSettings
16
16
  # Initialize the configuration view with a configuration instance.
17
17
  #
18
18
  # @param configuration [UltraSettings::Configuration] The configuration instance to display.
19
- def initialize(configuration)
19
+ # @param locale [String] The locale code for translations.
20
+ def initialize(configuration, locale: UltraSettings::MiniI18n::DEFAULT_LOCALE)
20
21
  @configuration = configuration
22
+ @locale = locale
21
23
  end
22
24
 
23
25
  # Render the HTML for the configuration view.
24
26
  #
25
- # @param table_class [String] CSS class for the table element (maintained for backwards compatibility).
27
+ # @param table_class [String] @deprecated CSS class for the table element (maintained for backwards compatibility).
26
28
  # @return [String] The rendered HTML.
27
29
  def render(table_class: "")
28
- configuration = @configuration
30
+ configuration = @configuration # used by ERB template via binding
29
31
  html = ViewHelper.erb_template("configuration.html.erb").result(binding)
30
32
  html = html.html_safe if html.respond_to?(:html_safe)
31
33
  html
@@ -40,6 +42,25 @@ module UltraSettings
40
42
 
41
43
  private
42
44
 
45
+ # Map an UltraSettings field type to a SuperSettings value type.
46
+ #
47
+ # @param type [Symbol] The UltraSettings field type.
48
+ # @return [String] The corresponding SuperSettings value type.
49
+ def super_settings_value_type(type)
50
+ case type.to_sym
51
+ when :symbol then "string"
52
+ else type.to_s
53
+ end
54
+ end
55
+
56
+ # Look up a translation key for the current locale.
57
+ #
58
+ # @param key [String] dotted translation key
59
+ # @return [String]
60
+ def t(key)
61
+ UltraSettings::MiniI18n.t(key, locale: @locale)
62
+ end
63
+
43
64
  def display_value(value)
44
65
  case value
45
66
  when Time
@@ -49,35 +70,9 @@ module UltraSettings
49
70
  end
50
71
  end
51
72
 
52
- def show_defined_value(label, value, secret)
53
- val = nil
54
- icon = nil
55
- css_class = nil
56
-
57
- if value.nil?
58
- val = "Not set"
59
- icon = not_set_icon
60
- css_class = "ultra-settings-icon-not-set"
61
- elsif secret
62
- val = secret_value(value)
63
- icon = lock_icon
64
- css_class = "ultra-settings-icon-secret"
65
- else
66
- val = display_value(value)
67
- icon = eye_icon
68
- css_class = "ultra-settings-icon-info"
69
- end
70
-
71
- <<~HTML
72
- <dfn class="#{css_class}" title="#{html_escape(val)}" onclick="#{html_escape(open_dialog_script)}" data-label="#{html_escape(label)}">
73
- #{icon}
74
- </dfn>
75
- HTML
76
- end
77
-
78
73
  def secret_value(value)
79
74
  if value.nil?
80
- "nil"
75
+ t("field.nil")
81
76
  else
82
77
  "••••••••••••••••"
83
78
  end
@@ -92,28 +87,102 @@ module UltraSettings
92
87
  path.relative_path_from(root_path)
93
88
  end
94
89
 
95
- def info_icon(size = 16)
90
+ def source_chip_label(source)
91
+ case source
92
+ when :env then t("source.env")
93
+ when :settings then t("source.setting")
94
+ when :yaml then t("source.yaml")
95
+ when :default then t("source.default")
96
+ else source.to_s.upcase
97
+ end
98
+ end
99
+
100
+ def source_chip_class(source)
101
+ case source
102
+ when :env then "ultra-settings-chip-env"
103
+ when :settings then "ultra-settings-chip-setting"
104
+ when :yaml then "ultra-settings-chip-yaml"
105
+ when :default then "ultra-settings-chip-default"
106
+ else "ultra-settings-chip-default"
107
+ end
108
+ end
109
+
110
+ def source_key_name(field, source)
111
+ case source
112
+ when :env then field.env_var
113
+ when :settings then field.runtime_setting
114
+ when :yaml then field.yaml_key
115
+ when :default then nil
116
+ end
117
+ end
118
+
119
+ def open_panel_script
120
+ <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
121
+ var el = this;
122
+ var panel = document.getElementById('ultra-settings-detail-panel');
123
+ if (panel) {
124
+ var name = el.dataset.name || '';
125
+ var value = el.dataset.value || '';
126
+ var type = el.dataset.type || '';
127
+ var isSecret = el.dataset.secret || 'false';
128
+ document.getElementById('ultra-settings-dp-title').textContent = name;
129
+ document.getElementById('ultra-settings-dp-value').textContent = isSecret === 'true' ? window.__ultraSettingsI18n['detail.secret_value'] : value;
130
+ document.getElementById('ultra-settings-dp-meta').innerHTML = window.__ultraSettingsI18n['detail.type_label'] + ' <span>' + type.toUpperCase() + '</span>' + (isSecret === 'true' ? ' \u00B7 <span style=color:var(--badge-secret-text)>' + window.__ultraSettingsI18n['detail.secret_badge'] + '</span>' : '');
131
+ document.getElementById('ultra-settings-panel-bg').classList.add('open');
132
+ panel.classList.add('open');
133
+ } else {
134
+ var block = el.closest('.ultra-settings-block');
135
+ if (block) {
136
+ var dialog = block.querySelector('.ultra-settings-dialog');
137
+ if (dialog) {
138
+ var title = dialog.querySelector('.ultra-settings-dialog-title');
139
+ var val = dialog.querySelector('.ultra-settings-dialog-value');
140
+ if (title) title.textContent = el.dataset.name || '';
141
+ if (val) val.textContent = el.dataset.value || '';
142
+ dialog.showModal();
143
+ }
144
+ }
145
+ }
146
+ JAVASCRIPT
147
+ end
148
+
149
+ def source_priority
150
+ [:env, :settings, :yaml, :default]
151
+ end
152
+
153
+ def source_overridden_by(current_source, active_source)
154
+ return nil if current_source == active_source
155
+
156
+ current_index = source_priority.index(current_source)
157
+ active_index = source_priority.index(active_source)
158
+
159
+ return nil if current_index.nil? || active_index.nil?
160
+ return nil if current_index < active_index
161
+
162
+ active_source
163
+ end
164
+
165
+ def lock_icon(size = 16)
96
166
  <<~HTML
97
167
  <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
98
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
99
- <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
168
+ <path fill-rule="evenodd" d="M8 0a4 4 0 0 1 4 4v2.05a2.5 2.5 0 0 1 2 2.45v5a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 2 13.5v-5a2.5 2.5 0 0 1 2-2.45V4a4 4 0 0 1 4-4m0 1a3 3 0 0 0-3 3v2h6V4a3 3 0 0 0-3-3"/>
100
169
  </svg>
101
170
  HTML
102
171
  end
103
172
 
104
- def not_set_icon(size = 16)
173
+ def pin_icon(size = 16)
105
174
  <<~HTML
106
175
  <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
107
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
108
- <path d="M11.354 4.646a.5.5 0 0 0-.708 0l-6 6a.5.5 0 0 0 .708.708l6-6a.5.5 0 0 0 0-.708"/>
176
+ <path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4A.5.5 0 0 1 3 9.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354"/>
109
177
  </svg>
110
178
  HTML
111
179
  end
112
180
 
113
- def lock_icon(size = 16)
181
+ def file_icon(size = 13)
114
182
  <<~HTML
115
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
116
- <path fill-rule="evenodd" d="M8 0a4 4 0 0 1 4 4v2.05a2.5 2.5 0 0 1 2 2.45v5a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 2 13.5v-5a2.5 2.5 0 0 1 2-2.45V4a4 4 0 0 1 4-4m0 1a3 3 0 0 0-3 3v2h6V4a3 3 0 0 0-3-3"/>
183
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
184
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
185
+ <polyline points="13 2 13 9 20 9"/>
117
186
  </svg>
118
187
  HTML
119
188
  end
@@ -127,31 +196,23 @@ module UltraSettings
127
196
  HTML
128
197
  end
129
198
 
130
- def eye_icon(size = 16)
199
+ def close_icon(size = 16)
131
200
  <<~HTML
132
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
133
- <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/>
134
- <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/>
201
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
202
+ <line x1="18" y1="6" x2="6" y2="18"/>
203
+ <line x1="6" y1="6" x2="18" y2="18"/>
135
204
  </svg>
136
205
  HTML
137
206
  end
138
207
 
139
- def close_icon(size = 16)
208
+ def warning_icon(size = 16)
140
209
  <<~HTML
141
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
142
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
143
- <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/>
210
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
211
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke-width="2" stroke-linejoin="round"/>
212
+ <line x1="12" y1="10" x2="12" y2="14" stroke-width="2.5" stroke-linecap="round"/>
213
+ <circle cx="12" cy="17" r="1" fill="currentColor"/>
144
214
  </svg>
145
215
  HTML
146
216
  end
147
-
148
- def open_dialog_script
149
- <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
150
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-title').textContent = this.dataset.label;
151
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-value').textContent = this.title;
152
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog').showModal();
153
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-close').blur();
154
- JAVASCRIPT
155
- end
156
217
  end
157
218
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module UltraSettings
6
+ # Internationalization support for the web UI. Translations are stored as
7
+ # JSON files in +app/locales/+ with one file per locale (e.g. +en.json+).
8
+ #
9
+ # Ruby templates use `#t` to look up a dotted key. JavaScript receives the
10
+ # full translation hash inlined via a +<script>+ tag so that the same JSON
11
+ # file drives both server-side and client-side strings.
12
+ module MiniI18n
13
+ DEFAULT_LOCALE = "en"
14
+
15
+ @cache = {}
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Return the list of available locale codes derived from the JSON files
20
+ # present in +app/locales/+.
21
+ #
22
+ # @return [Array<String>] locale codes, e.g. +["en"]+
23
+ def available_locales
24
+ load_all_locales.keys.sort
25
+ end
26
+
27
+ # Look up a translation by dotted key for the given locale.
28
+ # Falls back to the default locale when the key is missing, and
29
+ # ultimately returns the key itself if no translation is found.
30
+ #
31
+ # @param key [String] dotted translation key, e.g. +"page.title"+
32
+ # @param locale [String] the locale code (default: +DEFAULT_LOCALE+)
33
+ # @return [String] the translated string
34
+ def t(key, locale: DEFAULT_LOCALE)
35
+ translations = translations_for(locale)
36
+ translations[key] || translations_for(DEFAULT_LOCALE)[key] || key
37
+ end
38
+
39
+ # Return the full translation hash for a locale. Used to inline
40
+ # translations into the HTML page for JavaScript consumption.
41
+ #
42
+ # @param locale [String] the locale code
43
+ # @return [Hash<String, String>] all key/value translations
44
+ def translations_for(locale)
45
+ load_all_locales[normalize_locale(locale)] || load_all_locales[DEFAULT_LOCALE] || {}
46
+ end
47
+
48
+ # Return the text direction for the given locale. Reads the +"dir"+ key
49
+ # from the locale's translations hash, falling back to +"ltr"+ when not set.
50
+ #
51
+ # @param locale [String] the locale code
52
+ # @return [String] +"ltr"+ or +"rtl"+
53
+ def text_direction(locale = DEFAULT_LOCALE)
54
+ dir = translations_for(locale)["dir"]
55
+ (dir == "rtl") ? "rtl" : "ltr"
56
+ end
57
+
58
+ # Clear the translation cache. Called automatically in development mode.
59
+ #
60
+ # @return [void]
61
+ def clear_cache!
62
+ @mutex.synchronize { @cache = {} }
63
+ end
64
+
65
+ private
66
+
67
+ # Normalize a locale string to just the language subtag if the full
68
+ # tag is not available (e.g. "en-US" → "en").
69
+ def normalize_locale(locale)
70
+ locale = locale.to_s.strip.tr("_", "-").downcase
71
+ return locale if @cache.key?(locale)
72
+
73
+ # Try the language subtag (e.g. "en-us" → "en")
74
+ lang = locale.split("-").first
75
+ lang if @cache.key?(lang)
76
+ end
77
+
78
+ # Load every JSON file from the locales directory, keyed by filename
79
+ # stem (e.g. "en").
80
+ def load_all_locales
81
+ if development_mode?
82
+ @mutex.synchronize { @cache = {} }
83
+ end
84
+
85
+ return @cache unless @cache.empty?
86
+
87
+ @mutex.synchronize do
88
+ return @cache unless @cache.empty?
89
+
90
+ Dir.glob(File.join(locales_dir, "*.json")).each do |path|
91
+ code = File.basename(path, ".json").downcase
92
+ @cache[code] = JSON.parse(File.read(path))
93
+ rescue JSON::ParserError
94
+ # Skip malformed locale files
95
+ end
96
+ end
97
+
98
+ @cache
99
+ end
100
+
101
+ def locales_dir
102
+ File.expand_path(File.join("..", "..", "app", "locales"), __dir__)
103
+ end
104
+
105
+ def development_mode?
106
+ ENV.fetch("RACK_ENV", "development") == "development"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -18,7 +18,9 @@ module UltraSettings
18
18
  # @param env [Hash] The Rack environment.
19
19
  # @return [Array] A Rack response array [status, headers, body].
20
20
  def call(env)
21
- [200, {"content-type" => "text/html; charset=utf8"}, [webview.render_settings]]
21
+ request = Rack::Request.new(env)
22
+ locale = resolve_locale(request)
23
+ [200, {"content-type" => "text/html; charset=utf8"}, [webview.render_settings(request, locale: locale)]]
22
24
  end
23
25
 
24
26
  private
@@ -29,5 +31,53 @@ module UltraSettings
29
31
  end
30
32
  @webview ||= WebView.new(color_scheme: @color_scheme)
31
33
  end
34
+
35
+ # Determine the locale for a request. Precedence:
36
+ # 1. ?lang= query parameter
37
+ # 2. ultra_settings_locale cookie (set by the language picker)
38
+ # 3. Accept-Language header
39
+ # 4. Default locale
40
+ def resolve_locale(request)
41
+ available = UltraSettings::MiniI18n.available_locales
42
+
43
+ # 1. Explicit query parameter
44
+ lang = request.params["lang"] if request.respond_to?(:params)
45
+ return lang if lang && available.include?(lang)
46
+
47
+ # 2. Cookie
48
+ cookie = request.cookies["ultra_settings_locale"] if request.respond_to?(:cookies)
49
+ return cookie if cookie && available.include?(cookie)
50
+
51
+ # 3. Accept-Language header
52
+ accept = request.env["HTTP_ACCEPT_LANGUAGE"] if request.respond_to?(:env)
53
+ locale_from_accept_language(accept.to_s, available) || UltraSettings::MiniI18n::DEFAULT_LOCALE
54
+ end
55
+
56
+ # Parse the Accept-Language header and return the best matching locale.
57
+ def locale_from_accept_language(header, available)
58
+ return nil if header.nil? || header.empty?
59
+
60
+ # Parse tags with optional quality values, e.g. "en-US,en;q=0.9,fr;q=0.8"
61
+ tags = header.split(",").map { |entry|
62
+ parts = entry.strip.split(";")
63
+ tag = parts[0].to_s.strip.downcase.tr("_", "-")
64
+ q = 1.0
65
+ parts[1..-1].each do |p|
66
+ if p.strip.start_with?("q=")
67
+ q = p.strip.sub("q=", "").to_f
68
+ end
69
+ end
70
+ [tag, q]
71
+ }.sort_by { |_, q| -q }
72
+
73
+ tags.each do |tag, _|
74
+ return tag if available.include?(tag)
75
+ # Try language subtag
76
+ lang = tag.split("-").first
77
+ return lang if available.include?(lang)
78
+ end
79
+
80
+ nil
81
+ end
32
82
  end
33
83
  end
@@ -23,6 +23,8 @@ module UltraSettings
23
23
 
24
24
  app_config_dir = Rails.root.join(directory)
25
25
  app_config_dir.glob("**/*_configuration.rb").each do |file_path|
26
+ next unless file_path.file? && file_path.readable?
27
+
26
28
  relative_path = file_path.relative_path_from(app_config_dir).to_s
27
29
  class_name = relative_path.chomp(".rb").classify
28
30
  unless UltraSettings.added?(class_name)
@@ -32,5 +34,11 @@ module UltraSettings
32
34
  end
33
35
  end
34
36
  end
37
+
38
+ rake_tasks do
39
+ Dir.glob(File.expand_path("tasks/*.rake", __dir__)).each do |rake_file|
40
+ load rake_file
41
+ end
42
+ end
35
43
  end
36
44
  end