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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +23 -0
- data/app/controllers/rails_i18n_onair/api/live_translations_controller.rb +70 -12
- data/app/controllers/rails_i18n_onair/dashboard_controller.rb +3 -4
- data/app/controllers/rails_i18n_onair/locale_files_controller.rb +34 -9
- data/app/controllers/rails_i18n_onair/sessions_controller.rb +39 -0
- data/app/controllers/rails_i18n_onair/sync_controller.rb +1 -1
- data/app/helpers/rails_i18n_onair/application_helper.rb +6 -0
- data/app/models/rails_i18n_onair/translation.rb +12 -3
- data/app/views/rails_i18n_onair/dashboard/_file_mode.html.erb +14 -11
- data/app/views/rails_i18n_onair/locale_files/edit.html.erb +4 -4
- data/app/views/rails_i18n_onair/locale_files/index.html.erb +12 -8
- data/app/views/rails_i18n_onair/locale_files/show.html.erb +1 -1
- data/config/routes.rb +4 -1
- data/db/migrate/create_translations.rb +2 -1
- data/lib/rails_i18n_onair/backend.rb +18 -3
- data/lib/rails_i18n_onair/engine.rb +1 -1
- data/lib/rails_i18n_onair/file_manager.rb +64 -26
- data/lib/rails_i18n_onair/importer.rb +19 -14
- data/lib/rails_i18n_onair/live_ui/controller_helper.rb +16 -3
- data/lib/rails_i18n_onair/live_ui/middleware.rb +121 -7
- data/lib/rails_i18n_onair/live_ui/script.rb +49 -14
- data/lib/rails_i18n_onair/live_ui/translation_helper.rb +65 -12
- data/lib/rails_i18n_onair/live_ui/unwrapper.rb +50 -0
- data/lib/rails_i18n_onair/live_ui.rb +1 -0
- data/lib/rails_i18n_onair/sync_service.rb +34 -2
- data/lib/rails_i18n_onair/version.rb +1 -1
- metadata +17 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19ed757e0ff55cdfe24a433b697f40fae925c5f9ab9984fc87eea890ac327ef7
|
|
4
|
+
data.tar.gz: ef2239c23a898371ac91c9b3533e24586b30ec5e016a21b4ce8d652ed387a5c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
node
|
|
77
|
-
|
|
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
|
-
|
|
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:
|
|
20
|
-
files:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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 |
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
24
|
-
<%= hidden_field_tag :locale, @file_info
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|