moirai 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +8 -4
  3. data/CHANGELOG.md +16 -0
  4. data/Gemfile +12 -1
  5. data/README.md +20 -2
  6. data/app/assets/config/moirai_manifest.js +0 -1
  7. data/app/assets/javascripts/moirai_translation_controller.js +32 -0
  8. data/app/controllers/moirai/translation_files_controller.rb +41 -16
  9. data/app/controllers/moirai/translations_controller.rb +7 -0
  10. data/app/models/moirai/change.rb +15 -0
  11. data/app/models/moirai/key_finder.rb +55 -0
  12. data/app/models/moirai/translation.rb +9 -5
  13. data/app/models/moirai/translation_dumper.rb +40 -13
  14. data/app/views/layouts/moirai/application.html.erb +0 -1
  15. data/app/views/moirai/translation_files/_form.html.erb +3 -3
  16. data/app/views/moirai/translation_files/index.html.erb +1 -1
  17. data/app/views/moirai/translation_files/show.html.erb +5 -5
  18. data/app/views/moirai/translations/index.html.erb +7 -0
  19. data/bin/check +3 -1
  20. data/config/routes.rb +2 -1
  21. data/lib/generators/moirai/install_generator.rb +1 -2
  22. data/lib/generators/moirai/migration_generator.rb +5 -1
  23. data/lib/generators/moirai/templates/make_moirai_translations_file_path_not_required.rb.erb +5 -0
  24. data/lib/i18n/backend/moirai.rb +1 -53
  25. data/lib/moirai/engine.rb +3 -6
  26. data/lib/moirai/pull_request_creator.rb +42 -33
  27. data/moirai.gemspec +1 -6
  28. metadata +9 -76
  29. data/app/assets/javascripts/moirai/application.js +0 -2
  30. data/app/assets/javascripts/moirai/controllers/moirai_translation_controller.js +0 -27
  31. data/app/assets/javascripts/moirai/stimulus/init.js +0 -5
  32. /data/lib/generators/moirai/templates/{migration.rb.erb → create_moirai_translations.rb.erb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f735406747923f4a2493a81628e4cd88cf6f54d926fb31448ca5dab4ae70a8b9
4
- data.tar.gz: 40f69e72824a96f13fe4e67b753a36617a745cdca04c61e691c7cc7877c55b96
3
+ metadata.gz: 5cd2eae769961645bc1baa4295ed58bf261beb38a48e934c1d3d38dd083f033a
4
+ data.tar.gz: fd3dddd2520c7f06987d814fec2986c5e02ac113748d39a1117315296cc0aaf3
5
5
  SHA512:
6
- metadata.gz: 19eeb5b122adcd1f46fbfee0a39440f5d6ef9536bf28f8486f36fed40506d61449793adc6681cd3ce5eb35c10f9b5021928f97494e0c7063a5736fc8c0a2ea90
7
- data.tar.gz: 9e3d8298eac29bca6008af867b57f8e663d9128c07bccf531c400c2c2889109b15515b676b6bbd0d85144fae119cc087a5ad3bb6cec83564edae67907b73543c
6
+ metadata.gz: 5ae7c1af2f697ea2f7fad123f4c2451e3fa9ba86ffb048ae0b6846c2002bc532e50f762ecf000e32d76f6631d62ebd9f211288b4dca081e61e9d1bbcb05fd6ef
7
+ data.tar.gz: f113aa608adfe9b5fae4a073f2d23936bb30090b3fe9d544dfc7d9421663244a78e3350b7218f988fa9c413fe6aef6a6ab154dd79cb5d0160dd026205bdac30d
@@ -1,11 +1,12 @@
1
1
  name: Test & lint
2
- on: [push, pull_request]
2
+ on: [push]
3
3
 
4
4
  env:
5
5
  RAILS_ENV: test
6
6
  PGHOST: localhost
7
7
  PGUSER: postgres
8
-
8
+ MOIRAI_GITHUB_REPO_NAME: ${{ secrets.MOIRAI_GITHUB_REPO_NAME }}
9
+ MOIRAI_GITHUB_ACCESS_TOKEN: ${{ secrets.MOIRAI_GITHUB_ACCESS_TOKEN }}
9
10
  jobs:
10
11
  tests:
11
12
  name: Test
@@ -14,7 +15,7 @@ jobs:
14
15
  fail-fast: false
15
16
  matrix:
16
17
  os: [ubuntu-latest]
17
- ruby: [3.1, 3.2, 3.3]
18
+ ruby: [3.3]
18
19
 
19
20
  runs-on: ${{ matrix.os }}
20
21
 
@@ -32,7 +33,10 @@ jobs:
32
33
  run: bundle install --jobs 4 --retry 3
33
34
 
34
35
  - name: Run tests
35
- run: bin/check
36
+ run: bundle exec rails test
37
+
38
+ - name: Run system tests
39
+ run: bundle exec rails test:system
36
40
 
37
41
 
38
42
  lint:
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
+ ## Unreleased
2
+
3
+ ## 0.2.0
4
+
5
+ * Support for strings coming from gems ([@coorasse][])
6
+ * Support for new strings (not yet translated) ([@coorasse][])
7
+
8
+ ## 0.1.1
9
+
10
+ * Review Stimulus controller ([@coorasse][])
11
+
1
12
  ## 0.1.0
2
13
 
3
14
  * Gem structure created ([@oliveranthony17][])
15
+ * Database tables created ([@oliveranthony17][])
16
+ * Pull request creation ([@oliveranthony17][])
17
+ * Dummy app for tests ([@coorasse][])
18
+ * CRUD for translations ([@CuddlyBunion341][])
19
+ * Inline editing ([@CuddlyBunion341][])
4
20
 
5
21
  [@coorasse]: https://github.com/coorasse
6
22
 
data/Gemfile CHANGED
@@ -4,14 +4,25 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
+ gem "rails", "~> 7.2.0"
8
+ gem "octokit", ">= 4.0"
9
+ gem "importmap-rails"
10
+ gem "stimulus-rails"
11
+
7
12
  group :development, :test do
8
13
  gem "puma"
9
14
  gem "sqlite3"
10
- gem "minitest"
11
15
  gem "rake"
16
+ gem "dotenv"
17
+ gem "minitest"
12
18
  gem "standard"
13
19
  gem "appraisal"
14
20
  gem "better_errors"
15
21
  gem "binding_of_caller"
16
22
  gem "sprockets-rails"
17
23
  end
24
+
25
+ group :test do
26
+ gem "capybara"
27
+ gem "selenium-webdriver"
28
+ end
data/README.md CHANGED
@@ -46,6 +46,7 @@ Open a file, change the value of translations, and press ENTER to update the tra
46
46
  ### Inline editing
47
47
 
48
48
  By default, inline editing is disabled. To enable it, set the `moirai=true` query parameter in the URL.
49
+
49
50
  If you want to only allow specific users to perform inline editing, you can override the `moirai_edit_enabled?` method in your application helper.
50
51
 
51
52
  ```ruby
@@ -56,6 +57,18 @@ module ApplicationHelper
56
57
  end
57
58
  ```
58
59
 
60
+ You also need to have the moirai_translations_controller.js Stimulus Controller initialized.
61
+
62
+ If you use importmaps:
63
+
64
+ Pin the controller in `config/importmap.rb`
65
+
66
+ ```ruby
67
+ pin "controllers/moirai_translation_controller", to: "moirai_translation_controller.js"
68
+ ```
69
+
70
+ If you’re unsure about all the possible configuration options, you can simply copy and paste the stimulus controller into your app as a fallback.
71
+
59
72
  ### Automatic PR creation with Octokit (**optional**)
60
73
 
61
74
  If you would like Moirai to automatically create a pull request on GitHub to keep translations synchronized with the codebase,
@@ -153,6 +166,12 @@ end
153
166
 
154
167
  4. Set your environment variables using the newly created `.env` file.
155
168
 
169
+ You will need a repository to test against and a token. Generate a new Fine-GRained Personal access token and give the necessary permissions to your repository.
170
+ See the image below as an example:
171
+
172
+ ![](docs/github_settings.png)
173
+
174
+
156
175
  5. Run the tests:
157
176
  ```bash
158
177
  bin/check
@@ -169,8 +188,7 @@ end
169
188
  * Support for interpolation
170
189
  * Support for count variants
171
190
  * Better inline editing tool
172
- * Performance of translations lookup
173
- * Support for translations and strings coming from other gems
191
+ * Support for fallbacks: it should detect when a fallback string is in use and prevent attempts to override its value.
174
192
 
175
193
  ## License
176
194
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,2 +1 @@
1
1
  //= link_directory ../stylesheets/moirai .css
2
- //= link_directory ../javascripts/moirai .js
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class MoiraiTranslationController extends Controller {
4
+ static values = {
5
+ key: String,
6
+ locale: String
7
+ }
8
+
9
+ click(event) {
10
+ event.preventDefault()
11
+ }
12
+
13
+ submit(event) {
14
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').content
15
+
16
+ fetch('/moirai/translation_files', {
17
+ method: 'POST',
18
+ headers: {
19
+ 'X-CSRF-Token': csrfToken,
20
+ 'Content-Type': 'application/json',
21
+ 'Accept': 'application/json'
22
+ },
23
+ body: JSON.stringify({
24
+ translation: {
25
+ key: this.keyValue,
26
+ locale: this.localeValue,
27
+ value: event.target.innerText
28
+ }
29
+ })
30
+ });
31
+ }
32
+ }
@@ -14,13 +14,13 @@ module Moirai
14
14
  end
15
15
 
16
16
  def show
17
- @translation_keys = @file_handler.parse_file(@decoded_path)
17
+ @translation_keys = @file_handler.parse_file(@file_path)
18
+ @locale = @file_handler.get_first_key(@file_path)
19
+ @translations = Moirai::Translation.by_file_path(@file_path)
18
20
  end
19
21
 
20
22
  def create_or_update
21
- if (translation = Translation.find_by(file_path: translation_params[:file_path],
22
- key: translation_params[:key],
23
- locale: @file_handler.get_first_key(translation_params[:file_path])))
23
+ if (translation = Translation.find_by(key: translation_params[:key], locale: translation_params[:locale]))
24
24
  handle_update(translation)
25
25
  else
26
26
  handle_create
@@ -28,7 +28,7 @@ module Moirai
28
28
  end
29
29
 
30
30
  def open_pr
31
- flash.notice = "I created an amazing PR"
31
+ flash.notice = "I created an amazing Pull Request"
32
32
  changes = Moirai::TranslationDumper.new.call
33
33
  Moirai::PullRequestCreator.new.create_pull_request(changes)
34
34
  redirect_back_or_to(root_path)
@@ -37,8 +37,7 @@ module Moirai
37
37
  private
38
38
 
39
39
  def handle_update(translation)
40
- translation_from_file = @file_handler.parse_file(translation_params[:file_path])
41
- if translation_from_file[translation.key] == translation_params[:value] || translation_params[:value].blank?
40
+ if translation_params[:value].blank? || translation_same_as_current?
42
41
  translation.destroy
43
42
  flash.notice = "Translation #{translation.key} was successfully deleted."
44
43
  redirect_to_translation_file(translation.file_path)
@@ -51,26 +50,36 @@ module Moirai
51
50
  flash.alert = translation.errors.full_messages.join(", ")
52
51
  end
53
52
 
54
- redirect_to_translation_file(translation.file_path)
53
+ success_response(translation)
55
54
  end
56
55
 
57
56
  def handle_create
58
- translation_from_file = @file_handler.parse_file(translation_params[:file_path])
59
- if translation_from_file[translation_params[:key]] == translation_params[:value]
57
+ if translation_same_as_current?
60
58
  flash.alert = "Translation #{translation_params[:key]} already exists."
61
- redirect_to_translation_file(translation_params[:file_path])
59
+ redirect_back_or_to moirai_translation_files_path, status: :unprocessable_entity
62
60
  return
63
61
  end
64
62
 
65
63
  translation = Translation.new(translation_params)
66
- translation.locale = @file_handler.get_first_key(translation_params[:file_path])
64
+
67
65
  if translation.save
68
66
  flash.notice = "Translation #{translation.key} was successfully created."
67
+ success_response(translation)
69
68
  else
70
69
  flash.alert = translation.errors.full_messages.join(", ")
70
+ redirect_back_or_to moirai_translation_files_path, status: :unprocessable_entity
71
71
  end
72
+ end
72
73
 
73
- redirect_to_translation_file(translation.file_path)
74
+ def success_response(translation)
75
+ respond_to do |format|
76
+ format.json do
77
+ render json: {}
78
+ end
79
+ format.all do
80
+ redirect_to_translation_file(translation.file_path)
81
+ end
82
+ end
74
83
  end
75
84
 
76
85
  def redirect_to_translation_file(file_path)
@@ -78,16 +87,32 @@ module Moirai
78
87
  end
79
88
 
80
89
  def set_translation_file
81
- @file_path = @file_handler.file_hashes[params[:id]]
82
- @decoded_path = CGI.unescape(@file_path)
90
+ @file_path = @file_handler.file_hashes[params[:hashed_file_path]]
91
+ if @file_path.nil?
92
+ flash.alert = "File not found"
93
+ redirect_to moirai_translation_files_path, status: :not_found
94
+ end
83
95
  end
84
96
 
85
97
  def translation_params
86
- params.require(:translation).permit(:key, :locale, :value, :file_path)
98
+ params.require(:translation).permit(:key, :locale, :value)
87
99
  end
88
100
 
89
101
  def load_file_handler
90
102
  @file_handler = Moirai::TranslationFileHandler.new
91
103
  end
104
+
105
+ # TODO: to resolve the last point of the TODOs we could look at the current translation (without moirai)
106
+ # I quickly tried but I need to use the original backend instead of the moirai one
107
+ # The problem is that if we set a value that is the same as currently being used via fallback,
108
+ # it will create an entry in the database, and afterwards will try to add it in the PR, which we don't want.
109
+ def translation_same_as_current?
110
+ file_paths = KeyFinder.new.file_paths_for(translation_params[:key], locale: translation_params[:locale])
111
+
112
+ return false if file_paths.empty?
113
+ return false unless file_paths.all? { |file_path| File.exist?(file_path) }
114
+
115
+ translation_params[:value] == @file_handler.parse_file(file_paths.first)[translation_params[:key]]
116
+ end
92
117
  end
93
118
  end
@@ -0,0 +1,7 @@
1
+ module Moirai
2
+ class TranslationsController < ApplicationController
3
+ def index
4
+ @translations = Translation.order(created_at: :desc).pluck(:locale, :key, :value)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Prepares a certain file to be changefd in a Pull Request.
4
+ # It takes care of adjusting the file_path and content
5
+ module Moirai
6
+ class Change
7
+ attr_reader :file_path, :content
8
+
9
+ def initialize(file_path, content)
10
+ @file_path = file_path
11
+ @file_path = file_path.to_s.start_with?("./") ? file_path : "./#{file_path}"
12
+ @content = content.to_s.end_with?("\n") ? content : "#{content}\n"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moirai
4
+ class KeyFinder
5
+ include I18n::Backend::Base
6
+
7
+ # Mutex to ensure that concurrent translations loading will be thread-safe
8
+ MUTEX = Mutex.new
9
+
10
+ def initialize
11
+ load_translations
12
+ end
13
+
14
+ # TODO: remove locale default
15
+ # Returns all the file_paths where the key is found, including gems.
16
+ def file_paths_for(key, locale: I18n.locale)
17
+ return [] if key.blank?
18
+
19
+ locale ||= I18n.locale
20
+ moirai_translations[locale.to_sym].select do |_filename, data|
21
+ data.dig(*key.split(".")).present?
22
+ end.map { |k, _| k }.sort { |file_path| file_path.start_with?(Rails.root.to_s) ? 0 : 1 }
23
+ end
24
+
25
+ def store_moirai_translations(filename, locale, data, options)
26
+ moirai_translations[locale] ||= Concurrent::Hash.new
27
+
28
+ locale = locale.to_sym
29
+ moirai_translations[locale] ||= Concurrent::Hash.new
30
+ moirai_translations[locale][filename] = data.with_indifferent_access
31
+ end
32
+
33
+ def moirai_translations(do_init: false)
34
+ @moirai_translations ||= Concurrent::Hash.new do |h, k|
35
+ MUTEX.synchronize do
36
+ h[k] = Concurrent::Hash.new
37
+ end
38
+ end
39
+ end
40
+
41
+ def load_file(filename)
42
+ type = File.extname(filename).tr(".", "").downcase
43
+ raise I18n::UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true)
44
+ data, keys_symbolized = send(:"load_#{type}", filename)
45
+ unless data.is_a?(Hash)
46
+ raise I18n::InvalidLocaleData.new(filename, "expects it to return a hash, but does not")
47
+ end
48
+ data.each do |locale, d|
49
+ store_moirai_translations(filename, locale, d || {}, skip_symbolize_keys: keys_symbolized)
50
+ end
51
+
52
+ data
53
+ end
54
+ end
55
+ end
@@ -2,13 +2,17 @@
2
2
 
3
3
  module Moirai
4
4
  class Translation < Moirai::ApplicationRecord
5
- validates_presence_of :key, :locale, :file_path
6
- validate :file_path_must_exist
5
+ validates_presence_of :key, :locale, :value
7
6
 
8
- private
7
+ # what if the key is present in multiple file_paths?
8
+ def file_path
9
+ @key_finder ||= KeyFinder.new
10
+ @key_finder.file_paths_for(key, locale: locale).first
11
+ end
9
12
 
10
- def file_path_must_exist
11
- errors.add(:file_path, "must exist") unless file_path && File.exist?(file_path)
13
+ def self.by_file_path(file_path)
14
+ key_finder = KeyFinder.new
15
+ all.select { |translation| key_finder.file_paths_for(translation.key, locale: translation.locale).include?(file_path) }
12
16
  end
13
17
  end
14
18
  end
@@ -1,24 +1,51 @@
1
1
  module Moirai
2
2
  class TranslationDumper
3
+ def initialize
4
+ @key_finder = KeyFinder.new
5
+ end
6
+
7
+ # @return Array[Moirai::Change]
3
8
  def call
4
- project_root = Rails.root.to_s
5
- Moirai::Translation.pluck(:file_path).uniq.map do |file_path|
6
- absolute_file_path = File.expand_path(file_path, project_root)
7
- next unless absolute_file_path.start_with?(project_root)
8
-
9
- updated_file_contents = get_updated_file_contents(file_path)
10
- {
11
- file_path: file_path.sub(project_root, "."),
12
- content: updated_file_contents
13
- }
14
- end.compact
9
+ translations_by_file_path = group_translations_by_file_path
10
+ changes = []
11
+ translations_by_file_path.each do |file_path, translations|
12
+ relative_file_path = Pathname.new(file_path).relative_path_from(Rails.root)
13
+ changes << Change.new(relative_file_path, get_updated_file_contents(file_path, translations))
14
+ end
15
+ changes
16
+ end
17
+
18
+ def best_file_path_for(key, locale)
19
+ file_paths = @key_finder.file_paths_for(key, locale: locale)
20
+ file_paths.filter! { |p| p.start_with?(Rails.root.to_s) }
21
+ if file_paths.any?
22
+ file_paths.first
23
+ elsif key.split(".").size > 1
24
+ parent_key = key.split(".")[0..-2].join(".")
25
+ best_file_path_for(parent_key, locale)
26
+ else
27
+ "./config/locales/#{locale}.yml"
28
+ end
15
29
  end
16
30
 
17
31
  private
18
32
 
19
- def get_updated_file_contents(file_path)
20
- translations = Moirai::Translation.where(file_path: file_path)
33
+ def group_translations_by_file_path
34
+ translations_grouped_by_file_path = {}
35
+ Moirai::Translation.order(created_at: :asc).each do |translation|
36
+ file_path = best_file_path_for(translation.key, translation.locale)
37
+ absolute_file_path = File.expand_path(file_path, Rails.root)
38
+
39
+ # skip file paths that don't belong to the project
40
+ next unless absolute_file_path.to_s.start_with?(Rails.root.to_s)
41
+
42
+ translations_grouped_by_file_path[absolute_file_path] ||= []
43
+ translations_grouped_by_file_path[absolute_file_path] << translation
44
+ end
45
+ translations_grouped_by_file_path
46
+ end
21
47
 
48
+ def get_updated_file_contents(file_path, translations)
22
49
  yaml = YAML.load_file(file_path)
23
50
 
24
51
  translations.each do |translation|
@@ -6,7 +6,6 @@
6
6
  <%= csp_meta_tag %>
7
7
 
8
8
  <%= stylesheet_link_tag "moirai/application", media: "all" %>
9
- <%= javascript_include_tag "moirai/application", "data-turoblinks-track": "reload", type: "module" %>
10
9
  <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
11
10
  <link rel="icon" href="data:image/png;base64,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">
12
11
  </head>
@@ -1,9 +1,9 @@
1
1
  <span
2
2
  contenteditable
3
- data-action="blur->moirai-translation#submit click->moirai-translation#click"
3
+ data-action="blur->moirai-translation#submit click->moirai-translation#click"
4
4
  style="border: 1px dashed #1d9f74; min-width: 30px; display: inline-block;"
5
- data-key="<%= key %>"
6
- data-file-path="<%= file_path %>"
5
+ data-moirai-translation-key-value="<%= key %>"
6
+ data-moirai-translation-locale-value="<%= locale %>"
7
7
  data-controller="moirai-translation">
8
8
  <%= value %>
9
9
  </span>
@@ -2,7 +2,7 @@
2
2
  <h1>Translation files</h1>
3
3
 
4
4
  <% if Moirai::PullRequestCreator.available? %>
5
- <%= button_to "Create or update PR", moirai_open_pr_path %>
5
+ <%= button_to "Create or update Pull Request", moirai_open_pr_path %>
6
6
  <% else %>
7
7
  <p>PR creation is not available. Add the gem octokit to your gemfile to enable this feature</p>
8
8
  <% end %>
@@ -13,7 +13,7 @@
13
13
  </thead>
14
14
  <tbody>
15
15
  <% @translation_keys.each do |key, value| %>
16
- <% translation = Moirai::Translation.find_by(key: key, file_path: @decoded_path) %>
16
+ <% translation = @translations.find { |t| t.key == key } %>
17
17
 
18
18
  <tr>
19
19
  <td>
@@ -24,10 +24,10 @@
24
24
  <% end %>
25
25
  </td>
26
26
  <td>
27
- <%= form_for Moirai::Translation.new, url: moirai_create_or_update_translation_path do |f| %>
28
- <%= f.hidden_field :key, value: key %>
29
- <%= f.hidden_field :file_path, value: @decoded_path %>
30
- <%= f.text_field :value, value: translation&.value || value %>
27
+ <%= form_for translation&.presence || Moirai::Translation.new(key: key, locale: @locale, value: value), url: moirai_create_or_update_translation_path, method: :post do |f| %>
28
+ <%= f.hidden_field :key %>
29
+ <%= f.hidden_field :locale %>
30
+ <%= f.text_field :value %>
31
31
  <%= f.submit 'Update', style: 'display: none;' %>
32
32
  <% end %>
33
33
  </td>
@@ -0,0 +1,7 @@
1
+ <% @translations.each do |locale, key, value| %>
2
+ <div class="card">
3
+ <p><strong>Locale:</strong> <%=locale %></p>
4
+ <p><strong>Key:</strong> <%=key %></p>
5
+ <p><strong>Value:</strong> <%= value %></p>
6
+ </div>
7
+ <% end %>
data/bin/check CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
+ set -e
4
+
3
5
  bundle exec rails test
4
- #bundle exec rails test:system
6
+ bundle exec rails test:system
data/config/routes.rb CHANGED
@@ -3,7 +3,8 @@
3
3
  Moirai::Engine.routes.draw do
4
4
  root to: "translation_files#index"
5
5
 
6
- resources :translation_files, only: %i[index show], as: "moirai_translation_files"
6
+ resources :translations, only: %i[index]
7
+ resources :translation_files, only: %i[index show], as: "moirai_translation_files", param: :hashed_file_path
7
8
  post "/translation_files/open_pr", to: "translation_files#open_pr", as: "moirai_open_pr"
8
9
  post "/translation_files", to: "translation_files#create_or_update", as: "moirai_create_or_update_translation"
9
10
  end
@@ -11,8 +11,7 @@ module Moirai
11
11
  end
12
12
 
13
13
  def mount_engine
14
- route 'mount Moirai::Engine => "/moirai", as: "moirai"
15
- '
14
+ route 'mount Moirai::Engine => "/moirai", as: "moirai"'
16
15
  end
17
16
  end
18
17
  end
@@ -11,7 +11,11 @@ module Moirai
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  def create_migration_file
14
- migration_template "migration.rb.erb", "db/migrate/create_moirai_translations.rb", migration_version: migration_version
14
+ migration_template "create_moirai_translations.rb.erb", "db/migrate/create_moirai_translations.rb", migration_version: migration_version
15
+ end
16
+
17
+ def file_path_migration_file
18
+ migration_template "make_moirai_translations_file_path_not_required.rb.erb", "db/migrate/make_moirai_translations_file_path_not_required.rb", migration_version: migration_version
15
19
  end
16
20
 
17
21
  private
@@ -0,0 +1,5 @@
1
+ class MakeMoiraiTranslationsFilePathNotRequired < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ change_column_null :moirai_translations, :file_path, true
4
+ end
5
+ end
@@ -1,6 +1,6 @@
1
1
  module I18n
2
2
  module Backend
3
- class Moirai < I18n::Backend::Simple # TODO: no need to extend the simple one. It does too much
3
+ class Moirai < I18n::Backend::Simple
4
4
  # TODO: mega inefficient. we don't want to perform a SQL query for each key!
5
5
  def translate(locale, key, options = EMPTY_HASH)
6
6
  overridden_translation = ::Moirai::Translation.find_by(locale: locale, key: key)
@@ -8,58 +8,6 @@ module I18n
8
8
  overridden_translation.value
9
9
  end
10
10
  end
11
-
12
- def store_moirai_translations(filename, locale, data, options)
13
- moirai_translations[locale] ||= Concurrent::Hash.new
14
- flatten_data = flatten_hash(filename, data)
15
- flatten_data = Utils.deep_symbolize_keys(flatten_data) unless options.fetch(:skip_symbolize_keys, false)
16
- Utils.deep_merge!(moirai_translations[locale], flatten_data)
17
- end
18
-
19
- def moirai_translations(do_init: false)
20
- @moirai_translations ||= Concurrent::Hash.new do |h, k|
21
- MUTEX.synchronize do
22
- h[k] = Concurrent::Hash.new
23
- end
24
- end
25
- end
26
-
27
- def flatten_hash(filename, hash, parent_key = "", result = {})
28
- hash.each do |key, value|
29
- new_key = parent_key.empty? ? key.to_s : "#{parent_key}.#{key}"
30
- case value
31
- when Hash
32
- flatten_hash(filename, value, new_key, result)
33
- when Array
34
- value.each_with_index do |item, index|
35
- array_key = "#{new_key}.#{index}"
36
- if item.is_a?(Hash)
37
- flatten_hash(filename, item, array_key, result)
38
- else
39
- result[array_key] = filename
40
- end
41
- end
42
- else
43
- result[new_key] = filename
44
- end
45
- end
46
- result
47
- end
48
-
49
- def load_file(filename)
50
- type = File.extname(filename).tr(".", "").downcase
51
- raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true)
52
- data, keys_symbolized = send(:"load_#{type}", filename)
53
- unless data.is_a?(Hash)
54
- raise InvalidLocaleData.new(filename, "expects it to return a hash, but does not")
55
- end
56
- data.each do |locale, d|
57
- store_translations(locale, d || {}, skip_symbolize_keys: keys_symbolized)
58
- store_moirai_translations(filename, locale, d || {}, skip_symbolize_keys: keys_symbolized)
59
- end
60
-
61
- data
62
- end
63
11
  end
64
12
  end
65
13
  end
data/lib/moirai/engine.rb CHANGED
@@ -9,9 +9,7 @@ module Moirai
9
9
  end
10
10
 
11
11
  config.after_initialize do
12
- moirai_backend = I18n::Backend::Moirai.new
13
- moirai_backend.eager_load!
14
- I18n.backend = I18n::Backend::Chain.new(moirai_backend, I18n.backend)
12
+ I18n.backend = I18n::Backend::Chain.new(I18n::Backend::Moirai.new, I18n.backend)
15
13
  end
16
14
 
17
15
  # TODO: how to do this without rewriting the entire method?
@@ -25,12 +23,11 @@ module Moirai
25
23
  value = original_translate(key, **options)
26
24
 
27
25
  if moirai_edit_enabled?
28
- moirai_translations = I18n.backend.backends.find { |b| b.respond_to?(:moirai_translations) }.moirai_translations
29
- file_path = moirai_translations[I18n.locale][scope_key_by_partial(key)]
26
+ @key_finder ||= Moirai::KeyFinder.new
30
27
 
31
28
  render(partial: "moirai/translation_files/form",
32
29
  locals: {key: scope_key_by_partial(key),
33
- file_path: file_path,
30
+ locale: I18n.locale,
34
31
  value: value})
35
32
  else
36
33
  value
@@ -1,41 +1,43 @@
1
1
  class Moirai::PullRequestCreator
2
- BRANCH_NAME = "moirai-translations"
3
-
4
2
  def self.available?
5
- defined?(Octokit)
3
+ !!defined?(Octokit)
6
4
  end
7
5
 
6
+ BRANCH_PREFIX = "moirai-translations-"
7
+
8
+ attr_reader :github_repo_name, :github_access_token, :github_client, :github_repository, :branch_name
9
+
8
10
  def initialize
9
11
  @github_repo_name = ENV["MOIRAI_GITHUB_REPO_NAME"] || Rails.application.credentials.dig(:moirai, :github_repo_name)
10
12
  @github_access_token = ENV["MOIRAI_GITHUB_ACCESS_TOKEN"] || Rails.application.credentials.dig(:moirai, :github_access_token)
11
- @client = Octokit::Client.new(
12
- access_token: @github_access_token
13
- )
13
+ @github_client = Octokit::Client.new(access_token: github_access_token)
14
+ @github_repository = github_client.repo(github_repo_name)
14
15
  end
15
16
 
16
- def create_pull_request(translations_array)
17
- repo = @client.repo(@github_repo_name)
18
- default_branch = repo.default_branch
17
+ # @param changes Array[Moirai::Change]
18
+ def create_pull_request(changes)
19
+ @branch_name = "#{BRANCH_PREFIX}#{Time.current.strftime("%F-%H-%M-%S")}-#{rand(1000..9999)}"
20
+ default_branch = github_repository.default_branch
19
21
 
20
- if branch_exists?
21
- puts "Branch #{BRANCH_NAME} already exists - the branch will be updated with the new changes"
22
+ if moirai_branch_exists?
23
+ Rails.logger.debug { "Branch #{branch_name} already exists - the branch will be updated with the new changes" }
22
24
  else
23
- puts "Branch #{BRANCH_NAME} does not exist - creating branch"
24
- default_branch_ref = @client.ref(@github_repo_name, "heads/#{default_branch}")
25
+ Rails.logger.debug { "Branch #{branch_name} does not exist - creating branch" }
26
+ default_branch_ref = @github_client.ref(@github_repo_name, "heads/#{default_branch}")
25
27
  latest_commit_sha = default_branch_ref.object.sha
26
28
 
27
- @client.create_ref(@github_repo_name, "heads/#{BRANCH_NAME}", latest_commit_sha)
29
+ @github_client.create_ref(@github_repo_name, "heads/#{branch_name}", latest_commit_sha)
28
30
  end
29
31
 
30
- translations_array.each do |translation_hash|
31
- update_file(translation_hash[:file_path], translation_hash[:content])
32
+ changes.each do |change|
33
+ update_file(change.file_path, change.content)
32
34
  end
33
35
 
34
- unless pull_request_exists?
35
- pull_request = @client.create_pull_request(
36
+ unless existing_open_pull_request.present?
37
+ pull_request = @github_client.create_pull_request(
36
38
  @github_repo_name,
37
39
  default_branch,
38
- BRANCH_NAME,
40
+ branch_name,
39
41
  "Adding new content by Moirai",
40
42
  "BODY - This is a pull request created by Moirai"
41
43
  )
@@ -44,40 +46,47 @@ class Moirai::PullRequestCreator
44
46
  end
45
47
  end
46
48
 
47
- def branch_exists?
48
- @client.ref(@github_repo_name, "heads/#{BRANCH_NAME}")
49
+ def moirai_branch_exists?
50
+ @github_client.ref(@github_repo_name, "heads/#{branch_name}")
49
51
  true
50
52
  rescue Octokit::NotFound
51
53
  false
52
54
  end
53
55
 
56
+ def existing_open_pull_request
57
+ @github_client.pull_requests(@github_repo_name).find do |pull_request|
58
+ pull_request.head.ref.start_with?(BRANCH_PREFIX) && (pull_request.state == "open")
59
+ end
60
+ end
61
+
62
+ def cleanup
63
+ pr = existing_open_pull_request
64
+ @github_client.close_pull_request(@github_repo_name, pr.number)
65
+ @github_client.delete_branch(@github_repo_name, pr.head.ref)
66
+ end
67
+
68
+ private
69
+
54
70
  def update_file(path, content)
55
71
  # TODO: check what happens if branch exists
56
-
57
- file = @client.contents(@github_repo_name, path: path, ref: BRANCH_NAME)
72
+ file = @github_client.contents(@github_repo_name, path: path, ref: branch_name)
58
73
  file_sha = file.sha
59
74
 
60
- @client.update_contents(
75
+ @github_client.update_contents(
61
76
  @github_repo_name,
62
77
  path,
63
78
  "Updating translations for #{path} by Moirai",
64
79
  file_sha,
65
80
  content,
66
- branch: BRANCH_NAME
81
+ branch: branch_name
67
82
  )
68
83
  rescue Octokit::NotFound
69
- @client.create_contents(
84
+ @github_client.create_contents(
70
85
  @github_repo_name,
71
86
  path,
72
87
  "Creating translations for #{path} by Moirai",
73
88
  content,
74
- branch: BRANCH_NAME
89
+ branch: branch_name
75
90
  )
76
91
  end
77
-
78
- def pull_request_exists?
79
- @client.pull_requests(@github_repo_name).any? do |pull_request|
80
- pull_request.head.ref == BRANCH_NAME
81
- end
82
- end
83
92
  end
data/moirai.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "moirai"
5
- spec.version = "0.1.0"
5
+ spec.version = "0.2.0"
6
6
  spec.authors = ["Alessandro Rodi", "Oliver Anthony", "Daniel Bengl"]
7
7
  spec.email = %w[alessandro.rodi@renuo.ch oliver.anthony@renuo.ch daniel.bengl@renuo.ch]
8
8
 
@@ -25,11 +25,6 @@ Gem::Specification.new do |spec|
25
25
 
26
26
  spec.required_ruby_version = ">= 2.7.0"
27
27
  spec.add_dependency "rails", ">= 6.1"
28
- spec.add_development_dependency "octokit", ">= 4.0"
29
- spec.add_development_dependency "capybara"
30
- spec.add_development_dependency "selenium-webdriver"
31
- spec.add_development_dependency "rails", "~> 7.2.0"
32
- spec.add_development_dependency "dotenv"
33
28
 
34
29
  spec.license = "MIT"
35
30
  spec.metadata["rubygems_mfa_required"] = "true"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moirai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessandro Rodi
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: exe
12
12
  cert_chain: []
13
- date: 2024-10-23 00:00:00.000000000 Z
13
+ date: 2024-11-06 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: rails
@@ -26,76 +26,6 @@ dependencies:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
28
  version: '6.1'
29
- - !ruby/object:Gem::Dependency
30
- name: octokit
31
- requirement: !ruby/object:Gem::Requirement
32
- requirements:
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: '4.0'
36
- type: :development
37
- prerelease: false
38
- version_requirements: !ruby/object:Gem::Requirement
39
- requirements:
40
- - - ">="
41
- - !ruby/object:Gem::Version
42
- version: '4.0'
43
- - !ruby/object:Gem::Dependency
44
- name: capybara
45
- requirement: !ruby/object:Gem::Requirement
46
- requirements:
47
- - - ">="
48
- - !ruby/object:Gem::Version
49
- version: '0'
50
- type: :development
51
- prerelease: false
52
- version_requirements: !ruby/object:Gem::Requirement
53
- requirements:
54
- - - ">="
55
- - !ruby/object:Gem::Version
56
- version: '0'
57
- - !ruby/object:Gem::Dependency
58
- name: selenium-webdriver
59
- requirement: !ruby/object:Gem::Requirement
60
- requirements:
61
- - - ">="
62
- - !ruby/object:Gem::Version
63
- version: '0'
64
- type: :development
65
- prerelease: false
66
- version_requirements: !ruby/object:Gem::Requirement
67
- requirements:
68
- - - ">="
69
- - !ruby/object:Gem::Version
70
- version: '0'
71
- - !ruby/object:Gem::Dependency
72
- name: rails
73
- requirement: !ruby/object:Gem::Requirement
74
- requirements:
75
- - - "~>"
76
- - !ruby/object:Gem::Version
77
- version: 7.2.0
78
- type: :development
79
- prerelease: false
80
- version_requirements: !ruby/object:Gem::Requirement
81
- requirements:
82
- - - "~>"
83
- - !ruby/object:Gem::Version
84
- version: 7.2.0
85
- - !ruby/object:Gem::Dependency
86
- name: dotenv
87
- requirement: !ruby/object:Gem::Requirement
88
- requirements:
89
- - - ">="
90
- - !ruby/object:Gem::Version
91
- version: '0'
92
- type: :development
93
- prerelease: false
94
- version_requirements: !ruby/object:Gem::Requirement
95
- requirements:
96
- - - ">="
97
- - !ruby/object:Gem::Version
98
- version: '0'
99
29
  description: |-
100
30
  This gem allows you to manage translation strings in real time,
101
31
  viewing the live changes in the browser, with the changes then converted to a PR opened on the repository.
@@ -117,17 +47,18 @@ files:
117
47
  - Rakefile
118
48
  - app/assets/config/moirai_manifest.js
119
49
  - app/assets/images/moirai/.keep
120
- - app/assets/javascripts/moirai/application.js
121
- - app/assets/javascripts/moirai/controllers/moirai_translation_controller.js
122
- - app/assets/javascripts/moirai/stimulus/init.js
50
+ - app/assets/javascripts/moirai_translation_controller.js
123
51
  - app/assets/stylesheets/moirai/application.css
124
52
  - app/controllers/concerns/.keep
125
53
  - app/controllers/moirai/application_controller.rb
126
54
  - app/controllers/moirai/translation_files_controller.rb
55
+ - app/controllers/moirai/translations_controller.rb
127
56
  - app/helpers/moirai/application_helper.rb
128
57
  - app/jobs/moirai/application_job.rb
129
58
  - app/models/concerns/.keep
130
59
  - app/models/moirai/application_record.rb
60
+ - app/models/moirai/change.rb
61
+ - app/models/moirai/key_finder.rb
131
62
  - app/models/moirai/translation.rb
132
63
  - app/models/moirai/translation_dumper.rb
133
64
  - app/models/moirai/translation_file_handler.rb
@@ -135,6 +66,7 @@ files:
135
66
  - app/views/moirai/translation_files/_form.html.erb
136
67
  - app/views/moirai/translation_files/index.html.erb
137
68
  - app/views/moirai/translation_files/show.html.erb
69
+ - app/views/moirai/translations/index.html.erb
138
70
  - bin/check
139
71
  - bin/console
140
72
  - bin/fastcheck
@@ -143,7 +75,8 @@ files:
143
75
  - config/routes.rb
144
76
  - lib/generators/moirai/install_generator.rb
145
77
  - lib/generators/moirai/migration_generator.rb
146
- - lib/generators/moirai/templates/migration.rb.erb
78
+ - lib/generators/moirai/templates/create_moirai_translations.rb.erb
79
+ - lib/generators/moirai/templates/make_moirai_translations_file_path_not_required.rb.erb
147
80
  - lib/i18n/backend/moirai.rb
148
81
  - lib/moirai.rb
149
82
  - lib/moirai/engine.rb
@@ -1,2 +0,0 @@
1
- //= require ./stimulus/init
2
- //= require ./controllers/moirai_translation_controller
@@ -1,27 +0,0 @@
1
- Stimulus.register(
2
- "moirai-translation",
3
- class extends Controller {
4
- click(event) {
5
- event.preventDefault()
6
- }
7
-
8
- submit(event) {
9
- const {filePath, key} = event.target.dataset
10
-
11
- const csrfToken = document.querySelector('meta[name="csrf-token"]').content
12
-
13
- const formObject = new FormData()
14
- formObject.append('translation[key]', key)
15
- formObject.append('translation[file_path]', filePath)
16
- formObject.append('translation[value]', event.target.innerText)
17
-
18
- fetch(`/moirai/translation_files`, {
19
- method: 'POST',
20
- headers: {
21
- 'X-CSRF-Token': csrfToken
22
- },
23
- body: formObject
24
- })
25
- }
26
- }
27
- );
@@ -1,5 +0,0 @@
1
- import {
2
- Application,
3
- Controller,
4
- } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
5
- window.Stimulus = Application.start();