moirai 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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();