rails_i18n_onair 1.0.0 → 1.0.4

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +23 -0
  4. data/app/controllers/rails_i18n_onair/api/live_translations_controller.rb +70 -12
  5. data/app/controllers/rails_i18n_onair/dashboard_controller.rb +3 -4
  6. data/app/controllers/rails_i18n_onair/locale_files_controller.rb +34 -9
  7. data/app/controllers/rails_i18n_onair/sessions_controller.rb +39 -0
  8. data/app/controllers/rails_i18n_onair/sync_controller.rb +1 -1
  9. data/app/helpers/rails_i18n_onair/application_helper.rb +6 -0
  10. data/app/models/rails_i18n_onair/translation.rb +12 -3
  11. data/app/views/rails_i18n_onair/dashboard/_file_mode.html.erb +14 -11
  12. data/app/views/rails_i18n_onair/locale_files/edit.html.erb +4 -4
  13. data/app/views/rails_i18n_onair/locale_files/index.html.erb +12 -8
  14. data/app/views/rails_i18n_onair/locale_files/show.html.erb +1 -1
  15. data/config/routes.rb +4 -1
  16. data/db/migrate/create_translations.rb +2 -1
  17. data/lib/rails_i18n_onair/backend.rb +18 -3
  18. data/lib/rails_i18n_onair/engine.rb +1 -1
  19. data/lib/rails_i18n_onair/file_manager.rb +64 -26
  20. data/lib/rails_i18n_onair/importer.rb +19 -14
  21. data/lib/rails_i18n_onair/live_ui/controller_helper.rb +16 -3
  22. data/lib/rails_i18n_onair/live_ui/middleware.rb +121 -7
  23. data/lib/rails_i18n_onair/live_ui/script.rb +49 -14
  24. data/lib/rails_i18n_onair/live_ui/translation_helper.rb +65 -12
  25. data/lib/rails_i18n_onair/live_ui/unwrapper.rb +50 -0
  26. data/lib/rails_i18n_onair/live_ui.rb +1 -0
  27. data/lib/rails_i18n_onair/sync_service.rb +34 -2
  28. data/lib/rails_i18n_onair/version.rb +1 -1
  29. metadata +17 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3d0ff96fc79cf5fa34e5ebaa6ab26728790eb92549fe5c62944f959e9447356
4
- data.tar.gz: 5b7069cca340ba202525d2cd64c1bc76c3adfdecd355cd5f7f070e0aadd80fbe
3
+ metadata.gz: 19ed757e0ff55cdfe24a433b697f40fae925c5f9ab9984fc87eea890ac327ef7
4
+ data.tar.gz: ef2239c23a898371ac91c9b3533e24586b30ec5e016a21b4ce8d652ed387a5c5
5
5
  SHA512:
6
- metadata.gz: 86bb02b3cfaea5bafd61f9f23be2554b7eb041dc5e5788d94ae5bf259f05da5452e9b2622f272c83a70faa69e315cce21f1bb28a573a155b84eca784e00f7c98
7
- data.tar.gz: b5a9b6319ef11ff267a3b0cd21a72e54375f1850d4d1c86b3368201905fd616238df515a1dbb27db4d77e0749c39a36342eecd3f23c09ae1970b3a28632d3007
6
+ metadata.gz: 802199a2fb927aa52641ff15d69dd789397442c0619e32f0f23f0d86cf2e3ae042157059ada79d208cf1c953d110c4e4777717a0378d56e37b2efc129d395ce5
7
+ data.tar.gz: 6cd4f39bfc3eaa1cd47bb4aff0dea64dbd5e302486d15c7bc8fdb0a1ce24b953acd0230607ef94ae3934dd4fe7404ec2ee634a5aee6df8fd628062f6c96b99a5
data/CHANGELOG.md ADDED
@@ -0,0 +1,95 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.4] - 2026-07-26
11
+
12
+ ## [1.0.3] - 2026-07-26
13
+
14
+ ### Fixed
15
+
16
+ - **Wrapped translations no longer leak markup into plain-text contexts.** Editable
17
+ wrappers that landed in HTML attribute values (`placeholder:`, `title:`,
18
+ `aria-label:`) or inside `<title>`, `<textarea>`, `<script>`, `<style>` and
19
+ `<option>` are stripped back to the translated text, instead of rendering the
20
+ `<span>` as literal characters.
21
+ - **Translations used as interpolation values stay plain.**
22
+ `t("greeting", name: t("app_name"))` previously escaped the inner wrapper into the
23
+ visible text of the outer translation; the inner wrapping is now removed before
24
+ interpolation, and `data-i18n-vars` records the clean value.
25
+ - **Editing a pluralized key no longer destroys the plural hash.** For
26
+ `t("items", count: 5)` the editor now targets the plural form that produced the
27
+ rendered text (`items.other`) rather than the parent key. Saves that would replace a
28
+ nested branch with a string are rejected with an explanatory message.
29
+ - **Scoped keys resolve to their full path.** `t(:title, scope: [:users, :index])`
30
+ now saves to `users.index.title` instead of `title`.
31
+ - Hash and array results from `t()` are returned untouched instead of being coerced
32
+ into a wrapped string.
33
+ - Live UI saves are written to the file that already defines the key
34
+ (`devise.en.yml`, `models/en.yml`, …), falling back to the base `<locale>.yml` for
35
+ new keys, instead of always writing to `<locale>.yml`.
36
+ - Dashboard file edits preserve non-string YAML scalars; integers, floats and
37
+ booleans are no longer written back as quoted strings.
38
+ - The Live UI API responds with JSON `401` instead of an HTML login redirect when the
39
+ session has expired, so the editor can report it rather than failing to parse the
40
+ response.
41
+ - Saving a translation updates every occurrence of that key on the page, not only the
42
+ element that was clicked.
43
+ - The translations migration matches the documented schema (`jsonb` with a GIN index).
44
+
45
+ ### Added
46
+
47
+ - Locale files are discovered recursively and may be named `<language>.yml`,
48
+ `<prefix>.<language>.yml` (`devise.en.yml`) or live in subdirectories
49
+ (`models/en.yml`). Imports deep-merge every file belonging to a language instead of
50
+ letting the last file win, and sync comparison accounts for keys defined in any of
51
+ a locale's files.
52
+ - Dashboard sign-in is throttled: 10 failed attempts per username and IP pair lock
53
+ further attempts for 15 minutes (requires a real `Rails.cache` store).
54
+ - The session identifier is rotated on successful sign-in to prevent session fixation,
55
+ while preserving the host application's session data.
56
+ - Live UI verifies that the signed-in translator still exists, so deleting a
57
+ translator revokes access. The signed session remains the primary credential: when
58
+ the model cannot be reached at all, the session is trusted rather than disabling
59
+ Live UI.
60
+
61
+ ### Changed
62
+
63
+ - Locale file access is confined to the configured locale directory; path traversal
64
+ and non-locale filenames are rejected.
65
+ - Saving a translation reloads only the affected locale instead of clearing every
66
+ locale from the cache, and cache invalidation falls back to explicit key deletion on
67
+ stores without `delete_matched` (for example memcached).
68
+ - The Live UI middleware skips compressed and streamed responses, and closes the
69
+ original response body.
70
+ - Dropped the unused `bootstrap` and `rails-i18n` runtime dependencies; Bootstrap is
71
+ loaded from a CDN by the dashboard layouts.
72
+
73
+ ## [1.0.0] - 2026-03-15
74
+
75
+ ### Added
76
+
77
+ - **Dashboard** mounted as a Rails engine at `/i18n`, with translator authentication.
78
+ Browse translations by language (database mode) or by file (file mode), edit
79
+ key/value pairs in a table UI, and download every locale as a ZIP archive.
80
+ - **Live UI** in-page editing: a floating toolbar and popover editor for translated
81
+ text, covering view helpers, form submit buttons, and controller-level translations
82
+ such as flash messages.
83
+ - **Two storage modes** — YAML files, or PostgreSQL JSONB behind a chained I18n
84
+ backend with Rails-cache, request-level and in-memory caching.
85
+ - **Locale sync** — compare two locales and copy missing keys between them.
86
+ - **Import tasks** for loading existing YAML files into database mode.
87
+ - **Install generator** that creates the initializer, installs migrations, mounts the
88
+ engine and creates the first translator account.
89
+ - Edit mode persists across page navigations via `localStorage`, and callers can opt
90
+ out per key with `t("some.key", i18n_onair: false)`.
91
+
92
+ [Unreleased]: https://github.com/tarqmamdouh/rails-i18n-onair/compare/v1.0.4...HEAD
93
+ [1.0.4]: https://github.com/tarqmamdouh/rails-i18n-onair/compare/v1.0.3...v1.0.4
94
+ [1.0.3]: https://github.com/tarqmamdouh/rails-i18n-onair/compare/v1.0.0...v1.0.3
95
+ [1.0.0]: https://github.com/tarqmamdouh/rails-i18n-onair/releases/tag/v1.0.0
data/README.md CHANGED
@@ -558,6 +558,29 @@ bundle exec rspec
558
558
 
559
559
  The test suite uses SQLite in-memory — no PostgreSQL needed locally. For a production-parity test of the JSONB features, set `DATABASE_URL` to a Postgres instance before running specs.
560
560
 
561
+ The Gemfile resolves different dependency sets per Ruby version, because Rails 8 and the current `sqlite3`, `minitest` and `nokogiri` releases all require Ruby >= 3.2 while the gem itself still supports 2.7.2:
562
+
563
+ | Ruby | Resolves to |
564
+ | --- | --- |
565
+ | >= 3.2 | Rails 8.x, sqlite3 2.x |
566
+ | 3.0 – 3.1 | Rails 7.0, sqlite3 1.x |
567
+ | 2.7 | Rails 7.0, sqlite3 < 1.7 |
568
+
569
+ The suite passes on all of these. `Gemfile.lock` is not checked in, so run `bundle install` after switching Ruby versions.
570
+
571
+ ### Releasing
572
+
573
+ ```bash
574
+ bin/release patch # 1.0.2 -> 1.0.3
575
+ bin/release minor # 1.0.2 -> 1.1.0
576
+ bin/release 1.2.3 # explicit version
577
+ bin/release patch --dry-run # run the checks, change nothing
578
+ ```
579
+
580
+ The script runs the preflight checks (clean tree, on `main`, tag not already taken, RSpec green), bumps `lib/rails_i18n_onair/version.rb`, moves the CHANGELOG's `Unreleased` entries into a dated section, then commits, tags and pushes.
581
+
582
+ It stops there: publishing is handled by `.github/workflows/gem-push.yml`, which builds the gem and pushes it to GitHub Packages and RubyGems when a `v*` tag lands. Bumping the version before tagging is the part that must not be skipped — RubyGems rejects a re-push of an existing version, so a tag placed without a version bump fails at the publish step.
583
+
561
584
  ---
562
585
 
563
586
  ## Contributing
@@ -16,6 +16,7 @@ module RailsI18nOnair
16
16
  # (DB stores data as { "en" => { "user" => { "name" => "..." } } })
17
17
  full_key = "#{locale}.#{key}"
18
18
 
19
+ @save_error = nil
19
20
  success = if RailsI18nOnair.configuration.database_mode?
20
21
  update_database_translation(locale, full_key, value)
21
22
  else
@@ -25,12 +26,20 @@ module RailsI18nOnair
25
26
  if success
26
27
  render json: { status: "ok", key: key, locale: locale, value: value }
27
28
  else
28
- render json: { error: "Failed to save translation" }, status: :unprocessable_entity
29
+ render json: { error: @save_error || "Failed to save translation" }, status: :unprocessable_entity
29
30
  end
30
31
  end
31
32
 
32
33
  private
33
34
 
35
+ # Unauthenticated API calls get JSON 401 instead of a login redirect —
36
+ # the Live UI script can then show a "session expired" message.
37
+ def authenticate_translator!
38
+ return if current_translator
39
+
40
+ render json: { error: "Session expired — please sign in again" }, status: :unauthorized
41
+ end
42
+
34
43
  # Strip region/variant from locale: "en_FRA" → "en", "pt-BR" → "pt"
35
44
  # Translation files use language-only keys (en.yml, fr.yml, etc.)
36
45
  def normalize_locale(locale)
@@ -47,13 +56,18 @@ module RailsI18nOnair
47
56
  record = RailsI18nOnair::Translation.load_locale(locale)
48
57
  return false unless record
49
58
 
50
- record.set_translation(full_key, value)
59
+ existing = record.get_translation(full_key)
60
+ if existing.is_a?(Hash)
61
+ @save_error = "\"#{params[:key]}\" holds nested translations — edit a specific subkey instead"
62
+ return false
63
+ end
51
64
 
52
- # Bust cache so the new value is served immediately
53
- if I18n.backend.respond_to?(:reload!)
54
- I18n.backend.reload!
65
+ unless record.set_translation(full_key, value)
66
+ @save_error = "Key path conflicts with an existing translation"
67
+ return false
55
68
  end
56
69
 
70
+ reload_translations(locale)
57
71
  true
58
72
  rescue StandardError => e
59
73
  Rails.logger&.error "RailsI18nOnair LiveUI: DB save failed — #{e.message}"
@@ -62,26 +76,70 @@ module RailsI18nOnair
62
76
 
63
77
  def update_file_translation(locale, _full_key, value)
64
78
  key_path = params[:key]
79
+ keys = [locale, *key_path.split(".")].map(&:to_s)
65
80
  file_manager = RailsI18nOnair::FileManager.new
66
- filename = "#{locale}.yml"
81
+
82
+ # Update the file that already defines this key (devise.en.yml,
83
+ # models/en.yml, …); fall back to the base <locale>.yml.
84
+ filename = locate_file_for_key(file_manager, locale, keys) || "#{locale}.yml"
67
85
  content = file_manager.read_file(filename)
68
86
  return false unless content
69
87
 
70
88
  data = YAML.safe_load(content, permitted_classes: [Symbol], aliases: true) || {}
71
89
  data = {} unless data.is_a?(Hash)
72
90
 
91
+ existing = keys.reduce(data) { |h, k| h.is_a?(Hash) ? h[k] : nil }
92
+ if existing.is_a?(Hash)
93
+ @save_error = "\"#{key_path}\" holds nested translations — edit a specific subkey instead"
94
+ return false
95
+ end
96
+
73
97
  # Navigate to the correct nested key and update the value
74
- keys = [locale, *key_path.split(".")].map(&:to_s)
75
- last = keys.pop
76
- node = keys.reduce(data) { |h, k| h.is_a?(Hash) ? (h[k] ||= {}) : break }
77
- return false unless node.is_a?(Hash)
98
+ parent_keys = keys[0..-2]
99
+ node = parent_keys.reduce(data) { |h, k| h.is_a?(Hash) ? (h[k] ||= {}) : break }
100
+ unless node.is_a?(Hash)
101
+ @save_error = "Key path conflicts with an existing translation"
102
+ return false
103
+ end
78
104
 
79
- node[last] = value
80
- file_manager.write_file(filename, data.to_yaml)
105
+ node[keys.last] = value
106
+ return false unless file_manager.write_file(filename, data.to_yaml)
107
+
108
+ reload_translations(locale)
109
+ true
81
110
  rescue StandardError => e
82
111
  Rails.logger&.error "RailsI18nOnair LiveUI: file save failed — #{e.message}"
83
112
  false
84
113
  end
114
+
115
+ # Reload only the affected locale when the backend supports it
116
+ # (DatabaseBackend#reload_locale); otherwise fall back to a full reload
117
+ # so file-mode edits become visible without an app restart.
118
+ def reload_translations(locale)
119
+ backend = I18n.backend
120
+ backends = backend.respond_to?(:backends) ? backend.backends : [backend]
121
+ targeted = backends.find { |b| b.respond_to?(:reload_locale) }
122
+
123
+ if targeted
124
+ targeted.reload_locale(locale)
125
+ elsif backend.respond_to?(:reload!)
126
+ backend.reload!
127
+ end
128
+ rescue StandardError => e
129
+ Rails.logger.warn "RailsI18nOnair LiveUI: cache reload failed — #{e.message}"
130
+ end
131
+
132
+ def locate_file_for_key(file_manager, locale, keys)
133
+ file_manager.files_for_language(locale).find { |file|
134
+ content = file_manager.read_file(file[:filename])
135
+ next false unless content
136
+
137
+ data = YAML.safe_load(content, permitted_classes: [Symbol], aliases: true) rescue nil
138
+ next false unless data.is_a?(Hash)
139
+
140
+ !keys.reduce(data) { |h, k| h.is_a?(Hash) ? h[k] : nil }.nil?
141
+ }&.dig(:filename)
142
+ end
85
143
  end
86
144
  end
87
145
  end
@@ -11,13 +11,12 @@ module RailsI18nOnair
11
11
  recent_updates: RailsI18nOnair::Translation.order(updated_at: :desc).limit(5)
12
12
  }
13
13
  else
14
- locale_path = Rails.root.join(RailsI18nOnair.configuration.locale_files_path)
15
- yaml_files = Dir.glob("#{locale_path}/**/*.yml")
14
+ locale_files = RailsI18nOnair::FileManager.new.list_files
16
15
 
17
16
  @statistics = {
18
17
  locale_files_path: RailsI18nOnair.configuration.locale_files_path,
19
- file_count: yaml_files.count,
20
- files: yaml_files.map { |f| File.basename(f) }.sort,
18
+ file_count: locale_files.count,
19
+ files: locale_files,
21
20
  mode: :file
22
21
  }
23
22
  end
@@ -35,8 +35,11 @@ module RailsI18nOnair
35
35
 
36
36
  filename = "#{params[:locale]}.yml"
37
37
 
38
- # Convert nested form params back to hash
39
- translations_hash = reconstruct_hash_from_params(params[:translations] || {})
38
+ # Convert nested form params back to a hash, restoring the scalar types
39
+ # (integers, booleans, floats) the original YAML used — form fields
40
+ # arrive as strings.
41
+ original = @file_info[:content] ? (YAML.safe_load(@file_info[:content], permitted_classes: [Symbol], aliases: true) rescue nil) : nil
42
+ translations_hash = reconstruct_hash_from_params(params[:translations] || {}, original)
40
43
 
41
44
  # Validate that we have data
42
45
  if translations_hash.empty?
@@ -49,9 +52,6 @@ module RailsI18nOnair
49
52
  # Convert to YAML with proper formatting
50
53
  new_content = translations_hash.to_yaml
51
54
 
52
- # Log for debugging (remove in production)
53
- Rails.logger.info "Saving translations: #{translations_hash.inspect}"
54
-
55
55
  if @file_manager.write_file(filename, new_content)
56
56
  redirect_to locale_file_path(locale: params[:locale]), notice: "File updated successfully"
57
57
  else
@@ -76,7 +76,8 @@ module RailsI18nOnair
76
76
  if result[:errors].empty?
77
77
  redirect_to locale_files_path, notice: "Successfully imported #{result[:imported]} file(s) to database"
78
78
  else
79
- redirect_to locale_files_path, alert: "Import completed with errors: #{result[:errors].join(', ')}"
79
+ messages = result[:errors].map { |e| "#{e[:language]}: #{e[:error]}" }.join(", ")
80
+ redirect_to locale_files_path, alert: "Import completed with errors: #{messages}"
80
81
  end
81
82
  end
82
83
 
@@ -98,20 +99,44 @@ module RailsI18nOnair
98
99
  @file_info = @file_manager.get_file_info(filename)
99
100
  end
100
101
 
101
- def reconstruct_hash_from_params(params_hash)
102
+ def reconstruct_hash_from_params(params_hash, original = nil)
102
103
  # Rails nested params come as ActionController::Parameters or Hash
103
104
  # Convert to regular hash recursively
104
105
  result = {}
105
106
 
106
107
  params_hash.each do |key, value|
108
+ original_value = original.is_a?(Hash) ? (original[key] || original[key.to_s] || original[key.to_sym]) : nil
109
+
107
110
  if value.is_a?(ActionController::Parameters) || value.is_a?(Hash)
108
- result[key] = reconstruct_hash_from_params(value)
111
+ result[key] = reconstruct_hash_from_params(value, original_value)
109
112
  else
110
- result[key] = value
113
+ result[key] = coerce_scalar(value, original_value)
111
114
  end
112
115
  end
113
116
 
114
117
  result
115
118
  end
119
+
120
+ # Form fields arrive as strings — restore the original YAML scalar type
121
+ # (integer, float, boolean) when the value still parses to that type.
122
+ def coerce_scalar(new_value, original_value)
123
+ return new_value if original_value.nil? || original_value.is_a?(String) || !new_value.is_a?(String)
124
+
125
+ parsed = begin
126
+ YAML.safe_load(new_value)
127
+ rescue StandardError
128
+ new_value
129
+ end
130
+
131
+ same_type = parsed.class == original_value.class ||
132
+ (parsed.is_a?(Numeric) && original_value.is_a?(Numeric)) ||
133
+ (boolean?(parsed) && boolean?(original_value))
134
+
135
+ same_type ? parsed : new_value
136
+ end
137
+
138
+ def boolean?(value)
139
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
140
+ end
116
141
  end
117
142
  end
@@ -4,6 +4,9 @@ module RailsI18nOnair
4
4
 
5
5
  layout "rails_i18n_onair/authentication"
6
6
 
7
+ MAX_LOGIN_ATTEMPTS = 10
8
+ LOCKOUT_DURATION = 15.minutes
9
+
7
10
  before_action :redirect_if_authenticated, only: [:new, :create]
8
11
 
9
12
  def new
@@ -11,12 +14,23 @@ module RailsI18nOnair
11
14
  end
12
15
 
13
16
  def create
17
+ if login_throttled?
18
+ flash.now[:alert] = "Too many failed attempts — try again in a few minutes"
19
+ render :new, status: :too_many_requests
20
+ return
21
+ end
22
+
14
23
  translator = RailsI18nOnair::Translator.find_by(username: params[:username])
15
24
 
16
25
  if translator&.authenticate(params[:password])
26
+ clear_failed_logins
27
+ # Rotate the session id on sign-in (prevents session fixation) while
28
+ # keeping the host app's session data intact
29
+ request.session_options[:renew] = true
17
30
  session[:translator_id] = translator.id
18
31
  redirect_to main_app.rails_i18n_onair_path, notice: "Signed in successfully"
19
32
  else
33
+ register_failed_login
20
34
  flash.now[:alert] = "Invalid username or password"
21
35
  render :new, status: :unprocessable_entity
22
36
  end
@@ -34,5 +48,30 @@ module RailsI18nOnair
34
48
  redirect_to main_app.rails_i18n_onair_path
35
49
  end
36
50
  end
51
+
52
+ # Cache-backed failure counter per username+IP. With a null cache store
53
+ # (default in development) throttling is a no-op.
54
+ def login_throttled?
55
+ Rails.cache.read(login_throttle_key).to_i >= MAX_LOGIN_ATTEMPTS
56
+ rescue StandardError
57
+ false
58
+ end
59
+
60
+ def register_failed_login
61
+ attempts = Rails.cache.read(login_throttle_key).to_i + 1
62
+ Rails.cache.write(login_throttle_key, attempts, expires_in: LOCKOUT_DURATION)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ def clear_failed_logins
68
+ Rails.cache.delete(login_throttle_key)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ def login_throttle_key
74
+ "i18n_onair:login_attempts:#{params[:username].to_s.downcase}:#{request.remote_ip}"
75
+ end
37
76
  end
38
77
  end
@@ -51,7 +51,7 @@ module RailsI18nOnair
51
51
  RailsI18nOnair::Translation.pluck(:language).sort
52
52
  else
53
53
  file_manager = RailsI18nOnair::FileManager.new
54
- file_manager.list_files.map { |f| f[:language] }.sort
54
+ file_manager.list_files.map { |f| f[:language] }.uniq.sort
55
55
  end
56
56
  end
57
57
  end
@@ -18,6 +18,12 @@ module RailsI18nOnair
18
18
  File.basename(filename, ".yml").upcase
19
19
  end
20
20
 
21
+ # Route identifier for a locale file: the relative filename minus .yml
22
+ # ("en", "devise.en"). Only valid for editable (top-level) files.
23
+ def locale_file_param(file_info)
24
+ file_info[:filename].to_s.sub(/\.yml\z/, "")
25
+ end
26
+
21
27
  def render_yaml_tree(hash, level = 0)
22
28
  return "" unless hash.is_a?(Hash)
23
29
 
@@ -25,7 +25,8 @@ module RailsI18nOnair
25
25
  exists?(language: locale.to_s)
26
26
  end
27
27
 
28
- # Get specific translation key using JSONB query (leverages GIN index)
28
+ # Get a specific translation key. Loads the locale row (unique index on
29
+ # :language) and walks the JSONB document in Ruby.
29
30
  def lookup_key(locale, key_path)
30
31
  translation_record = find_by(language: locale.to_s)
31
32
  return nil unless translation_record
@@ -56,11 +57,19 @@ module RailsI18nOnair
56
57
  keys.reduce(translation) { |hash, key| hash&.dig(key) || hash&.dig(key.to_sym) }
57
58
  end
58
59
 
59
- # Set a translation value by key path
60
+ # Set a translation value by key path.
61
+ # Returns false when an intermediate node is not a Hash (the path passes
62
+ # through a leaf value) instead of raising.
60
63
  def set_translation(key_path, value)
61
64
  keys = key_path.split(".")
62
65
  last_key = keys.pop
63
- hash = keys.reduce(translation) { |h, key| h[key] ||= {} }
66
+ hash = keys.reduce(translation) do |h, key|
67
+ return false unless h.is_a?(Hash)
68
+
69
+ h[key] ||= {}
70
+ end
71
+ return false unless hash.is_a?(Hash)
72
+
64
73
  hash[last_key] = value
65
74
  save
66
75
  end
@@ -38,19 +38,22 @@
38
38
  <div class="card-body">
39
39
  <% if statistics[:files].any? %>
40
40
  <ul class="list-group list-group-flush">
41
- <% statistics[:files].each do |filename| %>
41
+ <% statistics[:files].each do |file| %>
42
42
  <li class="list-group-item d-flex justify-content-between align-items-center">
43
- <span><i class="bi bi-file-earmark-code me-2"></i><%= filename %></span>
44
- <div class="btn-group btn-group-sm" role="group">
45
- <%= link_to locale_file_path(locale: filename.sub('.yml', '')), class: "btn btn-outline-info" do %>
46
- <i class="bi bi-eye"></i> View
47
- <% end %>
48
- <%= link_to edit_locale_file_path(locale: filename.sub('.yml', '')), class: "btn btn-outline-primary" do %>
49
- <i class="bi bi-pencil"></i> Edit
50
- <% end %>
51
- </div>
43
+ <span><i class="bi bi-file-earmark-code me-2"></i><%= file[:filename] %></span>
44
+ <% if file[:editable] %>
45
+ <div class="btn-group btn-group-sm" role="group">
46
+ <%= link_to locale_file_path(locale: locale_file_param(file)), class: "btn btn-outline-info" do %>
47
+ <i class="bi bi-eye"></i> View
48
+ <% end %>
49
+ <%= link_to edit_locale_file_path(locale: locale_file_param(file)), class: "btn btn-outline-primary" do %>
50
+ <i class="bi bi-pencil"></i> Edit
51
+ <% end %>
52
+ </div>
53
+ <% else %>
54
+ <small class="text-muted">Edit via file system</small>
55
+ <% end %>
52
56
  </li>
53
-
54
57
  <% end %>
55
58
  </ul>
56
59
  <% else %>
@@ -4,7 +4,7 @@
4
4
  <button type="submit" form="translation-form" class="btn btn-success me-2">
5
5
  <i class="bi bi-check-circle me-1"></i> Save Changes
6
6
  </button>
7
- <%= link_to locale_file_path(locale: @file_info[:language]), class: "btn btn-outline-secondary" do %>
7
+ <%= link_to locale_file_path(locale: locale_file_param(@file_info)), class: "btn btn-outline-secondary" do %>
8
8
  <i class="bi bi-x-circle"></i> Cancel
9
9
  <% end %>
10
10
  </div>
@@ -20,8 +20,8 @@
20
20
  </button>
21
21
  </div>
22
22
  <div class="card-body">
23
- <%= form_with url: locale_file_path(locale: @file_info[:language]), method: :patch, local: true, id: "translation-form" do |f| %>
24
- <%= hidden_field_tag :locale, @file_info[:language] %>
23
+ <%= form_with url: locale_file_path(locale: locale_file_param(@file_info)), method: :patch, local: true, id: "translation-form" do |f| %>
24
+ <%= hidden_field_tag :locale, locale_file_param(@file_info) %>
25
25
  <%= hidden_field_tag :form_submitted, "true" %>
26
26
 
27
27
  <div class="table-responsive">
@@ -51,7 +51,7 @@
51
51
  =begin%>
52
52
  <div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4 pt-3 border-top">
53
53
  <%= submit_tag "Save Changes", class: "btn btn-success btn-lg", id: "save-btn" %>
54
- <%= link_to "Cancel", locale_file_path(locale: @file_info[:language]), class: "btn btn-outline-secondary btn-lg" %>
54
+ <%= link_to "Cancel", locale_file_path(locale: locale_file_param(@file_info)), class: "btn btn-outline-secondary btn-lg" %>
55
55
  </div>
56
56
  <%
57
57
  =end%>
@@ -46,14 +46,18 @@
46
46
  </small>
47
47
  </td>
48
48
  <td>
49
- <div class="btn-group btn-group-sm" role="group">
50
- <%= link_to locale_file_path(locale: file[:language]), class: "btn btn-outline-info" do %>
51
- <i class="bi bi-eye"></i> View
52
- <% end %>
53
- <%= link_to edit_locale_file_path(locale: file[:language]), class: "btn btn-outline-primary" do %>
54
- <i class="bi bi-pencil"></i> Edit
55
- <% end %>
56
- </div>
49
+ <% if file[:editable] %>
50
+ <div class="btn-group btn-group-sm" role="group">
51
+ <%= link_to locale_file_path(locale: locale_file_param(file)), class: "btn btn-outline-info" do %>
52
+ <i class="bi bi-eye"></i> View
53
+ <% end %>
54
+ <%= link_to edit_locale_file_path(locale: locale_file_param(file)), class: "btn btn-outline-primary" do %>
55
+ <i class="bi bi-pencil"></i> Edit
56
+ <% end %>
57
+ </div>
58
+ <% else %>
59
+ <small class="text-muted">Edit via file system</small>
60
+ <% end %>
57
61
  </td>
58
62
  </tr>
59
63
  <% end %>
@@ -1,7 +1,7 @@
1
1
  <div class="d-flex justify-content-between align-items-center mb-4">
2
2
  <h3><i class="bi bi-file-earmark-code"></i> <%= @file_info[:filename] %></h3>
3
3
  <div>
4
- <%= link_to edit_locale_file_path(locale: @file_info[:language]), class: "btn btn-primary me-2" do %>
4
+ <%= link_to edit_locale_file_path(locale: locale_file_param(@file_info)), class: "btn btn-primary me-2" do %>
5
5
  <i class="bi bi-pencil"></i> Edit
6
6
  <% end %>
7
7
  <%= link_to locale_files_path, class: "btn btn-outline-secondary" do %>
data/config/routes.rb CHANGED
@@ -11,7 +11,10 @@ RailsI18nOnair::Engine.routes.draw do
11
11
  resources :translations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
12
12
 
13
13
  # File mode: Locale file management
14
- resources :locale_files, only: [:index, :show, :edit, :update], param: :locale do
14
+ # The constraint lets dotted identifiers (devise.en) through — the default
15
+ # segment pattern would otherwise treat ".en" as a format.
16
+ resources :locale_files, only: [:index, :show, :edit, :update], param: :locale,
17
+ constraints: { locale: /[^\/]+/ }, format: false do
15
18
  collection do
16
19
  post :sync
17
20
  post :reload
@@ -2,11 +2,12 @@ class CreateTranslations < ActiveRecord::Migration[6.0]
2
2
  def change
3
3
  create_table :rails_i18n_onair_translations do |t|
4
4
  t.string :language, null: false
5
- t.json :translation, null: false, default: {}
5
+ t.jsonb :translation, null: false, default: {}
6
6
 
7
7
  t.timestamps
8
8
  end
9
9
 
10
10
  add_index :rails_i18n_onair_translations, :language, unique: true
11
+ add_index :rails_i18n_onair_translations, :translation, using: :gin
11
12
  end
12
13
  end
@@ -53,9 +53,7 @@ module RailsI18nOnair
53
53
  @translations = {}
54
54
  end
55
55
 
56
- if defined?(Rails) && Rails.cache
57
- Rails.cache.delete_matched("i18n_onair:*")
58
- end
56
+ clear_rails_cache
59
57
 
60
58
  if options[:warm_up] != false && defined?(I18n)
61
59
  load_locale(I18n.default_locale)
@@ -161,6 +159,23 @@ module RailsI18nOnair
161
159
 
162
160
  private
163
161
 
162
+ # Not all cache stores support delete_matched (memcached raises
163
+ # NotImplementedError) — fall back to deleting the known keys.
164
+ def clear_rails_cache
165
+ return unless defined?(Rails) && Rails.cache
166
+
167
+ begin
168
+ Rails.cache.delete_matched("i18n_onair:*")
169
+ rescue NotImplementedError
170
+ Rails.cache.delete("i18n_onair:available_locales")
171
+ fetch_available_locales.each do |locale|
172
+ Rails.cache.delete("i18n_onair:locale:#{locale}")
173
+ end
174
+ end
175
+ rescue StandardError
176
+ nil
177
+ end
178
+
164
179
  def fetch_available_locales
165
180
  if defined?(RailsI18nOnair::Translation)
166
181
  RailsI18nOnair::Translation.pluck(:language).map(&:to_sym)