├── public
└── .keep
├── spec
├── dummy
│ ├── log
│ │ └── .keep
│ ├── lib
│ │ └── assets
│ │ │ └── .keep
│ ├── public
│ │ ├── favicon.ico
│ │ ├── apple-touch-icon.png
│ │ ├── apple-touch-icon-precomposed.png
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ ├── app
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ └── application_record.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ └── application_controller.rb
│ │ ├── views
│ │ │ └── layouts
│ │ │ │ ├── mailer.text.erb
│ │ │ │ ├── mailer.html.erb
│ │ │ │ └── application.html.erb
│ │ ├── helpers
│ │ │ └── application_helper.rb
│ │ ├── assets
│ │ │ └── config
│ │ │ │ └── manifest.js
│ │ ├── channels
│ │ │ └── application_cable
│ │ │ │ ├── channel.rb
│ │ │ │ └── connection.rb
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ └── jobs
│ │ │ └── application_job.rb
│ ├── config
│ │ ├── initializers
│ │ │ ├── rails_i18n_translations_engine.rb
│ │ │ ├── mime_types.rb
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── application_controller_renderer.rb
│ │ │ ├── cookies_serializer.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── wrap_parameters.rb
│ │ │ ├── inflections.rb
│ │ │ └── content_security_policy.rb
│ │ ├── routes.rb
│ │ ├── environment.rb
│ │ ├── cable.yml
│ │ ├── boot.rb
│ │ ├── environments
│ │ │ ├── test.rb
│ │ │ └── development.rb
│ │ ├── database.yml
│ │ ├── application.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── storage.yml
│ │ └── puma.rb
│ ├── bin
│ │ ├── rake
│ │ ├── rails
│ │ └── setup
│ ├── config.ru
│ ├── Rakefile
│ └── db
│ │ └── seeds.rb
├── factories
│ ├── translation_app_factory.rb
│ ├── translation_value_factory.rb
│ └── translation_key_factory.rb
├── unit
│ ├── config_spec.rb
│ ├── models
│ │ ├── translation_value_spec.rb
│ │ ├── translation_app_spec.rb
│ │ └── translation_key_spec.rb
│ ├── google_translate_spec.rb
│ └── translations_importer_spec.rb
├── spec_helper.rb
├── rails_helper.rb
└── request
│ ├── translation_apps_controller_spec.rb
│ └── translations_controller_spec.rb
├── config
├── initializers
│ └── .keep
├── locales
│ └── en.yml
└── routes.rb
├── lib
├── rails_i18n_manager
│ ├── version.rb
│ ├── engine.rb
│ └── config.rb
└── rails_i18n_manager.rb
├── screenshot_edit.png
├── screenshot_list.png
├── screenshot_import.png
├── bin
├── dev
└── rails
├── app
├── views
│ ├── rails_i18n_manager
│ │ ├── kaminari
│ │ │ ├── _gap.html.erb
│ │ │ ├── _first_page.html.erb
│ │ │ ├── _last_page.html.erb
│ │ │ ├── _next_page.html.erb
│ │ │ ├── _prev_page.html.erb
│ │ │ ├── _page.html.erb
│ │ │ └── _paginator.html.erb
│ │ ├── translations
│ │ │ ├── edit.html.slim
│ │ │ ├── _sub_nav.html.slim
│ │ │ ├── _translation_value_fields.html.slim
│ │ │ ├── _breadcrumbs.html.slim
│ │ │ ├── _filter_bar.html.slim
│ │ │ ├── _form.html.slim
│ │ │ ├── import.html.slim
│ │ │ └── index.html.slim
│ │ ├── form_builder
│ │ │ ├── _error_notification.html.erb
│ │ │ └── _basic_field.html.erb
│ │ ├── shared
│ │ │ └── _flash.html.slim
│ │ └── translation_apps
│ │ │ ├── _filter_bar.html.slim
│ │ │ ├── _breadcrumbs.html.slim
│ │ │ ├── index.html.slim
│ │ │ └── form.html.slim
│ └── layouts
│ │ └── rails_i18n_manager
│ │ ├── application.js.erb
│ │ ├── _app_javascript.html.erb
│ │ ├── _app_css.html.erb
│ │ ├── application.html.slim
│ │ ├── _utility_css.html.erb
│ │ └── _assets.html.slim
├── controllers
│ └── rails_i18n_manager
│ │ ├── application_controller.rb
│ │ ├── translation_apps_controller.rb
│ │ └── translations_controller.rb
├── models
│ └── rails_i18n_manager
│ │ ├── application_record.rb
│ │ ├── translation_value.rb
│ │ ├── translation_app.rb
│ │ └── translation_key.rb
├── lib
│ └── rails_i18n_manager
│ │ ├── forms
│ │ ├── base.rb
│ │ └── translation_file_form.rb
│ │ ├── google_translate.rb
│ │ ├── translations_importer.rb
│ │ └── custom_form_builder.rb
└── helpers
│ └── rails_i18n_manager
│ └── application_helper.rb
├── .gitignore
├── Rakefile
├── Gemfile
├── db
└── migrate
│ └── 20221001001344_add_rails_i18n_manager_tables.rb
├── LICENSE
├── rails_i18n_manager.gemspec
├── .github
└── workflows
│ └── test.yml
├── CHANGELOG.md
└── README.md
/public/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/initializers/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/rails_i18n_translations_engine.rb:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/lib/rails_i18n_manager/version.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | VERSION = "1.1.4".freeze
3 | end
4 |
--------------------------------------------------------------------------------
/screenshot_edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westonganger/rails_i18n_manager/HEAD/screenshot_edit.png
--------------------------------------------------------------------------------
/screenshot_list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westonganger/rails_i18n_manager/HEAD/screenshot_list.png
--------------------------------------------------------------------------------
/screenshot_import.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westonganger/rails_i18n_manager/HEAD/screenshot_import.png
--------------------------------------------------------------------------------
/spec/dummy/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | // this file is only present to make Rails 6.1 and below work in our CI
2 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | end
3 |
--------------------------------------------------------------------------------
/spec/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | mount RailsI18nManager::Engine => "/"
3 | end
4 |
--------------------------------------------------------------------------------
/bin/dev:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | system("cd spec/dummy/ && bundle exec rails s --binding 0.0.0.0", out: STDOUT)
4 |
--------------------------------------------------------------------------------
/spec/dummy/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative '../config/boot'
3 | require 'rake'
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/spec/dummy/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: 'from@example.com'
3 | layout 'mailer'
4 | end
5 |
--------------------------------------------------------------------------------
/spec/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative 'config/environment'
4 |
5 | run Rails.application
6 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_gap.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= link_to raw(t 'views.pagination.truncate'), '#', class: 'page-link' %>
3 |
4 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | activerecord:
3 | attributes:
4 | 'rails_i18n_manager/board':
5 | num_iterations_to_track: "Number of Iterations to Track"
6 |
--------------------------------------------------------------------------------
/spec/dummy/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path('../config/application', __dir__)
3 | require_relative '../config/boot'
4 | require 'rails/commands'
5 |
--------------------------------------------------------------------------------
/spec/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative 'application'
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/application.js.erb:
--------------------------------------------------------------------------------
1 | $("#flash-container").html("<%= j render partial: "rails_i18n_manager/shared/flash" %>");
2 |
3 | <%= yield %>
4 |
5 | window.init();
6 |
--------------------------------------------------------------------------------
/app/controllers/rails_i18n_manager/application_controller.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class ApplicationController < ActionController::Base
3 | protect_from_forgery with: :exception
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_first_page.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, remote: remote, class: 'page-link' %>
3 |
4 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_last_page.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, remote: remote, class: 'page-link' %>
3 |
4 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/edit.html.slim:
--------------------------------------------------------------------------------
1 | = render "breadcrumbs"
2 |
3 | h2.page-title Translations
4 | /h5.page-title App Name: #{@translation_key.translation_app.name}
5 |
6 | = render "form"
7 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_next_page.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, rel: 'next', remote: remote, class: 'page-link' %>
3 |
4 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_prev_page.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, rel: 'prev', remote: remote, class: 'page-link' %>
3 |
4 |
--------------------------------------------------------------------------------
/spec/dummy/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: test
6 |
7 | production:
8 | adapter: redis
9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
10 | channel_prefix: dummy_production
11 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [:password]
5 |
--------------------------------------------------------------------------------
/spec/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative 'config/application'
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/form_builder/_error_notification.html.erb:
--------------------------------------------------------------------------------
1 | <% if f.object.errors[:base].present? %>
2 |
3 | <% f.object.errors[:base].each do |error| %>
4 | <%= error %>
5 | <% end %>
6 |
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/spec/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # Set up gems listed in the Gemfile.
2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__)
3 |
4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__)
6 |
--------------------------------------------------------------------------------
/spec/factories/translation_app_factory.rb:
--------------------------------------------------------------------------------
1 | FactoryBot.define do
2 | factory :translation_app, class: RailsI18nManager::TranslationApp do
3 | name { Faker::App.unique.name }
4 | default_locale { "en" }
5 | additional_locales { ["fr", "es"] }
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ActiveSupport::Reloader.to_prepare do
4 | # ApplicationController.renderer.defaults.merge!(
5 | # http_host: 'example.org',
6 | # https: false
7 | # )
8 | # end
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | log/*.log
3 | pkg/
4 | spec/dummy/db/*.sqlite3
5 | spec/dummy/db/*.sqlite3*
6 | spec/dummy/log/*.log
7 | spec/dummy/storage/
8 | spec/dummy/tmp/
9 | spec/dummy/db/schema.rb
10 |
11 | Gemfile.lock
12 |
13 | **/.DS_Store
14 | .DS_Store
15 |
16 | .rspec
17 | spec/examples.txt
18 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Specify a serializer for the signed and encrypted cookie jars.
4 | # Valid options are :json, :marshal, and :hybrid.
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/spec/dummy/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | # Automatically retry jobs that encountered a deadlock
3 | # retry_on ActiveRecord::Deadlocked
4 |
5 | # Most jobs are safe to ignore if the underlying records are no longer available
6 | # discard_on ActiveJob::DeserializationError
7 | end
8 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | config.action_controller.allow_forgery_protection = false
3 |
4 | if Rails::VERSION::STRING.to_f >= 7.1
5 | config.action_dispatch.show_exceptions = :none
6 | else
7 | config.action_dispatch.show_exceptions = false
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | config.action_mailer.default_url_options = { host: 'localhost:3000' }
3 | config.active_record.migration_error = :page_load
4 | config.consider_all_requests_local = true
5 | config.eager_load = true ### helps catch more errors in development
6 | end
7 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/shared/_flash.html.slim:
--------------------------------------------------------------------------------
1 | #flash-container
2 | - flash.each do |name, msg|
3 | - if msg.is_a?(String)
4 | .alert.alert-dismissible class="alert-#{name.to_s == "notice" ? "success" : "danger"}"
5 | button.btn-close type="button" data-bs-dismiss="alert"
6 | span id="flash_#{name}" = msg
7 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dummy
5 | <%= csrf_meta_tags %>
6 | <%= csp_meta_tag %>
7 |
8 | <%= stylesheet_link_tag 'application', media: 'all' %>
9 |
10 |
11 |
12 | <%= yield %>
13 |
14 |
15 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_page.html.erb:
--------------------------------------------------------------------------------
1 | <% if page.current? %>
2 |
3 | <%= content_tag :a, page, data: { remote: remote }, rel: page.rel, class: 'page-link' %>
4 |
5 | <% else %>
6 |
7 | <%= link_to page, url, remote: remote, rel: page.rel, class: 'page-link' %>
8 |
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translation_apps/_filter_bar.html.slim:
--------------------------------------------------------------------------------
1 | form
2 | .row.align-items-center.g-1
3 | .col-auto
4 | = text_field_tag :search, params[:search], placeholder: "Search", class: "form-control", style: "max-width: 200px; width: 100%;"
5 |
6 | .col-auto
7 | button.btn.btn-primary.btn-sm type="submit" Filter
8 | - if params[:search].present?
9 | = link_to "Clear", nil, class: "btn btn-sm space-left"
10 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
7 | # Rails.backtrace_cleaner.remove_silencers!
8 |
--------------------------------------------------------------------------------
/app/models/rails_i18n_manager/application_record.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class ApplicationRecord < ActiveRecord::Base
3 | self.abstract_class = true
4 |
5 | include ActiveSortOrder
6 |
7 | scope :multi_search, ->(full_str){
8 | if full_str.present?
9 | rel = self
10 |
11 | full_str.split(' ').each do |q|
12 | rel = rel.search(q)
13 | end
14 |
15 | next rel
16 | end
17 | }
18 |
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require 'bundler/setup'
3 | rescue LoadError
4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5 | end
6 |
7 | APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
8 | load 'rails/tasks/engine.rake'
9 |
10 | load 'rails/tasks/statistics.rake'
11 |
12 | require 'bundler/gem_tasks'
13 |
14 | require 'rspec/core/rake_task'
15 | RSpec::Core::RakeTask.new(:spec)
16 |
17 | task test: [:spec]
18 |
19 | task default: [:spec]
20 |
--------------------------------------------------------------------------------
/app/models/rails_i18n_manager/translation_value.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationValue < ApplicationRecord
3 |
4 | belongs_to :translation_key, class_name: "RailsI18nManager::TranslationKey"
5 |
6 | validates :translation_key, presence: true
7 | validates :locale, presence: true, uniqueness: {scope: :translation_key_id}
8 | validates :translation, presence: {if: ->(){ locale == translation_key.translation_app.default_locale.to_s } }
9 |
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/dummy/db/seeds.rb:
--------------------------------------------------------------------------------
1 | Dir.glob("#{__dir__}/../../factories/*").each do |x|
2 | puts x
3 | require_relative(x)
4 | end
5 |
6 | module RailsI18nManager
7 | if TranslationKey.first
8 | raise "Error already seeded"
9 | end
10 |
11 | puts "Seeding"
12 |
13 | app = FactoryBot.create(:translation_app, name: "Bluejay")
14 |
15 | 100.times do
16 | FactoryBot.create(:translation_key, :with_translation_values, translation_app: app)
17 | end
18 |
19 | puts "Successfully seeded"
20 | end
21 |
--------------------------------------------------------------------------------
/spec/factories/translation_value_factory.rb:
--------------------------------------------------------------------------------
1 | FactoryBot.define do
2 | factory :translation_value, class: RailsI18nManager::TranslationValue do
3 | translation_key { FactoryBot.create(:translation_key) }
4 | locale { translation_key.translation_app.default_locale }
5 | translation do
6 | if locale == translation_key.translation_app.default_locale
7 | translation_key.key.split(".").last.titleize
8 | else
9 | Faker::Lorem.word
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | RailsI18nManager::Engine.routes.draw do
2 | resources :translations, only: [:index, :show, :edit, :update, :destroy] do
3 | collection do
4 | post :translate_missing
5 |
6 | get :import
7 | post :import
8 |
9 | delete :delete_inactive_keys
10 | end
11 | end
12 |
13 | resources :translation_apps
14 |
15 | get "/robots", to: "application#robots", constraints: ->(req){ req.format == :text }
16 |
17 | get "/", to: "translations#index"
18 |
19 | root "translations#index"
20 | end
21 |
--------------------------------------------------------------------------------
/lib/rails_i18n_manager/engine.rb:
--------------------------------------------------------------------------------
1 | require 'slim'
2 | require 'active_sort_order'
3 | require 'kaminari'
4 | require "zip"
5 | require "activerecord-import"
6 | require "csv"
7 | require "easy_translate"
8 |
9 | module RailsI18nManager
10 | class Engine < ::Rails::Engine
11 | isolate_namespace RailsI18nManager
12 |
13 | paths["app/models"] << "app/lib"
14 |
15 | initializer "rails_i18n_manager.load_static_assets" do |app|
16 | ### Expose static assets
17 | app.middleware.use ::ActionDispatch::Static, "#{root}/public"
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/_sub_nav.html.slim:
--------------------------------------------------------------------------------
1 | ul.nav.nav-tabs.space-below5.space-above3
2 | = nav_link "View", translation_path(@translation_key)
3 |
4 | = nav_link "Edit", edit_translation_path(@translation_key), active: ["edit", "update"].include?(action_name)
5 |
6 | - if @translation_key.any_missing_translations?
7 | = nav_link "Translate Missing with Google", translate_missing_translations_path(translation_key_id: @translation_key.id), method: :post, "data-confirm" => "Are you sure you want to proceed with translating the missing translations for this entry?"
8 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/_translation_value_fields.html.slim:
--------------------------------------------------------------------------------
1 | - locale = f.object.locale
2 | - default_locale = f.object.translation_key.translation_app.default_locale
3 |
4 | - if default_locale == locale
5 | - help_text = "This default translation will be utilized whenever a more specific language is not available"
6 | - required = true
7 |
8 | .nested-fields.translation-value-fields
9 | = f.hidden_field :locale
10 | = f.field :translation, type: :textarea, label: "#{locale}", required: required, help_text: help_text, field_layout: :horizontal, input_html: {rows: 1}
11 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translation_apps/_breadcrumbs.html.slim:
--------------------------------------------------------------------------------
1 | nav style="--bs-breadcrumb-divider: '/';"
2 | .breadcrumb
3 | = breadcrumb_item RailsI18nManager::TranslationApp::NAME.pluralize, translation_apps_path
4 |
5 | - if @translation_app
6 | - if @translation_app.new_record?
7 | = breadcrumb_item "New", new_translation_app_path
8 | - else
9 | = breadcrumb_item @translation_app.name, translation_app_path(@translation_app)
10 |
11 | - if ["edit", "update"].include?(action_name)
12 | = breadcrumb_item "Edit", edit_translation_app_path(@translation_app)
13 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails gems
3 | # installed from the root of your application.
4 |
5 | ENGINE_ROOT = File.expand_path('..', __dir__)
6 | ENGINE_PATH = File.expand_path('../lib/rails_i18n_manager/engine', __dir__)
7 | APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__)
8 |
9 | # Set up gems listed in the Gemfile.
10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
12 |
13 | require 'rails/all'
14 | require 'rails/engine/commands'
15 |
--------------------------------------------------------------------------------
/spec/unit/config_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RailsI18nManager::Config, type: :model do
4 |
5 | context "google_translate_api_key" do
6 | before do
7 | @prev_google_translate_api_key = RailsI18nManager.config.google_translate_api_key
8 | end
9 |
10 | after do
11 | RailsI18nManager.config.google_translate_api_key = @prev_google_translate_api_key
12 | end
13 |
14 | it "allows assignment" do
15 | RailsI18nManager.config.google_translate_api_key = "foo"
16 | expect(RailsI18nManager.config.google_translate_api_key).to eq("foo")
17 | end
18 | end
19 |
20 | end
21 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/kaminari/_paginator.html.erb:
--------------------------------------------------------------------------------
1 | <%= paginator.render do %>
2 |
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/app/lib/rails_i18n_manager/forms/base.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | module Forms
3 | class Base
4 | include ActiveModel::Validations
5 |
6 | def initialize(attrs={})
7 | attrs ||= {}
8 |
9 | attrs.each do |k,v|
10 | self.send("#{k}=", v) ### Use send so that it checks that attr_accessor has already defined the method so its a valid attribute
11 | end
12 | end
13 |
14 | def to_key
15 | nil
16 | end
17 |
18 | def model_name
19 | sanitized_class_name = self.class.name.to_s.gsub("Forms::", '').gsub(/Form$/, '')
20 | ActiveModel::Name.new(self, self.class.superclass, sanitized_class_name)
21 | end
22 |
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/spec/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite. Versions 3.8.0 and up are supported.
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: db/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: db/test.sqlite3
22 |
23 | production:
24 | <<: *default
25 | database: db/production.sqlite3
26 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/_breadcrumbs.html.slim:
--------------------------------------------------------------------------------
1 | nav style="--bs-breadcrumb-divider: '/';"
2 | .breadcrumb
3 | = breadcrumb_item "Translations", translations_path
4 |
5 | - if action_name == "import"
6 | = breadcrumb_item "Import", import_translations_path
7 |
8 | - elsif @translation_key
9 | - if @translation_key.new_record?
10 | = breadcrumb_item "New", new_translation_path(@translation_key)
11 | - else
12 | = breadcrumb_item @translation_key.translation_app.name, translations_path(app_name: @translation_key.translation_app.name)
13 |
14 | = breadcrumb_item @translation_key.key, translation_path(@translation_key)
15 |
16 | - if ["edit", "update"].include?(action_name)
17 | = breadcrumb_item "Edit", edit_translation_path(@translation_key)
18 |
--------------------------------------------------------------------------------
/spec/factories/translation_key_factory.rb:
--------------------------------------------------------------------------------
1 | FactoryBot.define do
2 | factory :translation_key, class: RailsI18nManager::TranslationKey do
3 | translation_app { FactoryBot.create(:translation_app) }
4 | key { [Faker::Verb.base, Faker::Verb.base, Faker::Verb.base][0..(rand(1..2))].join(".").downcase }
5 |
6 | # after :build do |record|
7 | # record.assign_attributes(
8 | # translation_values_attributes: [
9 | # {locale: record.translation_app.default_locale, translation: "some-default-translation"}
10 | # ]
11 | # )
12 | # end
13 |
14 | trait :with_translation_values do
15 | after :create do |record|
16 | record.translation_app.all_locales.each do |locale|
17 | FactoryBot.create(:translation_value, translation_key: record, locale: locale)
18 | end
19 | end
20 | end
21 |
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | # Declare your gem's dependencies in coders_log.gemspec.
5 | # Bundler will treat runtime dependencies like base dependencies, and
6 | # development dependencies will be added by default to the :development group.
7 | gemspec
8 |
9 | # Declare any dependencies that are still in development here instead of in
10 | # your gemspec. These might include edge Rails or gems from your path or
11 | # Git. Remember to move these dependencies to your gemspec before releasing
12 | # your gem to rubygems.org.
13 |
14 | def get_env(name)
15 | (ENV[name] && !ENV[name].empty?) ? ENV[name] : nil
16 | end
17 |
18 | rails_version = get_env("RAILS_VERSION")
19 |
20 | gem "rails", rails_version
21 |
22 | db_gem = get_env("DB_GEM") || "sqlite3"
23 | gem db_gem, get_env("DB_GEM_VERSION")
24 |
25 | group :development do
26 | gem "puma"
27 | end
28 |
--------------------------------------------------------------------------------
/spec/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative 'boot'
2 |
3 | require "logger" # Fix for Rails 7.0 and below, https://github.com/rails/rails/pull/54264
4 |
5 | require 'rails/all'
6 |
7 | Bundler.require(*Rails.groups)
8 | require "rails_i18n_manager"
9 |
10 | module Dummy
11 | class Application < Rails::Application
12 | # Initialize configuration defaults for originally generated Rails version.
13 | if Rails::VERSION::STRING.to_f >= 5.1
14 | config.load_defaults Rails::VERSION::STRING.to_f
15 | end
16 |
17 | # Settings in config/environments/* take precedence over those specified here.
18 | # Application configuration can go into files in config/initializers
19 | # -- all .rb files in that directory are automatically loaded after loading
20 | # the framework and any gems in your application.
21 |
22 | config.eager_load = true ### to catch more bugs in development/test environments
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/db/migrate/20221001001344_add_rails_i18n_manager_tables.rb:
--------------------------------------------------------------------------------
1 | class AddRailsI18nManagerTables < ActiveRecord::Migration[6.0]
2 | def change
3 |
4 | create_table :rails_i18n_manager_translation_apps do |t|
5 | t.string :name
6 | t.string :default_locale
7 | t.text :additional_locales
8 | t.timestamps
9 | end
10 |
11 | create_table :rails_i18n_manager_translation_keys do |t|
12 | t.string :key
13 | t.references :translation_app, index: { name: 'index_translation_keys_on_translation_app_id' }
14 | t.boolean :active, default: true, null: false
15 | t.datetime :updated_at
16 | end
17 |
18 | create_table :rails_i18n_manager_translation_values do |t|
19 | t.references :translation_key, index: { name: 'index_translation_values_on_translation_key_id' }
20 | t.string :locale, limit: 5
21 | t.string :translation
22 | t.datetime :updated_at
23 | end
24 |
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/spec/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # The following keys must be escaped otherwise they will not be retrieved by
20 | # the default I18n backend:
21 | #
22 | # true, false, on, off, yes, no
23 | #
24 | # Instead, surround them with single quotes.
25 | #
26 | # en:
27 | # 'true': 'foo'
28 | #
29 | # To learn more, please read the Rails Internationalization guide
30 | # available at https://guides.rubyonrails.org/i18n.html.
31 |
32 | en:
33 | hello: "Hello world"
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2022 Weston Ganger
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translation_apps/index.html.slim:
--------------------------------------------------------------------------------
1 | .well.alert.alert-dark.permanent.pull-right style=("max-width: 920px;")
2 | | Once you have created a #{RailsI18nManager::TranslationApp::NAME} then you can use the Import functionality on #{link_to "Translations Import page", import_translations_path}
3 |
4 | .row.align-items-center.g-3
5 | .col-auto
6 | h2.page-title = RailsI18nManager::TranslationApp::NAME.pluralize
7 | .col-auto
8 | = link_to "New App", new_translation_app_path, class: 'btn btn-primary btn-sm'
9 |
10 | .space-above4
11 | = render "filter_bar"
12 |
13 | table.table.table-striped.table-hover.space-above3.list-table
14 | thead
15 | tr
16 | th = sort_link(:name)
17 | th Default Locale
18 | th Additional Locales
19 | th Actions
20 | tbody
21 | - @translation_apps.each do |x|
22 | tr
23 | td = x.name
24 | td = x.default_locale
25 | td = x.additional_locales_array.join(", ")
26 | td
27 | = link_to "Edit", {action: :edit, id: x.id}
28 |
29 | = paginate @translation_apps, views_prefix: "rails_i18n_manager"
30 |
--------------------------------------------------------------------------------
/spec/dummy/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'fileutils'
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path('..', __dir__)
6 |
7 | def system!(*args)
8 | system(*args) || abort("\n== Command #{args} failed ==")
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to setup or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts '== Installing dependencies =='
17 | system! 'gem install bundler --conservative'
18 | system('bundle check') || system!('bundle install')
19 |
20 | # puts "\n== Copying sample files =="
21 | # unless File.exist?('config/database.yml')
22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
23 | # end
24 |
25 | puts "\n== Preparing database =="
26 | system! 'bin/rails db:prepare'
27 |
28 | puts "\n== Removing old logs and tempfiles =="
29 | system! 'bin/rails log:clear tmp:clear'
30 |
31 | puts "\n== Restarting application server =="
32 | system! 'bin/rails restart'
33 | end
34 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/_filter_bar.html.slim:
--------------------------------------------------------------------------------
1 | form
2 | .row.align-items-center.g-1
3 | .col-auto
4 | = select_tag "filters[app_name]", options_for_select(RailsI18nManager::TranslationApp.order(name: :asc).pluck(:name), params.dig(:filters, :app_name)), prompt: "All Apps", class: "form-select", style: "min-width: 220px"
5 |
6 | .col-auto
7 | = select_tag :status, options_for_select(["All Translations", "All Active Translations", "Inactive Translations", "Missing Default Translation", "Missing Any Translation"], params.dig(:filters, :status)), class: 'form-select', style: "min-width: 215px;"
8 |
9 | .col-auto
10 | = text_field_tag :search, params.dig(:filters, :search), placeholder: "Search", class: "form-control"
11 |
12 | .col-auto
13 | button.btn.btn-primary.btn-sm type="submit" Filter
14 |
15 | - if params.dig(:filters, :app_name).present? || params.dig(:filters, :search).present?
16 | - if params.dig(:filters, :status).present?
17 | - link_params = {filters: {status: params.dig(:filters, :status)}}
18 | = link_to "Clear", (link_params || {}), class: "btn btn-sm space-left"
19 |
--------------------------------------------------------------------------------
/spec/unit/models/translation_value_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module RailsI18nManager
4 | RSpec.describe TranslationValue, type: :model do
5 |
6 | let(:translation_app) { FactoryBot.create(:translation_app, default_locale: :en, additional_locales: [:fr]) }
7 | let(:translation_key) { FactoryBot.create(:translation_key) }
8 | let(:default_translation_value) { translation_key.translation_values.create!(locale: translation_app.default_locale, translation: "foo") }
9 | let(:additional_translation_value) { translation_key.translation_values.create!(locale: translation_app.additional_locales_array.first, translation: "bar") }
10 |
11 | context "validations" do
12 | it "requires translation for default locale only" do
13 | default_translation_value.translation = nil
14 | default_translation_value.valid?
15 | expect(default_translation_value.errors[:translation]).not_to be_empty
16 |
17 | additional_translation_value.translation = nil
18 | additional_translation_value.valid?
19 | expect(additional_translation_value.errors[:translation]).to be_empty
20 | end
21 | end
22 |
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/rails_i18n_manager/config.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class Config
3 |
4 | @@google_translate_api_key = nil
5 | mattr_reader :google_translate_api_key
6 |
7 | def self.google_translate_api_key=(val)
8 | @@google_translate_api_key = val
9 | end
10 |
11 | ### List retrieved from Google Translate (2022)
12 | @@valid_locales = ["af", "am", "ar", "az", "be", "bg", "bn", "bs", "ca", "ceb", "co", "cs", "cy", "da", "de", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fr", "fy", "ga", "gd", "gl", "gu", "ha", "haw", "he", "hi", "hmn", "hr", "ht", "hu", "hy", "id", "ig", "is", "it", "iw", "ja", "jw", "ka", "kk", "km", "kn", "ko", "ku", "ky", "la", "lb", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my", "ne", "nl", "no", "ny", "or", "pa", "pl", "ps", "pt", "ro", "ru", "rw", "sd", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "tk", "tl", "tr", "tt", "ug", "uk", "ur", "uz", "vi", "xh", "yi", "yo", "zh", "zh-CN", "zh-TW", "zu"].freeze
13 | mattr_accessor :valid_locales
14 |
15 | def self.valid_locales=(locales_array)
16 | @@valid_locales = locales_array
17 | end
18 |
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/dummy/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket
23 |
24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/_app_javascript.html.erb:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/_app_css.html.erb:
--------------------------------------------------------------------------------
1 |
70 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/application.html.slim:
--------------------------------------------------------------------------------
1 | - @title = "Translations Manager"
2 |
3 | doctype html
4 | html
5 | head
6 | title = @title
7 |
8 | = csrf_meta_tags
9 | meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0"
10 |
11 | = render "layouts/rails_i18n_manager/assets"
12 |
13 | body
14 | nav.navbar.navbar-expand-lg.navbar-light.bg-success.fixed-top
15 | .container-fluid
16 | a.navbar-brand = "#{@title}"
17 |
18 | button.navbar-toggler type="button" data-bs-toggle="collapse" data-bs-target="#navbar-list" aria-controls="navbar-list" aria-expanded="false" aria-label="Toggle navigation"
19 | span.navbar-toggler-icon
20 |
21 | #navbar-list.collapse.navbar-collapse
22 | ul.navbar-nav.me-auto.mb-2.mb-lg-0
23 | li.nav-item
24 | a.nav-link class=("active" if params[:controller].split("/").last == "translations") href=translations_path Translations
25 | li.nav-item
26 | a.nav-link class=("active" if params[:controller].split("/").last == "translation_apps") href=translation_apps_path = RailsI18nManager::TranslationApp::NAME.pluralize
27 |
28 | .container-fluid
29 | = render "rails_i18n_manager/shared/flash"
30 |
31 | = yield
32 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide content security policy
4 | # For further information see the following documentation
5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
6 |
7 | # Rails.application.config.content_security_policy do |policy|
8 | # policy.default_src :self, :https
9 | # policy.font_src :self, :https, :data
10 | # policy.img_src :self, :https, :data
11 | # policy.object_src :none
12 | # policy.script_src :self, :https
13 | # policy.style_src :self, :https
14 |
15 | # # Specify URI for violation reports
16 | # # policy.report_uri "/csp-violation-report-endpoint"
17 | # end
18 |
19 | # If you are using UJS then enable automatic nonce generation
20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
21 |
22 | # Set the nonce only to specific directives
23 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src)
24 |
25 | # Report CSP violations to a specified URI
26 | # For further information see the following documentation:
27 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
28 | # Rails.application.config.content_security_policy_report_only = true
29 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/_utility_css.html.erb:
--------------------------------------------------------------------------------
1 |
69 |
--------------------------------------------------------------------------------
/lib/rails_i18n_manager.rb:
--------------------------------------------------------------------------------
1 | require "rails_i18n_manager/engine"
2 | require "rails_i18n_manager/config"
3 |
4 | module RailsI18nManager
5 |
6 | def self.config(&block)
7 | c = RailsI18nManager::Config
8 |
9 | if block_given?
10 | block.call(c)
11 | else
12 | return c
13 | end
14 | end
15 |
16 | def self.fetch_flattened_dot_notation_keys(translations_hash)
17 | keys = []
18 |
19 | translations_hash.each do |_locale, h|
20 | h.each do |k,v|
21 | _recursive_fetch_keys(list: keys, key: k, val: v)
22 | end
23 | end
24 |
25 | return keys.uniq
26 | end
27 |
28 | def self._recursive_fetch_keys(list:, key:, val:, prev_dot_notation_key: nil)
29 | if prev_dot_notation_key
30 | dot_notation_key = [prev_dot_notation_key, key].compact.join(".")
31 | else
32 | dot_notation_key = key
33 | end
34 |
35 | if val.is_a?(Hash)
36 | val.each do |inner_key, inner_val|
37 | _recursive_fetch_keys(list: list, key: inner_key, val: inner_val, prev_dot_notation_key: dot_notation_key)
38 | end
39 | else
40 | list << dot_notation_key
41 | end
42 | end
43 |
44 | def self.hash_deep_set(hash, keys_array, val)
45 | if !hash.is_a?(::Hash)
46 | raise TypeError.new("Invalid object passed to #{__method__}, must be a Hash")
47 | end
48 |
49 | keys_array[0...-1].inject(hash){|result, key|
50 | if !result[key].is_a?(Hash)
51 | result[key] = {}
52 | end
53 |
54 | result[key]
55 | }.send(:[]=, keys_array.last, val)
56 |
57 | return hash
58 | end
59 |
60 | end
61 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_i18n_manager/_assets.html.slim:
--------------------------------------------------------------------------------
1 | script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" referrerpolicy="no-referrer"
2 |
3 | link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous"
4 | script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"
5 |
6 | = render "layouts/rails_i18n_manager/utility_css"
7 | = render "layouts/rails_i18n_manager/app_css"
8 |
9 | script src="https://cdn.jsdelivr.net/npm/@rails/ujs@7.0.4-3/lib/assets/compiled/rails-ujs.min.js"
10 |
11 | link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/slim-select/2.4.5/slimselect.min.css" integrity="sha512-GvqWM4KWH8mbgWIyvwdH8HgjUbyZTXrCq0sjGij9fDNiXz3vJoy3jCcAaWNekH2rJe4hXVWCJKN+bEW8V7AAEQ==" crossorigin="anonymous" referrerpolicy="no-referrer"
12 | script src="https://cdnjs.cloudflare.com/ajax/libs/slim-select/2.4.5/slimselect.global.min.js" integrity="sha512-r2ujllVbPV4gVNZyqAB6LS3cnpEenEl18yFYoowmutUX5zVXQi5mp13lMWv3FQpsn96eFJTcd5VqBkZuatGtWQ==" crossorigin="anonymous" referrerpolicy="no-referrer"
13 |
14 | script src="https://cdnjs.cloudflare.com/ajax/libs/autosize.js/3.0.20/autosize.min.js" integrity="sha512-EAEoidLzhKrfVg7qX8xZFEAebhmBMsXrIcI0h7VPx2CyAyFHuDvOAUs9CEATB2Ou2/kuWEDtluEVrQcjXBy9yw==" crossorigin="anonymous" referrerpolicy="no-referrer"
15 |
16 | = render "layouts/rails_i18n_manager/app_javascript"
17 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/_form.html.slim:
--------------------------------------------------------------------------------
1 | - view_mode = params[:action] == "show"
2 |
3 | = custom_form_for @translation_key, url: translation_path(@translation_key), method: :patch, defaults: {view_mode: view_mode, field_layout: :horizontal} do |f|
4 | = f.view_field label: "App Name", value: @translation_key.translation_app.name
5 | = f.view_field label: "Key", value: @translation_key.key, help_text: ("Nested Keys are denoted with dot (.)" if @translation_key.key.include?("."))
6 |
7 | - if !@translation_key.active
8 | = f.view_field label: "Status", value: "Inactive"
9 | = link_to "Delete", {action: :destroy, id: @translation_key.id}, method: :delete, class: "btn btn-danger btn-sm space-left3", "data-confirm" => "Are you sure you want to delete this record?"
10 |
11 | = render "sub_nav"
12 |
13 | .translations-container
14 | - sorted_translation_values = []
15 |
16 | - @translation_key.translation_app.all_locales.each do |locale|
17 | - val_record = @translation_key.translation_values.detect{|x| x.locale == locale.to_s }
18 | - if val_record.nil?
19 | - val_record = @translation_key.translation_values.new(locale: locale)
20 | - sorted_translation_values << val_record
21 |
22 | = f.fields_for :translation_values, sorted_translation_values do |f2|
23 | = render "translation_value_fields", f: f2
24 |
25 | - if !view_mode
26 | .form-group
27 | .col-lg-offset-2.col-md-offset-2.col-sm-offset-3.col-lg-10.col-md-10.col-sm-9
28 | button.btn.btn-primary type="submit" Save
29 | = link_to "Cancel", {action: :index}, class: 'btn btn-secondary space-left2'
30 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translation_apps/form.html.slim:
--------------------------------------------------------------------------------
1 | = render "breadcrumbs"
2 |
3 | h2
4 | - if @translation_app.new_record?
5 | | New Translation App
6 | - else
7 | | Edit Translation App
8 |
9 | - url = @translation_app.new_record? ? translation_apps_path : translation_app_path(@translation_app)
10 | - method = @translation_app.new_record? ? :post : :patch
11 |
12 | - view_mode = params[:action] == "show"
13 |
14 | = custom_form_for @translation_app, url: url, method: method, defaults: {view_mode: view_mode}, html: {class: "form-horizontal"} do |f|
15 | = f.error_notification
16 |
17 | = f.field :name, type: :text
18 |
19 | = f.field :default_locale, type: :select, collection: RailsI18nManager.config.valid_locales.dup, selected: @translation_app.default_locale, include_blank: f.object.default_locale.nil?, input_html: {style: "width:120px;"}
20 |
21 | = f.field :additional_locales, type: :select, collection: RailsI18nManager.config.valid_locales.dup, selected: @translation_app.additional_locales_array, include_blank: false, input_html: {multiple: true}, help_text: "Warning: Removing any locale will result in its translations being deleted."
22 |
23 | - if !view_mode
24 | .form-group
25 | button.btn.btn-primary type="submit" Save
26 |
27 | = link_to "Cancel", {action: :index}, class: 'btn btn-secondary space-left2'
28 |
29 | - if !@translation_app.new_record?
30 | = link_to "Delete", {action: :destroy, id: @translation_app.id}, method: :delete, data: { confirm: "WARNING: All the associated translations will be deleted.\n\nAre you sure you want to delete this translation app?" }, class: 'btn btn-danger space-left2'
31 |
--------------------------------------------------------------------------------
/spec/dummy/config/puma.rb:
--------------------------------------------------------------------------------
1 | # Puma can serve each request in a thread from an internal thread pool.
2 | # The `threads` method setting takes two numbers: a minimum and maximum.
3 | # Any libraries that use thread pools should be configured to match
4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
5 | # and maximum; this matches the default thread size of Active Record.
6 | #
7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
9 | threads min_threads_count, max_threads_count
10 |
11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
12 | #
13 | port ENV.fetch("PORT") { 3000 }
14 |
15 | # Specifies the `environment` that Puma will run in.
16 | #
17 | environment ENV.fetch("RAILS_ENV") { "development" }
18 |
19 | # Specifies the `pidfile` that Puma will use.
20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
21 |
22 | # Specifies the number of `workers` to boot in clustered mode.
23 | # Workers are forked web server processes. If using threads and workers together
24 | # the concurrency of the application would be max `threads` * `workers`.
25 | # Workers do not work on JRuby or Windows (both of which do not support
26 | # processes).
27 | #
28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
29 |
30 | # Use the `preload_app!` method when specifying a `workers` number.
31 | # This directive tells Puma to first boot the application and load code
32 | # before forking the application. This takes advantage of Copy On Write
33 | # process behavior so workers use less memory.
34 | #
35 | # preload_app!
36 |
37 | # Allow puma to be restarted by `rails restart` command.
38 | plugin :tmp_restart
39 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require "rails_helper"
2 | require "database_cleaner"
3 | require "factory_bot_rails"
4 | require "faker"
5 | require "minitest_change_assertions"
6 | require "rspec-html-matchers"
7 |
8 | require "active_support/all"
9 |
10 | RSpec.configure do |config|
11 | config.include RSpecHtmlMatchers
12 |
13 | config.expect_with :rspec do |expectations|
14 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
15 | end
16 |
17 | config.mock_with :rspec do |mocks|
18 | mocks.verify_partial_doubles = true
19 | end
20 |
21 | config.shared_context_metadata_behavior = :apply_to_host_groups
22 |
23 | config.example_status_persistence_file_path = "spec/examples.txt"
24 |
25 | config.disable_monkey_patching!
26 |
27 | config.order = :random
28 |
29 | Kernel.srand(config.seed)
30 |
31 | config.before(:suite) do
32 | ### PERFORMS CLEAN IMMEDIATELY
33 | DatabaseCleaner.clean_with(:truncation)
34 |
35 | #DatabaseCleaner.strategy = :truncation, { except: [], pre_count: true }
36 | DatabaseCleaner.strategy = :transaction
37 | end
38 |
39 | config.around(:each) do |example|
40 | DatabaseCleaner.cleaning do
41 | example.run
42 | end
43 | end
44 |
45 | config.before(:each) do
46 | RailsI18nManager.config.google_translate_api_key = "foobar"
47 | end
48 |
49 | require "rails-controller-testing"
50 | RSpec.configure do |config|
51 | [:controller, :view, :request].each do |type|
52 | config.include ::Rails::Controller::Testing::TestProcess, type: type
53 | config.include ::Rails::Controller::Testing::TemplateAssertions, :type => type
54 | config.include ::Rails::Controller::Testing::Integration, :type => type
55 | end
56 | end
57 |
58 | end
59 |
--------------------------------------------------------------------------------
/app/lib/rails_i18n_manager/google_translate.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | module GoogleTranslate
3 |
4 | def self.translate(text, from:, to:)
5 | api_key = RailsI18nManager.config.google_translate_api_key
6 |
7 | if !SUPPORTED_LOCALES.include?(to.to_s) || Rails.env.test? || (api_key.blank? && Rails.env.development?)
8 | return false
9 | end
10 |
11 | if text.include?("<") && text.include?(">")
12 | ### Dont translate any HTML strings
13 | return false
14 | end
15 |
16 | translated_text = EasyTranslate.translate(text, from: from, to: to, key: api_key)
17 |
18 | if translated_text.present?
19 | ### Replace single quote html entity with single quote character
20 | translated_text = translated_text.gsub("'", "'")
21 |
22 | if to.to_s == "es"
23 | translated_text = translated_text.gsub("% {", " %{").strip
24 | end
25 |
26 | return translated_text
27 | end
28 | end
29 |
30 | ### List retrieved from Google Translate (2022)
31 | SUPPORTED_LOCALES = ["af", "am", "ar", "az", "be", "bg", "bn", "bs", "ca", "ceb", "co", "cs", "cy", "da", "de", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fr", "fy", "ga", "gd", "gl", "gu", "ha", "haw", "he", "hi", "hmn", "hr", "ht", "hu", "hy", "id", "ig", "is", "it", "iw", "ja", "jw", "ka", "kk", "km", "kn", "ko", "ku", "ky", "la", "lb", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my", "ne", "nl", "no", "ny", "or", "pa", "pl", "ps", "pt", "ro", "ru", "rw", "sd", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "tk", "tl", "tr", "tt", "ug", "uk", "ur", "uz", "vi", "xh", "yi", "yo", "zh", "zh-CN", "zh-TW", "zu"].freeze
32 |
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/rails_i18n_manager.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("lib", __dir__)
2 |
3 | # Maintain your gem's version:
4 | require "rails_i18n_manager/version"
5 |
6 | # Describe your gem and declare its dependencies:
7 | Gem::Specification.new do |spec|
8 | spec.name = "rails_i18n_manager"
9 | spec.version = RailsI18nManager::VERSION
10 | spec.authors = ["Weston Ganger"]
11 | spec.email = ["weston@westonganger.com"]
12 | spec.homepage = "https://github.com/westonganger/rails_i18n_manager"
13 | spec.summary = "Web interface to manage i18n translations for your apps to facilitate the editors of your translations. Provides a low-tech and complete workflow for importing, translating, and exporting your I18n translation files. Design to allows you to keep the translation files inside your projects git repository where they should be."
14 | spec.description = spec.summary
15 | spec.license = "MIT"
16 |
17 | spec.files = Dir["{app,config,db,lib,public}/**/*", "LICENSE", "Rakefile", "README.md"]
18 |
19 | spec.add_dependency "rails", ">= 6.0"
20 | spec.add_dependency "slim"
21 | spec.add_dependency "kaminari"
22 | spec.add_dependency "active_sort_order"
23 | spec.add_dependency "easy_translate" ### no grpc dependency
24 | spec.add_dependency "rubyzip"
25 | spec.add_dependency "activerecord-import"
26 |
27 | spec.add_development_dependency "sqlite3"
28 | spec.add_development_dependency "rspec-rails"
29 | spec.add_development_dependency "rspec-html-matchers"
30 | spec.add_development_dependency "factory_bot_rails"
31 | spec.add_development_dependency "database_cleaner"
32 | spec.add_development_dependency "rails-controller-testing"
33 | spec.add_development_dependency "faker"
34 | spec.add_development_dependency "minitest_change_assertions"
35 | end
36 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 | branches: ['master']
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 |
11 | env:
12 | RAILS_ENV: test
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | include:
18 | ### TEST RUBY VERSIONS
19 | - ruby: "2.7"
20 | - ruby: "3.0"
21 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue
22 | - ruby: "3.1"
23 | - ruby: "3.2"
24 | - ruby: "3.3"
25 | ### TEST RAILS VERSIONS
26 | - ruby: "2.7"
27 | rails_version: "~> 6.0.0"
28 | - ruby: "2.7"
29 | rails_version: "~> 6.1.0"
30 | - ruby: "3.3"
31 | rails_version: "~> 7.0.0"
32 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue
33 | - ruby: "3.3"
34 | rails_version: "~> 7.1.0"
35 | - ruby: "3.3"
36 | rails_version: "~> 7.2.0"
37 | - ruby: "3.3"
38 | rails_version: "~> 8.0.0"
39 |
40 | steps:
41 | - uses: actions/checkout@v3
42 |
43 | - name: Set env variables
44 | run: |
45 | echo "RAILS_VERSION=${{ matrix.rails_version }}" >> "$GITHUB_ENV"
46 | echo "DB_GEM=${{ matrix.db_gem }}" >> "$GITHUB_ENV"
47 | echo "DB_GEM_VERSION=${{ matrix.db_gem_version }}" >> "$GITHUB_ENV"
48 |
49 | - name: Install ruby
50 | uses: ruby/setup-ruby@v1
51 | with:
52 | ruby-version: "${{ matrix.ruby }}"
53 | bundler-cache: false ### not compatible with ENV-style Gemfile
54 |
55 | - name: Run test
56 | run: |
57 | bundle install
58 | bundle exec rake db:create
59 | bundle exec rake db:migrate
60 | bundle exec rake test
61 |
--------------------------------------------------------------------------------
/spec/rails_helper.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | ENV["RAILS_ENV"] ||= "test"
4 |
5 | require_relative "dummy/config/environment"
6 |
7 | abort("The Rails environment is running in production mode!") if Rails.env.production?
8 |
9 | require "rspec/rails"
10 |
11 | begin
12 | ActiveRecord::Migration.maintain_test_schema!
13 | rescue ActiveRecord::PendingMigrationError => e
14 | puts e.to_s.strip
15 | exit 1
16 | end
17 |
18 | RSpec.configure do |config|
19 | config.use_transactional_fixtures = true
20 |
21 | config.infer_spec_type_from_file_location!
22 |
23 | config.filter_rails_from_backtrace!
24 | end
25 |
26 | if Rails::VERSION::STRING.to_f <= 6.0
27 | def assert_no_difference(expression, message = nil, &block)
28 | assert_difference expression, 0, message, &block
29 | end
30 |
31 | def assert_difference(expression, *args, &block)
32 | expressions =
33 | if expression.is_a?(Hash)
34 | message = args[0]
35 | expression
36 | else
37 | difference = args[0] || 1
38 | message = args[1]
39 | Array(expression).index_with(difference)
40 | end
41 |
42 | exps = expressions.keys.map { |e|
43 | e.respond_to?(:call) ? e : lambda { eval(e, block.binding) }
44 | }
45 | before = exps.map(&:call)
46 |
47 | retval = assert_nothing_raised(&block)
48 |
49 | expressions.zip(exps, before) do |(code, diff), exp, before_value|
50 | actual = exp.call
51 | error = "#{code.inspect} didn't change by #{diff}, but by #{actual - before_value}"
52 | error = "#{message}.\n#{error}" if message
53 | assert_equal(before_value + diff, actual, error)
54 | end
55 |
56 | retval
57 | end
58 |
59 | def assert_nothing_raised
60 | yield.tap { assert(true) }
61 | rescue => error
62 | raise Minitest::UnexpectedError.new(error)
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/spec/dummy/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/spec/dummy/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/spec/dummy/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/app/controllers/rails_i18n_manager/translation_apps_controller.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationAppsController < ApplicationController
3 | before_action :set_translation_app, only: [:show, :edit, :update, :destroy]
4 |
5 | def index
6 | @translation_apps = TranslationApp
7 | .sort_order(params[:sort], params[:direction], base_sort_order: "#{TranslationApp.table_name}.name ASC")
8 | .multi_search(params[:search])
9 | .page(params[:page])
10 | end
11 |
12 | def new
13 | @translation_app = TranslationApp.new
14 | render "form"
15 | end
16 |
17 | def create
18 | @translation_app = TranslationApp.new(permitted_params)
19 |
20 | if @translation_app.save
21 | flash[:notice] = "Successfully created."
22 | redirect_to action: :edit, id: @translation_app.id
23 | else
24 | flash.now[:error] = "Create failed."
25 | render "rails_i18n_manager/translation_apps/form"
26 | end
27 | end
28 |
29 | def show
30 | redirect_to action: :edit
31 | end
32 |
33 | def edit
34 | render "form"
35 | end
36 |
37 | def update
38 | if @translation_app.update(permitted_params)
39 | flash[:notice] = "Update success."
40 | redirect_to action: :index
41 | else
42 | flash.now[:error] = "Update failed."
43 | render "form"
44 | end
45 | end
46 |
47 | def destroy
48 | if @translation_app.destroy
49 | flash[:notice] = "Deleted '#{@translation_app.name}'"
50 | else
51 | flash[:alert] = "Delete failed"
52 | end
53 | redirect_to action: :index
54 | end
55 |
56 | private
57 |
58 | def set_translation_app
59 | @translation_app = TranslationApp.find_by!(id: params[:id])
60 | end
61 |
62 | def set_browser_title
63 | @browser_title = TranslationApp::NAME.pluralize
64 | end
65 |
66 | def permitted_params
67 | params.require(:translation_app).permit(:name, :default_locale, additional_locales: [])
68 | end
69 |
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/import.html.slim:
--------------------------------------------------------------------------------
1 | = render "breadcrumbs"
2 |
3 | h2.page-sub-title Import Translations from Source File
4 |
5 | .row
6 | .col-6
7 | = custom_form_for @form, as: :import_form, url: import_translations_path, method: :post, html: {multipart: true, class: "form-horizontal"} do |f|
8 | = f.error_notification
9 |
10 | = f.field :translation_app_id, label: "App Name", type: :select, collection: RailsI18nManager::TranslationApp.order(name: :asc).pluck(:name, :id)
11 |
12 | = f.field :file, type: :file, label: "Translation File", help_text: "Allowed file types: yml, json"
13 |
14 | = f.field :mark_inactive_translations, type: :checkbox, label: "Mark Inactive Translations?", help_text: "Any translation keys not found in the source file will be marked as 'Inactive' while found keys will be marked 'Active'. Marking a translation key as inactive excludes it from any data export and allows it to be deletable. Do not check this if you are uploading a partial translation file."
15 |
16 | = f.field :overwrite_existing, type: :checkbox, label: "Overwrite existing translations?", help_text: "When enabled, if an existing translations exists it will be overwritten with the one contained in the file. If an outdated translation file is uploaded then it has the potential overwrite valuable translations in the app.", input_html: {"onclick" => 'if($(this).is(":checked")) alert("WARNING!\n\nEnabling overwrite can potentially be a highly destructive action. If an outdated translation file is uploaded then it has the potential overwrite valuable translations in the app. Please use caution.")'}
17 |
18 | .form-group
19 | button.btn.btn-primary type="submit" Save
20 | = link_to "Cancel", {action: :index}, class: 'btn btn-secondary space-left'
21 |
22 | .col-6
23 | .alert.alert-dark.permanent
24 | p This action will add translations that exist in the source file but do not exist in the database.
25 | p This import will not delete any existing translations.
26 | p You may import partial translations files with only some of the translation keys.
27 |
--------------------------------------------------------------------------------
/app/helpers/rails_i18n_manager/application_helper.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | module ApplicationHelper
3 |
4 | def custom_form_for(*args, **options, &block)
5 | options[:builder] = CustomFormBuilder
6 | if options.has_key?(:defaults)
7 | @_custom_form_for_defaults = options.delete(:defaults)
8 | end
9 | form_for(*args, options, &block)
10 | end
11 |
12 | def custom_fields_for(*args, **options, &block)
13 | options[:builder] = CustomFormBuilder
14 | if options.has_key?(:defaults)
15 | @_custom_form_for_defaults = options.delete(:defaults)
16 | end
17 | fields_for(*args, options, &block)
18 | end
19 |
20 | def breadcrumb_item(title, url=nil)
21 | if url.nil?
22 | %Q(#{title}).html_safe
23 | else
24 | %Q(#{title}).html_safe
25 | end
26 | end
27 |
28 | def sort_link(attr_name, label=nil)
29 | if label.blank?
30 | label = attr_name.to_s.titleize
31 | end
32 |
33 | direction = params[:direction].present? && params[:direction].casecmp?("asc") ? 'desc' : 'asc'
34 |
35 | link_to label, params.to_unsafe_h.merge(sort: attr_name, direction: direction)
36 | end
37 |
38 | def nav_link(name, url, html_options={}, &block)
39 | url = url_for(url)
40 |
41 | if html_options.has_key?(:active)
42 | active = html_options.delete(:active)
43 | elsif url == (url.include?("?") ? request.fullpath : request.path)
44 | active = true
45 | end
46 |
47 | html_options[:class] ||= ""
48 |
49 | html_options[:class] += " nav-link"
50 |
51 | if active
52 | html_options[:class] += " active"
53 | end
54 |
55 | html_options[:class].strip!
56 |
57 | content_tag(:li, class: "nav-item #{'active' if active}".strip) do
58 | link_to(name, url, html_options) + (
59 | if block_given?
60 | content_tag(:ul) do
61 | capture(&block)
62 | end
63 | end
64 | )
65 | end
66 | end
67 |
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/app/lib/rails_i18n_manager/forms/translation_file_form.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | module Forms
3 | class TranslationFileForm < Base
4 |
5 | attr_accessor :translation_app_id, :file, :overwrite_existing
6 | attr_reader :overwrite_existing, :mark_inactive_translations
7 |
8 | validates :translation_app_id, presence: {message: "Must select an App"}
9 | validates :file, presence: true
10 | validate :validate_file
11 |
12 | def overwrite_existing=(val)
13 | @overwrite_existing = ["1", "true", "t"].include?(val.to_s.downcase)
14 | end
15 |
16 | def mark_inactive_translations=(val)
17 | @mark_inactive_translations = ["1", "true", "t"].include?(val.to_s.downcase)
18 | end
19 |
20 | def file_extname
21 | @file_extname ||= File.extname(file)
22 | end
23 |
24 | def file_contents_string
25 | @file_contents_string ||= file.read
26 | end
27 |
28 | def parsed_file_contents
29 | if defined?(@parsed_file_contents)
30 | return @parsed_file_contents
31 | end
32 |
33 | case file_extname
34 | when ".yml", ".yaml"
35 | @parsed_file_contents = YAML.safe_load(file_contents_string, permitted_classes: [Symbol])
36 | when ".json"
37 | @parsed_file_contents = JSON.parse(file_contents_string)
38 | end
39 | end
40 |
41 | def validate_file
42 | if file.blank?
43 | errors.add(:file, "Must upload a valid translation file.")
44 | return
45 | end
46 |
47 | if [".yml", ".yaml", ".json"].exclude?(file_extname)
48 | errors.add(:file, "Invalid file format. Must be yaml or json file.")
49 | return
50 | end
51 |
52 | if file_contents_string.blank?
53 | errors.add(:file, "Empty file provided.")
54 | return
55 | end
56 |
57 | case file_extname
58 | when ".yml", ".yaml"
59 | if !parsed_file_contents.is_a?(Hash)
60 | errors.add(:file, "Invalid #{file_extname.sub(".","")} file.")
61 | return
62 | end
63 |
64 | when ".json"
65 | begin
66 | parsed_file_contents
67 | rescue JSON::ParserError
68 | errors.add(:file, "Invalid json file.")
69 | return
70 | end
71 | end
72 | end
73 |
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/spec/unit/models/translation_app_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module RailsI18nManager
4 | RSpec.describe TranslationApp, type: :model do
5 |
6 | let(:translation_app) { FactoryBot.create(:translation_app, default_locale: :en, additional_locales: [:fr]) }
7 |
8 | context "validations" do
9 | it "requires a default locale" do
10 | translation_app.default_locale = nil
11 | translation_app.valid?
12 | expect(translation_app.errors[:default_locale]).to be_present
13 | end
14 |
15 | it "allows empty (additional) locales" do
16 | translation_app.additional_locales = []
17 | expect(translation_app.valid?).to eq(true)
18 | end
19 | end
20 |
21 | context "additional_locales" do
22 | it "removes default locale if included" do
23 | expect(translation_app.default_locale).not_to eq("fr")
24 | expect(translation_app.additional_locales_array).to eq(["fr"])
25 | translation_app.additional_locales = translation_app.additional_locales_array + [translation_app.default_locale]
26 | expect(translation_app.additional_locales_array).to eq(["fr"])
27 | end
28 | end
29 |
30 | context "handle_added_locales" do
31 | it "doesnt create the new blank records" do
32 | translation_app.translation_keys.create!(key: :foo)
33 | translation_app.translation_keys.create!(key: :bar)
34 |
35 | num_keys = translation_app.translation_keys.count
36 | expect(TranslationValue.where(translation_key_id: translation_app.translation_key_ids).count).to eq(0)
37 |
38 | translation_app.update!(additional_locales: [:en, :fr, :es])
39 | expect(TranslationValue.where(translation_key_id: translation_app.translation_key_ids).count).to eq(0)
40 | end
41 | end
42 |
43 | context "handle_removed_locales" do
44 | it "deletes all translations values for the removed locale" do
45 | translation_app.update!(additional_locales: ["fr", "es"])
46 |
47 | FactoryBot.create(:translation_key, :with_translation_values, translation_app: translation_app)
48 | FactoryBot.create(:translation_key, :with_translation_values, translation_app: translation_app)
49 |
50 | num_keys = translation_app.translation_keys.count
51 | expect(TranslationValue.where(translation_key_id: translation_app.translation_key_ids).count).to eq(6)
52 |
53 | translation_app.update!(additional_locales: ["fr"])
54 | expect(TranslationValue.where(translation_key_id: translation_app.translation_key_ids).count).to eq(4)
55 | end
56 | end
57 |
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/spec/unit/google_translate_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | RSpec.describe RailsI18nManager::GoogleTranslate, type: :model do
4 |
5 | before do
6 | allow(Rails.env).to receive(:test?).and_return(false)
7 | allow(RailsI18nManager.config).to receive(:google_translate_api_key).and_return("some-api-key")
8 | end
9 |
10 | context "translate" do
11 | it "returns false for unsupported locales" do
12 | expect(RailsI18nManager::GoogleTranslate.translate("foo", from: "bar", to: "baz")).to eq(false)
13 | end
14 |
15 | it "returns false in test environment" do
16 | allow(Rails.env).to receive(:test?).and_return(true)
17 | expect(RailsI18nManager::GoogleTranslate.translate("foo", from: "en", to: "es")).to eq(false)
18 | end
19 |
20 | it "returns false in development environment if api key is missing" do
21 | allow(RailsI18nManager.config).to receive(:google_translate_api_key).and_return(nil)
22 | allow(Rails.env).to receive(:development?).and_return(true)
23 | expect(RailsI18nManager::GoogleTranslate.translate("foo", from: "en", to: "es")).to eq(false)
24 | end
25 |
26 | it "returns false if HTML string provided" do
27 | expect(RailsI18nManager::GoogleTranslate.translate("", from: "en", to: "es")).to eq(false)
28 |
29 | allow(EasyTranslate).to receive(:translate).and_return("foo")
30 | expect(RailsI18nManager::GoogleTranslate.translate("", from: "en", to: "es")).to eq("foo")
32 | end
33 |
34 | it "replaces single quote HTML entities with actual single quotes" do
35 | allow(EasyTranslate).to receive(:translate).and_return("'foo'")
36 | expect(RailsI18nManager::GoogleTranslate.translate("unrelated", from: "en", to: "es")).to eq("'foo'")
37 | end
38 |
39 | it "replaces '% {' with ' %{' for es locale" do
40 | allow(EasyTranslate).to receive(:translate).and_return("% {foo")
41 | expect(RailsI18nManager::GoogleTranslate.translate("unrelated", from: "en", to: "es")).to eq("%{foo")
42 | end
43 |
44 | it "returns nil if text was not able to be translated" do
45 | allow(EasyTranslate).to receive(:translate).and_return(nil)
46 | expect(RailsI18nManager::GoogleTranslate.translate("unrelated", from: "en", to: "es")).to eq(nil)
47 | end
48 |
49 | it "returns translated text" do
50 | allow(EasyTranslate).to receive(:translate).and_return("bonjour")
51 | expect(RailsI18nManager::GoogleTranslate.translate("hello", from: "en", to: "fr")).to eq("bonjour")
52 | end
53 | end
54 |
55 | end
56 |
--------------------------------------------------------------------------------
/app/lib/rails_i18n_manager/translations_importer.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationsImporter
3 |
4 | class ImportAbortedError < StandardError; end
5 |
6 | def self.import(translation_app_id:, parsed_file_contents:, overwrite_existing: false, mark_inactive_translations: false)
7 | app_record = TranslationApp.find(translation_app_id)
8 |
9 | new_locales = parsed_file_contents.keys - app_record.all_locales
10 |
11 | if new_locales.any?
12 | raise ImportAbortedError.new("Import aborted. Locale not listed in translation app: #{new_locales.join(', ')}")
13 | end
14 |
15 | all_keys = RailsI18nManager.fetch_flattened_dot_notation_keys(parsed_file_contents)
16 |
17 | key_records_by_key = app_record.translation_keys.includes(:translation_values).index_by(&:key)
18 |
19 | all_keys.each do |key|
20 | if key_records_by_key[key].nil?
21 | key_records_by_key[key] = app_record.translation_keys.new(key: key)
22 | key_records_by_key[key].save!
23 | end
24 | end
25 |
26 | translation_values_to_import = []
27 |
28 | key_records_by_key.each do |key, key_record|
29 | app_record.all_locales.each do |locale|
30 | split_keys = [locale] + key.split(".").map{|x| x}
31 |
32 | val = nil
33 |
34 | current_hash = parsed_file_contents
35 |
36 | split_keys.each do |k|
37 | if current_hash[k].is_a?(Hash)
38 | current_hash = current_hash[k]
39 | else
40 | val = current_hash[k]
41 | break
42 | end
43 | end
44 |
45 | if val.present?
46 | val_record = key_record.translation_values.detect{|x| x.locale == locale.to_s }
47 |
48 | if val_record.nil?
49 | translation_values_to_import << key_record.translation_values.new(locale: locale, translation: val)
50 | elsif val_record.translation.blank? || (overwrite_existing && val_record.translation != val)
51 | val_record.update!(translation: val)
52 | next
53 | end
54 | end
55 | end
56 | end
57 |
58 | ### We use active_record-import for big speedup, set validate false if more speed required
59 | TranslationValue.import(translation_values_to_import, validate: true)
60 |
61 | if mark_inactive_translations
62 | app_record.translation_keys
63 | .where.not(key: all_keys)
64 | .update_all(active: false)
65 |
66 | app_record.translation_keys
67 | .where(key: all_keys)
68 | .update_all(active: true)
69 | end
70 |
71 | return true
72 | end
73 |
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/translations/index.html.slim:
--------------------------------------------------------------------------------
1 | .pull-right
2 | = link_to "Translate with Google", translate_missing_translations_path(filters: params[:filters].to_unsafe_h), class: "btn btn-primary btn-sm", "data-method" => "post", "data-confirm" => "Are you sure you want to proceed with translating the missing translations in the currently filtered list?"
3 | = link_to "Import Translations", import_translations_path, class: "btn btn-secondary btn-sm space-left2"
4 | = link_to "Delete Inactive", delete_inactive_keys_translations_path(filters: params[:filters].to_unsafe_h), class: "btn btn-danger btn-sm space-left2", "data-method" => "delete", "data-confirm" => "Warning! This is a highly destructive action.\n\nIts possible to incorrectly upload an incomplete or incorrect file to 'Mark Inactive Translations from Source' which can leave you with inactive keys that maybe shouldnt have been inactivated.\n\nPlease proceed only if you are certain that you do not have any keys that are incorrectly marked inactive.\n\nAre you sure you want to proceed with deleting the inactive translations in the currently filtered list?"
5 |
6 | h2.page-title Translations
7 | - if params[:app_name].present?
8 | h5.page-title App Name: #{params[:app_name]}
9 |
10 | br
11 |
12 | .well.well-sm
13 | .btn-group.pull-right.text-right
14 | = link_to "Export to CSV", translations_path(format: :csv, app_name: params[:app_name]), class: "btn btn-sm btn-success"
15 | = link_to "YAML", translations_path(format: :zip, export_format: :yaml, app_name: params[:app_name]), class: "btn btn-sm btn-success"
16 | = link_to "JSON", translations_path(format: :zip, export_format: :json, app_name: params[:app_name]), class: "btn btn-sm btn-success"
17 |
18 | .pull-right.space-right2
19 |
20 | = render "filter_bar"
21 |
22 | table.table.table-striped.table-hover.space-above3.list-table
23 | thead
24 | tr
25 | th = sort_link(:app_name)
26 | th = sort_link(:key)
27 | th Default Translation
28 | - if params[:status] != "All Active Translations"
29 | th Status
30 | th = sort_link(:updated_at)
31 | th Actions
32 | tbody
33 | - @translation_keys.each do |x|
34 | tr
35 | td = x.translation_app.name
36 | td = x.key
37 | td = x.default_translation
38 | - if params[:status] != "All Active Translations"
39 | td Inactive
40 | td = x.updated_at&.strftime("%Y-%m-%d %l:%M %p")
41 | td
42 | span = link_to "View", {action: :show, id: x.id}
43 |
44 | span.space-left2 = link_to "Edit", {action: :edit, id: x.id}
45 |
46 | - if !x.active
47 | span.space-left2 = link_to "Delete", {action: :destroy, id: x.id}, method: :delete, "data-confirm" => "Are you sure you want to delete this translation?"
48 |
49 | - if x.any_missing_translations?
50 | span.space-left2 = link_to "Translate with Google", translate_missing_translations_path(id: x.id), method: :post, "data-confirm" => "Are you sure you want to proceed with translating the missing translations for this entry?"
51 |
52 | = paginate @translation_keys, views_prefix: "rails_i18n_manager"
53 |
--------------------------------------------------------------------------------
/spec/request/translation_apps_controller_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | module RailsI18nManager
4 | RSpec.describe "TranslationAppsController", type: :request do
5 |
6 | let!(:translation_app){ FactoryBot.create(:translation_app) }
7 | let!(:translation_key){ FactoryBot.create(:translation_key, translation_app: translation_app) }
8 |
9 | context "index" do
10 | it "renders" do
11 | get rails_i18n_manager.translation_apps_path
12 | expect(response).to have_http_status(200)
13 |
14 | get rails_i18n_manager.translation_apps_path, params: {search: "foobarfoobar"}
15 | expect(response).to have_http_status(200)
16 |
17 | ["", 'asc','desc'].each do |direction|
18 | ['name'].each do |col|
19 | get rails_i18n_manager.translation_apps_path, params: {sort: col, direction: direction}
20 | expect(response).to have_http_status(200), "Error: #{direction} #{col}"
21 | end
22 | end
23 |
24 | get rails_i18n_manager.translation_app_path(translation_app)
25 | expect(response).to redirect_to(rails_i18n_manager.edit_translation_app_path(translation_app))
26 | end
27 | end
28 |
29 | context "new" do
30 | it "renders" do
31 | get rails_i18n_manager.new_translation_app_path
32 | expect(response).to have_http_status(200)
33 | end
34 | end
35 |
36 | context "create" do
37 | it "succeeds" do
38 | assert_changed ->(){ TranslationApp.count } do
39 | post rails_i18n_manager.translation_apps_path, params: {translation_app: {name: "some-new-app-name", default_locale: "en"}}
40 | expect(response).to redirect_to(rails_i18n_manager.edit_translation_app_path(TranslationApp.last))
41 | end
42 | end
43 |
44 | it "renders form when there are validation errors" do
45 | assert_not_changed ->(){ TranslationApp.count } do
46 | post rails_i18n_manager.translation_apps_path, params: {translation_app: {name: ""}}
47 | expect(response).to render_template("translation_apps/form")
48 | end
49 | end
50 | end
51 |
52 | context "edit" do
53 | it "renders" do
54 | get rails_i18n_manager.edit_translation_app_path(translation_app)
55 | expect(response).to have_http_status(200)
56 | end
57 | end
58 |
59 | context "update" do
60 | it "succeeds" do
61 | translation_app.update!(additional_locales: nil)
62 |
63 | assert_changed ->(){ translation_app.additional_locales_array } do
64 | patch rails_i18n_manager.translation_app_path(translation_app), params: {translation_app: {additional_locales: ['es','id']}}
65 | translation_app.reload
66 | end
67 | expect(response).to redirect_to(rails_i18n_manager.translation_apps_path)
68 | end
69 |
70 | it "renders form when there are validation errors" do
71 | assert_not_changed ->(){ translation_app.additional_locales_array } do
72 | patch rails_i18n_manager.translation_app_path(translation_app), params: {translation_app: {additional_locales: ['foobar']}}
73 |
74 | expect(response).to render_template("translation_apps/form")
75 | translation_app.reload
76 | end
77 | end
78 | end
79 |
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/app/models/rails_i18n_manager/translation_app.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationApp < ApplicationRecord
3 | NAME = "Translated App".freeze
4 |
5 | has_many :translation_keys, class_name: "RailsI18nManager::TranslationKey", dependent: :destroy
6 |
7 | before_validation :clean_additional_locales
8 | after_update :handle_removed_locales
9 | after_update :handle_added_locales
10 |
11 | validates :name, presence: true, uniqueness: {case_sensitive: false}
12 | validates :default_locale, presence: true
13 | validate :validate_additional_locales
14 |
15 | scope :search, ->(str){
16 | fields = [
17 | "#{table_name}.name",
18 | ]
19 |
20 | like = connection.adapter_name.downcase.to_s == "postgres" ? "ILIKE" : "LIKE"
21 |
22 | sql_conditions = []
23 |
24 | fields.each do |col|
25 | sql_conditions << "(#{col} #{like} :search)"
26 | end
27 |
28 | self.where(sql_conditions.join(" OR "), search: "%#{str}%")
29 | }
30 |
31 | def additional_locales=(val)
32 | if val.is_a?(Array)
33 | val = val.map{|x| x.to_s.downcase.strip.presence }.compact.uniq.sort
34 | val.delete(self.default_locale)
35 |
36 | self[:additional_locales] = val.join(",")
37 | else
38 | self[:additional_locales] = val
39 | end
40 | end
41 |
42 | def additional_locales_array
43 | additional_locales.to_s.split(",")
44 | end
45 |
46 | def all_locales
47 | [self.default_locale] + additional_locales_array
48 | end
49 |
50 | private
51 |
52 | def validate_additional_locales
53 | if additional_locales_changed?
54 | invalid_locales = []
55 |
56 | additional_locales_array.each do |locale|
57 | if !RailsI18nManager.config.valid_locales.include?(locale)
58 | invalid_locales << locale
59 | end
60 | end
61 |
62 | if invalid_locales.any?
63 | self.errors.add(:additional_locales, "Invalid locales: #{invalid_locales.join(", ")}")
64 | end
65 | end
66 | end
67 |
68 | def clean_additional_locales
69 | if additional_locales_changed?
70 | cleaned_array = additional_locales_array.map{|x| x.to_s.downcase.strip.presence }.compact.uniq.sort
71 | cleaned_array.delete(self.default_locale)
72 |
73 | self.additional_locales = cleaned_array.join(",")
74 | end
75 | end
76 |
77 | def handle_removed_locales
78 | if previous_changes.has_key?("default_locale") || previous_changes.has_key?("additional_locales")
79 | TranslationValue
80 | .joins(:translation_key).where(TranslationKey.table_name => {translation_app_id: self.id})
81 | .where.not(locale: all_locales)
82 | .delete_all ### instead of destroy_all, use delete_all for speedup
83 | end
84 | end
85 |
86 | def handle_added_locales
87 | ### ATTEMPTING TO JUST SKIP THIS
88 |
89 | # ### For new locales, create TranslationValue records
90 | # value_records_for_import = []
91 |
92 | # translation_keys.includes(:translation_values).each do |key_record|
93 | # additional_locales_array.each do |locale|
94 | # val_record = key_record.translation_values.detect{|x| x.locale == locale }
95 |
96 | # if val_record.nil?
97 | # value_records_for_import << key_record.translation_values.new(locale: locale)
98 | # end
99 | # end
100 | # end
101 |
102 | # ### We use active_record-import for big speedup also using validate: false for more speed
103 | # TranslationValue.import(value_records_for_import, validate: false)
104 | # end
105 | end
106 |
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/app/views/rails_i18n_manager/form_builder/_basic_field.html.erb:
--------------------------------------------------------------------------------
1 | <%
2 | if options[:field_layout].to_s == "horizontal"
3 | options[:field_wrapper_html][:class] ||= ""
4 | options[:field_wrapper_html][:class].concat(" row").strip!
5 |
6 | options[:label_wrapper_html][:class] ||= ""
7 | options[:label_wrapper_html][:class].concat(" col-md-1 col-form-label text-md-end").strip!
8 |
9 | options[:input_wrapper_html][:class] ||= ""
10 | options[:input_wrapper_html][:class].concat(" col-md-11").strip!
11 | end
12 |
13 | if type == :text_area
14 | options[:rows] = options[:rows] || 1
15 | end
16 |
17 | case type
18 | when :select
19 | if options.has_key?(:prompt)
20 | if !options[:prompt].is_a?(String)
21 | options[:prompt] = !!options[:prompt]
22 | end
23 | elsif options[:selected].blank? && !options[:include_blank]
24 | options[:prompt] = true
25 | end
26 |
27 | if options[:prompt] == true
28 | options[:prompt] = "Select..."
29 | end
30 |
31 | options[:input_html][:class] ||= ""
32 | options[:input_html][:class].concat(" form-select").strip!
33 | when :checkbox
34 | checkbox_label = options.delete(:label)
35 |
36 | options[:label_html][:class] ||= ""
37 | options[:label_html][:class].concat(" form-check-label").strip!
38 |
39 | options[:input_html][:class] ||= ""
40 | options[:input_html][:class].concat(" form-check-input").strip!
41 | else
42 | options[:input_html][:class] ||= ""
43 | if options[:input_html][:class].exclude?("form-control-plaintext")
44 | options[:input_html][:class].concat(" form-control").strip!
45 | end
46 | end
47 | %>
48 |
49 |
50 | <% field = capture do %>
51 | <% if type == :view %>
52 | <%= text_area_tag nil, options[:input_html][:value], options[:input_html] %>
53 | <% elsif type == :select %>
54 | <%=
55 | f.select(
56 | method,
57 | options[:collection],
58 | {
59 | include_blank: options[:include_blank],
60 | selected: options[:selected],
61 | prompt: options[:prompt],
62 | disabled: options[:disabled]
63 | },
64 | options[:input_html],
65 | )
66 | %>
67 | <% elsif type == :checkbox %>
68 | <%= f.check_box method, **options[:input_html] %>
69 | <%= f.label method, options[:label], **options[:label_html] do %>
70 | <%= checkbox_label %>
71 | <% end %>
72 | <% elsif type == :textarea %>
73 | <%= f.text_area method, options[:input_html] %>
74 | <% elsif type == :file %>
75 | <%= f.file_field method, options[:input_html] %>
76 | <% else %>
77 | <%= f.text_field method, options[:input_html] %>
78 | <% end %>
79 | <% end %>
80 |
81 | <%= content_tag :div, **options[:field_wrapper_html] do %>
82 | <%= content_tag :div, **options[:label_wrapper_html] do %>
83 | <% if options[:label] %>
84 | <%= f.label method, options[:label], **options[:label_html] do %>
85 | <%= options[:label] %>
86 | <% if options[:required] %>
87 | <%= options[:required_text] %>
88 | <% end %>
89 | <% end %>
90 | <% end %>
91 | <% end %>
92 |
93 | <%= content_tag :div, **options[:input_wrapper_html] do %>
94 | <%= field %>
95 |
96 | <% if options[:help_text] %>
97 |
98 | <%= options[:help_text].html_safe %>
99 |
100 | <% end %>
101 |
102 | <% if options[:errors].present? %>
103 |
104 | <% options[:errors].each do |error| %>
105 |
<%= error %>
106 | <% end %>
107 |
108 | <% end %>
109 | <% end %>
110 | <% end %>
111 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ### Unreleased - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.1.4...master)
4 | - Nothing yet
5 |
6 | ### v1.1.4 - February 14, 2025 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.1.3...v1.1.4)
7 | - [#37](https://github.com/westonganger/rails_i18n_manager/pull/37) - Add recommended I18n configuration to the README
8 | - [#36](https://github.com/westonganger/rails_i18n_manager/pull/36) - Many fixes on the Translations#index page
9 | - [#35](https://github.com/westonganger/rails_i18n_manager/pull/35) - Fix render issues when there were form validation errors
10 | - [#34](https://github.com/westonganger/rails_i18n_manager/pull/34) - Fix issue where CSS was missing utility classes for `float: right`
11 | - [#33](https://github.com/westonganger/rails_i18n_manager/pull/33) - Add suggested workflow for teams
12 |
13 | ### v1.1.3 - February 9, 2025 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.1.2...v1.1.3)
14 | - [#30](https://github.com/westonganger/rails_i18n_manager/pull/30) - Fix for Rails 6.x where the multipart form enctype was not being applied
15 | - [#29](https://github.com/westonganger/rails_i18n_manager/pull/29) - Add `permitted_classes: [Symbol]` to `YAML.safe_load` call so that it will not error if there are values that start with a colon character (:)
16 |
17 | ### v1.1.2 - February 4, 2025 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.1.1...v1.1.2)
18 | - [#28](https://github.com/westonganger/rails_i18n_manager/pull/28) - Dont use dig method in import which could result in exception `TypeError: Undefined method dig for String`
19 | - [#27](https://github.com/westonganger/rails_i18n_manager/pull/27) - Only call File.read once for import
20 |
21 | ### v1.1.1 - February 4, 2025 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.1.0...v1.1.1)
22 | - [#26](https://github.com/westonganger/rails_i18n_manager/pull/26) - Fix file not found issues with file import
23 | - [#25](https://github.com/westonganger/rails_i18n_manager/pull/25) - Remove catch-all 404 route definition
24 |
25 | ### v1.1.0 - January 17, 2025 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.0.3...v1.1.0)
26 | - [#24](https://github.com/westonganger/rails_i18n_manager/pull/24) - Completely remove usage of sprockets or propshaft
27 | - [#23](https://github.com/westonganger/rails_i18n_manager/pull/23) - Fix issues with rubyzip 2.4+ create option
28 |
29 | ### v1.0.3 - December 2, 2024 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.0.2...v1.0.3)
30 | - [#21](https://github.com/westonganger/rails_i18n_manager/pull/21) - Switch to digested assets using either propshaft or sprockets
31 |
32 | ### v1.0.2 - November 7, 2024 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.0.1...v1.0.2)
33 | - [View commit](https://github.com/westonganger/rails_i18n_manager/commit/ccdeea7cdfb409b61e5d8ef23b03c52fbfd027c0) - Allow `.yaml` files to be uploaded. Previously the upload validation would only allow `.yml`.
34 | - [View commit](https://github.com/westonganger/rails_i18n_manager/commit/65558c10ee8337d578b9f627034f3d6e29c2178f) - Drop support for Rails v5.x
35 | - [#19](https://github.com/westonganger/rails_i18n_manager/pull/19) - Fix width issue on translation values form and view page
36 |
37 | ### v1.0.1 - October 17, 2023 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/v1.0.0...v1.0.1)
38 | - [#14](https://github.com/westonganger/rails_i18n_manager/pull/14) - Remove usage of Array#intersection to fix errors in Ruby 2.6 and below
39 | - [#12](https://github.com/westonganger/rails_i18n_manager/pull/12) - Fix for cleaning old tmp files created from TranslationKey#export_to
40 | - [#11](https://github.com/westonganger/rails_i18n_manager/pull/11) - Fix google translate and add specs
41 | - [#10](https://github.com/westonganger/rails_i18n_manager/pull/10) - Add missing pagination links to index pages
42 |
43 | ### v1.0.0 - August 3, 2023 - [View Diff](https://github.com/westonganger/rails_i18n_manager/compare/9c8305c...v1.0.0)
44 | - Release to rubygems
45 |
46 | ### April 17, 2023
47 | - [#3](https://github.com/westonganger/rails_i18n_manager/pull/3) - Do not automatically load migrations and instead require an explicit migration install step
48 |
49 | ### April 2023
50 | - Initial Release
51 |
--------------------------------------------------------------------------------
/app/models/rails_i18n_manager/translation_key.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationKey < ApplicationRecord
3 |
4 | belongs_to :translation_app, class_name: "RailsI18nManager::TranslationApp"
5 | has_many :translation_values, class_name: "RailsI18nManager::TranslationValue", dependent: :destroy
6 | accepts_nested_attributes_for :translation_values, reject_if: ->(x){ x["id"].nil? && x["translation"].blank? }
7 |
8 | validates :translation_app, presence: true
9 | validates :key, presence: true, uniqueness: {case_sensitive: false, scope: [:translation_app_id]}
10 | validate :validate_translation_values_includes_default_locale
11 |
12 | def validate_translation_values_includes_default_locale
13 | return if new_record?
14 | if translation_values.empty? || translation_values.none?{|x| x.locale == translation_app.default_locale }
15 | errors.add(:base, "Translation for default locale is required")
16 | end
17 | end
18 |
19 | scope :search, ->(str){
20 | fields = [
21 | "#{table_name}.key",
22 | "#{TranslationApp.table_name}.name",
23 | "#{TranslationValue.table_name}.locale",
24 | "#{TranslationValue.table_name}.translation",
25 | ]
26 |
27 | like = connection.adapter_name.downcase.to_s == "postgres" ? "ILIKE" : "LIKE"
28 |
29 | sql_conditions = []
30 |
31 | fields.each do |col|
32 | sql_conditions << "(#{col} #{like} :search)"
33 | end
34 |
35 | self.left_joins(:translation_values, :translation_app)
36 | .where(sql_conditions.join(" OR "), search: "%#{str}%")
37 | }
38 |
39 | def default_translation
40 | return @default_translation if defined?(@default_translation)
41 | @default_translation = self.translation_values.detect{|x| x.locale == translation_app.default_locale.to_s }&.translation
42 | end
43 |
44 | def any_missing_translations?
45 | self.translation_app.all_locales.any? do |locale|
46 | val_record = translation_values.detect{|x| x.locale == locale.to_s}
47 |
48 | next val_record.nil? || val_record.translation.blank?
49 | end
50 | end
51 |
52 | def self.to_csv
53 | CSV.generate do |csv|
54 | csv << ["App Name", "Key", "Locale", "Translation", "Updated At"]
55 |
56 | self.all.order(key: :asc).includes(:translation_app, :translation_values).each do |key_record|
57 | value_records = {}
58 |
59 | key_record.translation_values.each do |value_record|
60 | value_records[value_record.locale] = value_record
61 | end
62 |
63 | key_record.translation_app.all_locales.each do |locale|
64 | value_record = value_records[locale]
65 | csv << [key_record.translation_app.name, key_record.key, value_record&.locale, value_record&.translation, value_record&.updated_at&.to_s]
66 | end
67 | end
68 | end
69 | end
70 |
71 | def self.export_to(app_name: nil, zip: false, format: :yaml)
72 | format = format.to_sym
73 |
74 | if format == :yaml
75 | format = "yml"
76 | elsif [:yaml, :json].exclude?(format)
77 | raise ArgumentError.new("Invalid format provided")
78 | end
79 |
80 | base_export_path = Rails.root.join("tmp/export/translations/")
81 |
82 | files_to_delete = Dir.glob("#{base_export_path}/*").select{|f| File.ctime(f) < 1.minutes.ago }
83 | if !files_to_delete.empty?
84 | FileUtils.rm_r(files_to_delete, force: true)
85 | end
86 |
87 | base_folder_path = File.join(base_export_path, "#{Time.now.to_i}-#{SecureRandom.hex(6)}/")
88 |
89 | FileUtils.mkdir_p(base_folder_path)
90 |
91 | if app_name.nil?
92 | translation_apps = TranslationApp.order(name: :asc)
93 | else
94 | translation_apps = [TranslationApp.find_by!(name: app_name)]
95 | end
96 |
97 | if translation_apps.empty?
98 | return nil
99 | end
100 |
101 | translation_apps.each do |app_record|
102 | current_app_name = app_record.name
103 |
104 | key_records = app_record.translation_keys.order(key: :asc).includes(:translation_values)
105 |
106 | app_record.all_locales.each do |locale|
107 | tree = {}
108 |
109 | key_records.each do |key_record|
110 | val_record = key_record.translation_values.detect{|x| x.locale == locale.to_s}
111 |
112 | split_keys = [locale.to_s] + key_record.key.split(".")
113 |
114 | RailsI18nManager.hash_deep_set(tree, split_keys, val_record.try!(:translation))
115 | end
116 |
117 | filename = File.join(base_folder_path, current_app_name, "#{locale}.#{format}")
118 |
119 | FileUtils.mkdir_p(File.dirname(filename))
120 |
121 | File.open(filename, "wb") do |io|
122 | if format == :json
123 | str = tree.to_json
124 | else
125 | str = tree.to_yaml(line_width: -1).sub("---\n", "")
126 | end
127 |
128 | io.write(str)
129 | end
130 | end
131 | end
132 |
133 | if zip
134 | temp_file = Tempfile.new([Time.now.to_i.to_s, ".zip"], binmode: true)
135 |
136 | files_to_write = Dir.glob("#{base_folder_path}/**/**")
137 |
138 | if files_to_write.empty?
139 | return nil
140 | end
141 |
142 | zip_file = Zip::File.open(temp_file, create: true) do |zipfile|
143 | files_to_write.each do |file|
144 | zipfile.add(file.sub(base_folder_path, "translations/"), file)
145 | end
146 | end
147 |
148 | output_path = temp_file.path
149 | elsif app_name
150 | output_path = File.join(base_folder_path, app_name)
151 | else
152 | output_path = base_folder_path
153 | end
154 |
155 | return output_path
156 | end
157 |
158 | end
159 | end
160 |
--------------------------------------------------------------------------------
/spec/unit/models/translation_key_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | module RailsI18nManager
4 | RSpec.describe TranslationKey, type: :model do
5 |
6 | let!(:translation_app) { FactoryBot.create(:translation_app, default_locale: :en, additional_locales: [:fr]) }
7 | let!(:translation_key) { FactoryBot.create(:translation_key) }
8 | let!(:default_translation_value) { FactoryBot.create(:translation_value, translation_key: translation_key, locale: translation_app.default_locale) }
9 | let!(:additional_translation_value) { FactoryBot.create(:translation_value, translation_key: translation_key, locale: translation_app.additional_locales_array.first) }
10 |
11 | context "default_translation" do
12 | it "returns translation value of default locale" do
13 | expect(default_translation_value.translation).to be_present
14 | expect(additional_translation_value.translation).not_to eq(default_translation_value.translation)
15 | expect(translation_key.default_translation).to eq(default_translation_value.translation)
16 | end
17 | end
18 |
19 | context "any_missing_translations?" do
20 | it "check for any missing translations" do
21 | translation_key.any_missing_translations?
22 | end
23 | end
24 |
25 | context "to_csv" do
26 | it "returns a csv string with correct headers" do
27 | csv_str = TranslationKey.to_csv
28 | expect(csv_str).to be_kind_of(String)
29 |
30 | rows = CSV.parse(csv_str)
31 |
32 | expect(rows.first).to match_array(["App Name", "Key", "Locale", "Translation", "Updated At"])
33 | end
34 |
35 | it "create a csv for all apps" do
36 | csv_str = TranslationKey.to_csv
37 | rows = CSV.parse(csv_str)
38 |
39 | expect(rows.size).to eq(1+TranslationApp.all.sum{|x| x.all_locales.size*x.translation_keys.size })
40 | end
41 |
42 | it "creates a csv for a single app" do
43 | csv_str = TranslationKey.where(translation_app_id: translation_app.id).to_csv
44 | rows = CSV.parse(csv_str)
45 |
46 | expect(rows.size).to eq(1+(translation_app.all_locales.size*translation_app.translation_keys.size))
47 | end
48 |
49 | it "includes rows for locales without an associated translation key record" do
50 | 2.times do
51 | key = FactoryBot.create(:translation_key, translation_app: translation_app)
52 | FactoryBot.create(:translation_value, translation_key: key, locale: key.translation_app.default_locale)
53 | end
54 | expect(translation_app.all_locales.size).to eq(2)
55 | expect(translation_app.translation_keys.size).to eq(2)
56 | expect(translation_app.translation_keys.flat_map(&:translation_values).size).to eq(2)
57 |
58 | csv_str = TranslationKey.where(translation_app_id: translation_app.id).to_csv
59 | rows = CSV.parse(csv_str)
60 |
61 | expect(rows.size).to eq(5)
62 | end
63 | end
64 |
65 | context "export_to" do
66 | context "invalid format" do
67 | it "raises error" do
68 | expect do
69 | TranslationKey.export_to(app_name: nil, zip: false, format: :foo)
70 | end.to raise_error(ArgumentError, "Invalid format provided")
71 | end
72 | end
73 |
74 | it "works for all apps" do
75 | dirname = TranslationKey.export_to(app_name: nil, zip: false, format: "yaml")
76 | expect(File.directory?(dirname)).to eq(true)
77 | app_dirs = Dir.glob("#{dirname}/*")
78 | expect(app_dirs.size > 1)
79 | expect(app_dirs.any?{|x| File.directory?(x) && x.end_with?("/#{TranslationApp.first.name}")}).to eq(true)
80 | expect(app_dirs.any?{|x| File.directory?(x) && x.end_with?("/#{TranslationApp.last.name}")}).to eq(true)
81 | end
82 |
83 | it "works for a single app" do
84 | expect(TranslationApp.all.size).to be >= 2
85 |
86 | dirname = TranslationKey.export_to(app_name: translation_app.name, zip: false, format: "yaml")
87 | expect(File.directory?(dirname)).to eq(true)
88 | files = Dir.glob("#{dirname}/*").sort
89 | expect(files.size).to eq(2)
90 | expect(files[0].end_with?("/en.yml")).to eq(true)
91 | expect(files[1].end_with?("/fr.yml")).to eq(true)
92 | end
93 |
94 | it "zips the content if zip: true" do
95 | filename = TranslationKey.export_to(app_name: translation_app.name, zip: true, format: "yaml")
96 | expect(File.directory?(filename)).to eq(false)
97 | expect(filename.split(".").last).to eq("zip")
98 | end
99 |
100 | it "doesnt zip the content if zip: false" do
101 | dirname = TranslationKey.export_to(app_name: translation_app.name, zip: false, format: "yaml")
102 | expect(File.directory?(dirname)).to eq(true)
103 | files = Dir.glob("#{dirname}/**/*").sort
104 | expect(files.size).to eq(2)
105 | expect(files[0].end_with?("/en.yml")).to eq(true)
106 | expect(files[1].end_with?("/fr.yml")).to eq(true)
107 | end
108 |
109 | it "deletes old tmp files" do
110 | allow(FileUtils).to receive(:rm_r).and_call_original
111 |
112 | TranslationKey.export_to(app_name: nil, zip: false, format: "yaml")
113 | TranslationKey.export_to(app_name: nil, zip: false, format: "yaml")
114 | expect(FileUtils).not_to have_received(:rm_r)
115 |
116 | allow(File).to receive(:ctime).and_return(2.minutes.ago)
117 | TranslationKey.export_to(app_name: nil, zip: false, format: "yaml")
118 | expect(FileUtils).to have_received(:rm_r).once
119 | end
120 |
121 | context "yaml" do
122 | it "outputs content in yaml" do
123 | dirname = TranslationKey.export_to(app_name: nil, zip: false, format: "yaml")
124 |
125 | files = Dir.glob("#{dirname}/**/*").reject{|f| File.directory?(f) }
126 | expect(files.all?{|x| x.end_with?(".yml") }).to eq(true)
127 |
128 | YAML.safe_load(File.read(files.first))
129 | end
130 | end
131 |
132 | context "json" do
133 | it "outputs content in json" do
134 | dirname = TranslationKey.export_to(app_name: nil, zip: false, format: :json)
135 |
136 | files = Dir.glob("#{dirname}/**/*").reject{|f| File.directory?(f) }
137 | expect(files.all?{|x| x.end_with?(".json") }).to eq(true)
138 |
139 | JSON.parse(File.read(files.first))
140 | end
141 | end
142 | end
143 |
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/spec/unit/translations_importer_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | module RailsI18nManager
4 | RSpec.describe TranslationsImporter, type: :model do
5 |
6 | let(:translation_app){ FactoryBot.create(:translation_app, default_locale: :en, additional_locales: [:fr]) }
7 |
8 | before do
9 | @filename = Rails.root.join("tmp/#{SecureRandom.hex(6)}.yml").to_s
10 | end
11 |
12 | after do
13 | `rm -rf #{@filename}`
14 | end
15 |
16 | it "raises exception when the locale included in file but is not listed in translation app" do
17 | yaml = <<~YAML
18 | fr:
19 | foo: foo
20 | bar:
21 | es:
22 | foo: foo
23 | YAML
24 |
25 | expect do
26 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml))
27 | end.to raise_error(TranslationsImporter::ImportAbortedError)
28 | end
29 |
30 | it "creates the correct amount of TranslationKey and TranslationValue" do
31 | prev_key_count = translation_app.translation_keys.size
32 | prev_value_count = translation_app.translation_keys.sum{|x| x.translation_values.size }
33 |
34 | yaml = <<~YAML
35 | en:
36 | foo: foo
37 | baz:
38 | beef:
39 | steak: steak
40 | roast:
41 |
42 | fr:
43 | fr_only_key: fr_only
44 | YAML
45 |
46 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml))
47 |
48 | translation_app.reload
49 |
50 | expect(translation_app.translation_keys.size).to eq(prev_key_count + 5)
51 | expect(translation_app.translation_keys.sum{|x| x.translation_values.size }).to eq(prev_value_count + 3)
52 | end
53 |
54 | context "overwrite_existing" do
55 | it "when true it overwrites existing present values" do
56 | foo_value = FactoryBot.create(
57 | :translation_value,
58 | locale: :en,
59 | translation: "old",
60 | translation_key: FactoryBot.create(
61 | :translation_key,
62 | translation_app: translation_app,
63 | key: "foo",
64 | ),
65 | )
66 |
67 | yaml = <<~YAML
68 | en:
69 | foo: updated
70 | YAML
71 |
72 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), overwrite_existing: true)
73 |
74 | foo_value.reload
75 |
76 | expect(foo_value.translation).to eq("updated")
77 | end
78 |
79 | it "when false it does not overwrite existing present values" do
80 | foo_value = FactoryBot.create(
81 | :translation_value,
82 | locale: :fr,
83 | translation: "old",
84 | translation_key: FactoryBot.create(
85 | :translation_key,
86 | translation_app: translation_app,
87 | key: "foo",
88 | ),
89 | )
90 |
91 | blank_value = FactoryBot.create(
92 | :translation_value,
93 | locale: :fr,
94 | translation: nil,
95 | translation_key: FactoryBot.create(
96 | :translation_key,
97 | translation_app: translation_app,
98 | key: "bar",
99 | ),
100 | )
101 |
102 | yaml = <<~YAML
103 | fr:
104 | foo: foo_updated
105 | bar: bar_updated
106 | YAML
107 |
108 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), overwrite_existing: false)
109 |
110 | foo_value.reload
111 | blank_value.reload
112 |
113 | expect(foo_value.translation).to eq("old")
114 | expect(blank_value.translation).to eq("bar_updated")
115 | end
116 | end
117 |
118 | context "mark_inactive_translations" do
119 | it "when true it sets active attributes" do
120 | yaml = <<~YAML
121 | en:
122 | foo:
123 | bar:
124 | baz:
125 | YAML
126 |
127 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), mark_inactive_translations: true)
128 | translation_app.translation_keys.reload
129 | expect(translation_app.translation_keys.select(&:active).size).to eq(3)
130 | expect(translation_app.translation_keys.reject(&:active).size).to eq(0)
131 |
132 | yaml = <<~YAML
133 | en:
134 | foo:
135 | #bar:
136 | baz:
137 | YAML
138 |
139 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), mark_inactive_translations: true)
140 | translation_app.translation_keys.reload
141 | expect(translation_app.translation_keys.select(&:active).size).to eq(2)
142 | expect(translation_app.translation_keys.reject(&:active).size).to eq(1)
143 |
144 | yaml = <<~YAML
145 | en:
146 | foo:
147 | bar:
148 | baz:
149 | YAML
150 |
151 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), mark_inactive_translations: true)
152 | translation_app.translation_keys.reload
153 | expect(translation_app.translation_keys.select(&:active).size).to eq(3)
154 | expect(translation_app.translation_keys.reject(&:active).size).to eq(0)
155 | end
156 |
157 | it "when false it does not change active attribute" do
158 | yaml = <<~YAML
159 | en:
160 | foo:
161 | bar:
162 | baz:
163 | YAML
164 |
165 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), mark_inactive_translations: false)
166 | expect(translation_app.translation_keys.select(&:active).size).to eq(3)
167 | expect(translation_app.translation_keys.reject(&:active).size).to eq(0)
168 |
169 | yaml = <<~YAML
170 | en:
171 | foo:
172 | #bar:
173 | baz:
174 | YAML
175 |
176 | TranslationsImporter.import(translation_app_id: translation_app.id, parsed_file_contents: YAML.safe_load(yaml), mark_inactive_translations: false)
177 | expect(translation_app.translation_keys.select(&:active).size).to eq(3)
178 | expect(translation_app.translation_keys.reject(&:active).size).to eq(0)
179 | end
180 | end
181 |
182 | end
183 | end
184 |
--------------------------------------------------------------------------------
/app/lib/rails_i18n_manager/custom_form_builder.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class CustomFormBuilder < ActionView::Helpers::FormBuilder
3 |
4 | ALLOWED_OPTIONS = [
5 | :value,
6 | :name,
7 | :label,
8 | :field_wrapper_html,
9 | :label_wrapper_html,
10 | :label_html,
11 | :input_wrapper_html,
12 | :input_html,
13 | :required,
14 | :required_text,
15 | :help_text,
16 | :errors,
17 | :field_layout,
18 | :view_mode,
19 |
20 | ### SELECT OPTIONS
21 | :collection,
22 | :selected,
23 | :disabled,
24 | :prompt,
25 | :include_blank,
26 | ].freeze
27 |
28 | def error_notification
29 | @template.render "rails_i18n_manager/form_builder/error_notification", {f: self}
30 | end
31 |
32 | def view_field(label:, value:, **options)
33 | field(nil, type: :view, **options.merge(label: label, value: value, view_mode: true))
34 | end
35 |
36 | def field(method, type:, **options)
37 | type = type.to_sym
38 |
39 | options = _transform_options(options, method)
40 |
41 | if options[:view_mode]
42 | return _view_field(method, type, options)
43 | end
44 |
45 | invalid_options = options.keys - ALLOWED_OPTIONS
46 | if invalid_options.any?
47 |
48 | raise "Invalid options provided: #{invalid_options.join(", ")}"
49 | end
50 |
51 | if [:select, :textarea].exclude?(type)
52 | options[:input_html][:type] = type.to_s
53 | end
54 |
55 | case type
56 | when :select
57 | options[:collection] = _fetch_required_option(:collection, options)
58 |
59 | options = _transform_select_options(options, method)
60 | when :checkbox
61 | options = _transform_checkbox_options(options, method)
62 | else
63 | if !options[:input_html].has_key?(:value)
64 | options[:input_html][:value] = object.send(method)
65 | end
66 | end
67 |
68 | @template.render("rails_i18n_manager/form_builder/basic_field", {
69 | f: self,
70 | method: method,
71 | type: type,
72 | options: options,
73 | })
74 | end
75 |
76 | private
77 |
78 | def _view_field(method, type, options)
79 | options[:input_html][:class] ||= ""
80 | options[:input_html][:class].concat(" form-control-plaintext").strip!
81 | options[:input_html][:readonly] = true
82 | options[:input_html][:type] = "text"
83 | options[:input_html].delete(:name)
84 |
85 | if !options[:input_html].has_key?(:value)
86 | options[:input_html][:value] = _determine_display_value(method, type, options)
87 | end
88 |
89 | @template.render("rails_i18n_manager/form_builder/basic_field", {
90 | f: self,
91 | method: method,
92 | type: :view,
93 | options: options,
94 | })
95 | end
96 |
97 | def _defaults
98 | @_defaults ||= (@template.instance_variable_get(:@_custom_form_for_defaults) || {}).deep_symbolize_keys!
99 | end
100 |
101 | def _attr_presence_required?(attr)
102 | if attr && object.respond_to?(attr)
103 | (@object.try(:klass) || @object.class).validators_on(attr).any?{|x| x.kind.to_sym == :presence }
104 | end
105 | end
106 |
107 | def _fetch_required_option(key, options)
108 | if !options.has_key?(key)
109 | raise ArgumentError.new("Missing required option :#{key}")
110 | end
111 | options[key]
112 | end
113 |
114 | def _determine_display_value(method, type, options)
115 | case type
116 | when :checkbox
117 | options[:input_html]&.has_key?(:checked) ? options[:input_html][:checked] : @object.send(method)
118 | when :select
119 | if options.has_key?(:selected)
120 | val = options[:selected]
121 | else
122 | if options[:input_html].has_key?(:value)
123 | val = options[:input_html].delete(:value)
124 | else
125 | val = object.send(method)
126 | end
127 | end
128 |
129 | selected_opt = options[:collection].detect { |opt|
130 | val == (opt.is_a?(Array) ? opt[1] : opt)
131 | }
132 |
133 | selected_opt.is_a?(Array) ? selected_opt[0] : selected_opt
134 | else
135 | options[:input_html]&.has_key?(:value) ? options[:value] : @object.send(method)
136 | end
137 | end
138 |
139 | def _transform_options(options, method)
140 | options.deep_symbolize_keys!
141 |
142 | options = _defaults.merge(options)
143 |
144 | options[:label] = options.has_key?(:label) ? options[:label] : method.to_s.titleize
145 |
146 | options[:field_wrapper_html] ||= {}
147 | options[:label_wrapper_html] ||= {}
148 | options[:label_html] ||= {}
149 | options[:input_wrapper_html] ||= {}
150 | options[:input_html] ||= {}
151 |
152 | ### Shortcuts for some input_html arguments
153 | [:value, :name].each do |key|
154 | if options.has_key?(key) && !options[:input_html].has_key?(key)
155 | options[:input_html][key] = options[key]
156 | end
157 | end
158 |
159 | options[:field_wrapper_html][:class] ||= ""
160 | options[:field_wrapper_html][:class].concat(" form-group").strip!
161 |
162 | if !options.has_key?(:field_layout)
163 | options[:field_layout] = :vertical
164 | end
165 | options[:field_layout] = options[:field_layout].to_sym
166 |
167 | options[:required] = options.has_key?(:required) ? options[:required] : _attr_presence_required?(method)
168 |
169 | options[:required_text] ||= "*"
170 |
171 | options[:field_wrapper_html][:class].concat(" #{method}_field").strip!
172 |
173 | if method && !options.has_key?(:errors)
174 | options[:errors] = @object.errors[method]
175 | end
176 |
177 | if options[:errors].present?
178 | options[:input_html][:class] ||= ""
179 | options[:input_html][:class].concat(" is-invalid")
180 | end
181 |
182 | options
183 | end
184 |
185 | def _transform_select_options(options, method)
186 | if !options.has_key?(:selected)
187 | if options[:input_html].has_key?(:value)
188 | options[:selected] = options[:input_html].delete(:value)
189 | else
190 | options[:selected] = object.send(method)
191 | end
192 | end
193 |
194 | if options[:disabled].is_a?(TrueClass) && !options[:input_html].has_key?(:disabled)
195 | options.delete(:disabled)
196 | options[:input_html][:disabled] = true
197 | end
198 |
199 | options
200 | end
201 |
202 | def _transform_checkbox_options(options, method)
203 | if options[:input_html].has_key?(:value) && !options[:input_html].has_key?(:checked)
204 | options[:input_html][:checked] = (object.send(method) == options[:input_html][:value])
205 | elsif @object.class.respond_to?(:columns_hash) && @object.class.columns_hash[method]&.type == :boolean
206 | if !options[:input_html].has_key?(:checked)
207 | if options[:input_html].has_key?(:value)
208 | options[:input_html][:checked] = (options[:input_html][:value] == true)
209 | else
210 | options[:input_html][:checked] = (object.send(method) == true)
211 | end
212 | end
213 |
214 | if !options[:input_html].has_key?(:value)
215 | options[:input_html][:value] = "1"
216 | end
217 | end
218 |
219 | options
220 | end
221 |
222 | end
223 | end
224 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rails I18n Manager
2 | > A complete translation editor and workflow
3 |
4 |
5 |
6 |
7 | Web interface to manage i18n translations helping to facilitate the editors of your translations. Provides a low-tech and complete workflow for importing, translating, and exporting your I18n translation files. Designed to allow you to keep the translation files inside your projects git repository where they should be.
8 |
9 | Features:
10 |
11 | - Import & export translations using standard i18n YAML/JSON files
12 | - Allows managing translations for any number of apps
13 | - Built in support for Google Translation for missing translations
14 | - Provides an API end point to perform automated downloads of your translations
15 |
16 | ## Screenshots
17 | 
18 |
19 | 
20 |
21 | 
22 |
23 | ## Setup
24 |
25 | Developed as a Rails engine. So you can add to any existing app or create a brand new app with the functionality.
26 |
27 | First add the gem to your Gemfile
28 |
29 | ```ruby
30 | ### Gemfile
31 | gem "rails_i18n_manager"
32 | ```
33 |
34 | Then install and run the database migrations
35 |
36 | ```sh
37 | bundle install
38 | bundle exec rake rails_i18n_manager:install:migrations
39 | bundle exec rake db:migrate
40 | ```
41 |
42 | ### Routes
43 |
44 | #### Option A: Mount to a path
45 |
46 | ```ruby
47 | ### config/routes.rb
48 |
49 | ### As sub-path
50 | mount RailsI18nManager::Engine, at: "/rails_i18n_manager", as: "rails_i18n_manager"
51 |
52 | ### OR as root-path
53 | mount RailsI18nManager::Engine, at: "/", as: "rails_i18n_manager"
54 | ```
55 |
56 | #### Option B: Mount to a subdomain
57 |
58 | ```ruby
59 | ### config/routes.rb
60 |
61 | translations_engine_subdomain = "translations"
62 |
63 | mount RailsI18nManager::Engine,
64 | at: "/", as: "translations_engine",
65 | constraints: Proc.new{|request| request.subdomain == translations_engine_subdomain }
66 |
67 | not_engine = Proc.new{|request| request.subdomain != translations_engine_subdomain }
68 |
69 | constraints not_engine do
70 | # your app routes here...
71 | end
72 | ```
73 |
74 | ### Configuration
75 |
76 | ```ruby
77 | ### config/initializers/rails_i18n_manager.rb
78 |
79 | RailsI18nManager.config do |config|
80 | config.google_translate_api_key = ENV.fetch("GOOGLE_TRANSLATE_API_KEY", nil)
81 |
82 | ### You can use our built-in list of all locales Google Translate supports
83 | ### OR make your own list. These need to be supported by Google Translate
84 | # config.valid_locales = ["en", "es", "fr"]
85 | end
86 | ```
87 |
88 | ### Customizing Authentication
89 |
90 | ```ruby
91 | ### config/routes.rb
92 |
93 | ### Using Devise
94 | authenticated :user do
95 | mount RailsI18nManager::Engine, at: "/rails_i18n_manager", as: "rails_i18n_manager"
96 | end
97 |
98 | ### Custom devise-like
99 | constraints ->(req){ req.session[:user_id].present? && User.find_by(id: req.session[:user_id]) } do
100 | mount RailsI18nManager::Engine, at: "/rails_i18n_manager", as: "rails_i18n_manager"
101 | end
102 |
103 | ### HTTP Basic Auth
104 | with_http_basic_auth = ->(engine){
105 | Rack::Builder.new do
106 | use Rack::Auth::Basic do |username, password|
107 | ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), ::Digest::SHA256.hexdigest(ENV.fetch("RAILS_I18N_MANAGER_USERNAME"))) &&
108 | ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), ::Digest::SHA256.hexdigest(ENV.fetch("RAILS_I18N_MANAGER_PASSWORD")))
109 | end
110 | run(engine)
111 | end
112 | }
113 | mount with_http_basic_auth.call(RailsI18nManager::Engine), at: "/rails_i18n_manager", as: "rails_i18n_manager"
114 | ```
115 |
116 | ### API Endpoint
117 |
118 | We provide an endpoint to retrieve your translation files at `/translations`.
119 |
120 | You will likely want to add your own custom authentication strategy and can do so using a routing constraint on the `mount RailsI18nManager` call.
121 |
122 | From that point you can implement an automated mechanism to update your apps translations using the provided API end point. Some examples
123 |
124 | An example in Ruby:
125 |
126 | ```ruby
127 | require 'open-uri'
128 |
129 | zip_stream = URI.open('https://translations-manager.example.com/translations.zip?export_format=yaml')
130 | IO.copy_stream(zip_stream, '/tmp/my-app-locales.zip')
131 | `unzip /tmp/my-app-locales.zip /tmp/my-app-locales/`
132 | `rsync --delete-after /tmp/my-app-locales/my-app/ /path/to/my-app/config/locales/`
133 | puts "Locales are now updated, app restart not-required"
134 | ```
135 |
136 | A command line example using curl:
137 |
138 | ```
139 | curl https://translations-manager.example.com/translations.zip?export_format=json -o /tmp/my-app-locales.zip \
140 | && unzip /tmp/my-app-locales.zip /tmp/my-app-locales/ \
141 | && rsync --delete-after /tmp/my-app-locales/my-app/ \
142 | && echo "Locales are now updated, app restart not-required"
143 | ```
144 |
145 | ## Recommended Workflow for Teams
146 |
147 | It is desirable to reduce how often import/export is performed. It is also desirable that we do not violate the regular PR lifecycle/process. The following workflow should allow for this.
148 |
149 | When creating a PR you can just create a new YAML file named after your feature name or ticket number and then use the following format:
150 |
151 | ```yaml
152 | # config/locales/some_new_feature.yml
153 |
154 | en:
155 | some_new_key: "foo"
156 | fr:
157 | some_new_key: "bar"
158 | es:
159 | some_new_key: "baz"
160 | ```
161 |
162 | Whenever releasing a new version of your application, pre-deploy or some other cadence, then you can have a step where all translation files are uploaded to the `rails_i18n_manager`, have your translator folks double check everything, then export your new files and cleanup all the feature files.
163 |
164 | ## Recommended I18n Configuration
165 |
166 | The default I18n backend has some glaring issues
167 |
168 | - It will silently show "translation_missing" text which is very undesirable
169 | - It will not fallback to your default or any other locale
170 |
171 | You can avoid these issues using either of the techniques below
172 |
173 | ```ruby
174 | # config/initializers/i18n.rb
175 |
176 | Rails.configuration do |config|
177 | config.i18n.raise_on_missing_translations = true # WARNING: this will raise exceptions in Production too, preventing your users from using your application even when some silly little translation is missing
178 |
179 | config.i18n.fallbacks = [I18n.default_locale, :en].uniq # fallback to default locale, or if that is missing then fallback to english translation
180 | end
181 | ```
182 |
183 | You will likely find that `raise_on_missing_translations` is too aggressive. Causing major outages just because a translation is missing. In that scenario its better to use something like the following:
184 |
185 | ```ruby
186 | # config/initializers/i18n.rb
187 |
188 | Rails.configuration do |config|
189 | config.i18n.raise_on_missing_translations = false # Instead we use the custom backend below
190 |
191 | config.i18n.fallbacks = [I18n.default_locale, :en].uniq # fallback to default locale, or if that is missing then fallback to english translation
192 | end
193 |
194 | module I18n
195 | class CustomI18nBackend
196 | include I18n::Backend::Base
197 |
198 | def translate(locale, key, options = EMPTY_HASH)
199 | if !key.nil? && key.to_s != "i18n.plural.rule"
200 | translation_value = lookup(locale, key, options[:scope], options)
201 |
202 | if translation_value.blank?
203 | if Rails.env.production?
204 | # send an email or some other warning mechanism
205 | else
206 | # Raise exception in non-production environments
207 | raise "Translation not found (locale: #{locale}, key: #{key})"
208 | end
209 | end
210 | end
211 |
212 | return nil # allow the Backend::Chain to continue to the next backend
213 | end
214 | end
215 | end
216 |
217 | if I18n.backend.is_a?(I18n::Backend::Chain)
218 | I18n.backend.backends.unshift(I18n::CustomI18nBackend)
219 | else
220 | I18n.backend = I18n::Backend::Chain.new(
221 | I18n::CustomI18nBackend,
222 | I18n.backend, # retain original backend
223 | )
224 | end
225 | ```
226 |
227 | ## Development
228 |
229 | Run migrations using: `rails db:migrate`
230 |
231 | Run server using: `bin/dev` or `cd test/dummy/; rails s`
232 |
233 | ## Testing
234 |
235 | ```
236 | bundle exec rspec
237 | ```
238 |
239 | We can locally test different versions of Rails using `ENV['RAILS_VERSION']`
240 |
241 | ```
242 | export RAILS_VERSION=7.0
243 | bundle install
244 | bundle exec rspec
245 | ```
246 |
247 | ## Other Translation Managers & Web Interfaces
248 |
249 | For comparison, some other projects for managing Rails translations.
250 |
251 | - https://github.com/tolk/tolk - This is the project that inspired rails_i18n_manager. UI and file-based approach. I [attempted to revive tolk](https://github.com/tolk/tolk/pull/161) but gave up as I found the codebase and workflow was really just a legacy ball of spagetti.
252 | - https://github.com/prograils/lit
253 | - https://github.com/alphagov/rails_translation_manager
254 | - https://github.com/glebm/i18n-tasks
255 |
256 |
257 | # Credits
258 |
259 | Created & Maintained by [Weston Ganger](https://westonganger.com) - [@westonganger](https://github.com/westonganger)
260 |
--------------------------------------------------------------------------------
/app/controllers/rails_i18n_manager/translations_controller.rb:
--------------------------------------------------------------------------------
1 | module RailsI18nManager
2 | class TranslationsController < ApplicationController
3 | before_action :get_translation_key, only: [:show, :edit, :update, :destroy]
4 |
5 | def index
6 | case params[:sort]
7 | when "app_name"
8 | sort = "#{TranslationApp.table_name}.name"
9 | when "updated_at"
10 | sort = "#{TranslationKey.table_name}.updated_at"
11 | else
12 | sort = params[:sort]
13 | end
14 |
15 | @translation_keys = TranslationKey
16 | .includes(:translation_app, :translation_values)
17 | .references(:translation_app)
18 | .sort_order(sort, params[:direction], base_sort_order: "#{TranslationApp.table_name}.name ASC, #{TranslationKey.table_name}.key ASC")
19 |
20 | apply_filters
21 |
22 | if request.format.to_sym != :html && TranslationApp.first.nil?
23 | request.format = :html
24 | flash[:alert] = "No Translation apps exists"
25 | redirect_to action: :index
26 | return false
27 | end
28 |
29 | respond_to do |format|
30 | format.html do
31 | @translation_keys = @translation_keys.page(params[:page])
32 | end
33 |
34 | format.any do
35 | @translations_keys = @translation_keys.where(active: true) ### Ensure exported keys are active for any exports
36 |
37 | case request.format.to_sym
38 | when :csv
39 | if params.dig(:filters, :app_name).present?
40 | @translation_keys = @translation_keys.joins(:translation_app).where(TranslationApp.table_name => {name: params.dig(:filters, :app_name)})
41 | end
42 |
43 | send_data @translation_keys.to_csv, filename: "translations.csv"
44 | when :zip
45 | file = @translation_keys.export_to(format: params[:export_format], zip: true, app_name: params.dig(:filters, :app_name).presence)
46 |
47 | if file
48 | send_file file, filename: "translations-#{params[:export_format]}-#{params.dig(:filters, :app_name).presence || "all-apps"}.zip"
49 | else
50 | flash[:alert] = "Sorry, Nothing to export"
51 | redirect_to action: :index
52 | end
53 | else
54 | raise ActionController::UnknownFormat
55 | end
56 | end
57 | end
58 | end
59 |
60 | def show
61 | render "edit"
62 | end
63 |
64 | def edit
65 | end
66 |
67 | def update
68 | @translation_key.assign_attributes(allowed_params)
69 |
70 | if @translation_key.save
71 | flash[:notice] = "Update success."
72 | redirect_to edit_translation_path(@translation_key)
73 | else
74 | flash[:notice] = "Update failed."
75 | render "edit"
76 | end
77 | end
78 |
79 | def destroy
80 | if @translation_key.active
81 | redirect_to translations_path, alert: "Cannot delete active translations"
82 | else
83 | @translation_key.destroy!
84 | redirect_to translations_path, notice: "Delete Successful"
85 | end
86 | end
87 |
88 | def delete_inactive_keys
89 | @translation_keys = TranslationKey
90 | .where(active: false)
91 | .includes(:translation_app, :translation_values)
92 | .references(:translation_app)
93 |
94 | apply_filters
95 |
96 | @translation_keys.destroy_all
97 |
98 | flash[:notice] = "Delete Inactive was successful."
99 | redirect_to translations_path(filters: params[:filters].to_unsafe_h)
100 | end
101 |
102 | def import
103 | if request.get?
104 | @form = Forms::TranslationFileForm.new
105 | render
106 | else
107 | @form = Forms::TranslationFileForm.new(params[:import_form])
108 |
109 | if @form.valid?
110 | begin
111 | TranslationsImporter.import(
112 | translation_app_id: @form.translation_app_id,
113 | parsed_file_contents: @form.parsed_file_contents,
114 | overwrite_existing: @form.overwrite_existing,
115 | mark_inactive_translations: @form.mark_inactive_translations,
116 | )
117 | rescue TranslationsImporter::ImportAbortedError => e
118 | flash.now.alert = e.message
119 | render
120 | return
121 | end
122 |
123 | redirect_to translations_path, notice: "Import Successful"
124 | else
125 | flash.now.alert = "Please see form errors below"
126 | render
127 | end
128 | end
129 | end
130 |
131 | def translate_missing
132 | @translation_keys = TranslationKey.includes(:translation_values)
133 |
134 | apply_filters
135 |
136 | translated_count = 0
137 | total_missing = 0
138 |
139 | if params.dig(:filters, :app_name)
140 | app_locales = TranslationApp.find_by(name: params.dig(:filters, :app_name)).additional_locales_array
141 | else
142 | @translation_keys = @translation_keys.includes(:translation_app)
143 | end
144 |
145 | ### Check & Translate for Every i18n key
146 | @translation_keys.each do |key_record|
147 | locales = (app_locales || key_record.translation_app.additional_locales_array)
148 |
149 | ### Filter to just google translate supported languages
150 | locales = (locales & GoogleTranslate::SUPPORTED_LOCALES) # intersection
151 |
152 | default_translation_text = key_record.default_translation
153 |
154 | next if default_translation_text.blank?
155 |
156 | locales.each do |locale|
157 | if locale == key_record.translation_app.default_locale
158 | next ### skip, we dont translate the default locale
159 | end
160 |
161 | val_record = key_record.translation_values.detect{|x| x.locale == locale.to_s }
162 |
163 | ### Translate Missing
164 | if val_record.nil? || val_record.translation.blank?
165 | total_missing += 1
166 |
167 | translated_text = GoogleTranslate.translate(
168 | default_translation_text,
169 | from: key_record.translation_app.default_locale,
170 | to: locale
171 | )
172 |
173 | if translated_text.present?
174 | if val_record.nil?
175 | val_record = key_record.translation_values.new(locale: locale)
176 | end
177 |
178 | val_record.assign_attributes(translation: translated_text)
179 |
180 | val_record.save!
181 |
182 | translated_count += 1
183 | end
184 | end
185 | end
186 | end
187 |
188 | if params[:translation_key_id]
189 | url = request.referrer || translation_path(params[:translation_key_id])
190 | else
191 | url = translations_path(filters: params[:filters].to_unsafe_h)
192 | end
193 |
194 | redirect_to url, notice: "Translated #{translated_count} of #{total_missing} total missing translations"
195 | end
196 |
197 | private
198 |
199 | def get_translation_key
200 | @translation_key = TranslationKey.includes(:translation_values).find_by!(id: params[:id])
201 | end
202 |
203 | def allowed_params
204 | params.require(:translation_key).permit(translation_values_attributes: [:id, :locale, :translation])
205 | end
206 |
207 | def apply_filters
208 | if params.dig(:filters, :app_name).present?
209 | @translation_keys = @translation_keys.joins(:translation_app).where(TranslationApp.table_name => {name: params.dig(:filters, :app_name)})
210 | end
211 |
212 | if params[:translation_key_id].present?
213 | @translation_keys = @translation_keys.where(id: params[:translation_key_id])
214 | end
215 |
216 | if request.format.html?
217 | ### ONLY FOR HTML - SO THAT WE DONT DOWNLOAD INCOMPLETE TRANSLATION EXPORT PACKAGES
218 |
219 | if params.dig(:filters, :search).present?
220 | @translation_keys = @translation_keys.search(params.dig(:filters, :search))
221 | end
222 |
223 | if params.dig(:filters, :status).blank?
224 | params[:filters] ||= {}
225 | params[:filters][:status] = "All Active Translations"
226 | end
227 |
228 | if params.dig(:filters, :status) == "Inactive Translations"
229 | @translation_keys = @translation_keys.where(active: false)
230 | elsif params.dig(:filters, :status) == "All Translations"
231 | # Do nothing
232 | elsif params.dig(:filters, :status) == "All Active Translations"
233 | @translation_keys = @translation_keys.where(active: true)
234 | elsif params.dig(:filters, :status).start_with?("Missing")
235 | missing_key_ids = []
236 |
237 | if params.dig(:filters, :app_name).present?
238 | translation_apps = TranslationApp.where(name: params.dig(:filters, :app_name))
239 | else
240 | translation_apps = TranslationApp.all
241 | end
242 |
243 | translation_apps.includes(translation_keys: [:translation_values]).each do |app_record|
244 | app_record.translation_keys.each do |key_record|
245 | if params.dig(:filters, :status) == "Missing Default Translation"
246 | if key_record.translation_values.detect{|x| x.locale == app_record.default_locale}&.translation.blank?
247 | missing_key_ids << key_record.id
248 | end
249 | else
250 | if key_record.any_missing_translations?
251 | missing_key_ids << key_record.id
252 | end
253 | end
254 | end
255 | end
256 |
257 | @translation_keys = @translation_keys
258 | .references(:translation_values)
259 | .where("#{TranslationValue.table_name}.translation IS NULL OR #{TranslationKey.table_name}.id IN (:ids)", ids: missing_key_ids)
260 | end
261 | end
262 | end
263 |
264 | def set_browser_title
265 | @browser_title = "Translations"
266 | end
267 |
268 | end
269 | end
270 |
--------------------------------------------------------------------------------
/spec/request/translations_controller_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | module RailsI18nManager
4 | RSpec.describe "TranslationsController", type: :request do
5 | let(:translation_app){ FactoryBot.create(:translation_app, default_locale: :en, additional_locales: [:fr]) }
6 | let(:translation_key){ FactoryBot.create(:translation_key, translation_app: translation_app) }
7 | let!(:default_translation_value){ FactoryBot.create(:translation_value, translation_key: translation_key, locale: translation_app.default_locale) }
8 |
9 | context "index" do
10 | it "renders" do
11 | get rails_i18n_manager.translations_path
12 | expect(response).to have_http_status(200)
13 | end
14 |
15 | it "sorts records" do
16 | ["", 'asc','desc'].each do |direction|
17 | ['app_name','key','updated_at'].each do |col|
18 | get rails_i18n_manager.translations_path, params: {sort: col, direction: direction}
19 | expect(response).to have_http_status(200), "Error: #{direction} #{col}"
20 | end
21 | end
22 | end
23 |
24 | context "filters" do
25 | it "renders with search filter" do
26 | get rails_i18n_manager.translations_path, params: {filters: {search: "foobarfoobar"}}
27 | expect(response).to have_http_status(200)
28 | end
29 |
30 | it "renders with app name filter" do
31 | get rails_i18n_manager.translations_path, params: {filters: {app_name: translation_app.name}}
32 | expect(response).to have_http_status(200)
33 | end
34 |
35 | it "status is All Active Translations" do
36 | get rails_i18n_manager.translations_path, params: {filters: {status: "All Active Translations"}}
37 | expect(response).to have_http_status(200)
38 | expect(assigns(:translation_keys).all?(&:active)).to eq(true)
39 | end
40 |
41 | it "status is All Translations" do
42 | get rails_i18n_manager.translations_path, params: {filters: {status: "All Translations"}}
43 | expect(response).to have_http_status(200)
44 | end
45 |
46 | it "status is Inactive Translations" do
47 | get rails_i18n_manager.translations_path, params: {filters: {status: "Inactive Translations"}}
48 | expect(response).to have_http_status(200)
49 | expect(assigns(:translation_keys).none?(&:active)).to eq(true)
50 | end
51 |
52 | it "status is Missing Default Translation" do
53 | get rails_i18n_manager.translations_path, params: {filters: {status: "Missing Default Translation"}}
54 | expect(response).to have_http_status(200)
55 | end
56 |
57 | it "status is Missing Any Translation" do
58 | get rails_i18n_manager.translations_path, params: {filters: {status: "Missing Any Translation"}}
59 | expect(response).to have_http_status(200)
60 | end
61 | end
62 |
63 | it "exports" do
64 | get rails_i18n_manager.translations_path, params: {format: :zip, export_format: :yaml}
65 | expect(response).to have_http_status(200)
66 |
67 | get rails_i18n_manager.translations_path, params: {format: :zip, export_format: :json}
68 | expect(response).to have_http_status(200)
69 |
70 | get rails_i18n_manager.translations_path, params: {format: :csv}
71 | expect(response).to have_http_status(200)
72 |
73 | get rails_i18n_manager.translations_path, params: {translation_app_id: translation_app.id, format: :zip, export_format: :yaml}
74 | expect(response).to have_http_status(200)
75 |
76 | get rails_i18n_manager.translations_path, params: {translation_app_id: translation_app.id, format: :zip, export_format: :json}
77 | expect(response).to have_http_status(200)
78 |
79 | get rails_i18n_manager.translations_path, params: {translation_app_id: translation_app.id, format: :csv}
80 | expect(response).to have_http_status(200)
81 | end
82 | end
83 |
84 | context "show" do
85 | it "renders" do
86 | get rails_i18n_manager.translation_path(translation_key)
87 | expect(response).to have_http_status(200)
88 | end
89 | end
90 |
91 | context "edit" do
92 | it "renders" do
93 | get rails_i18n_manager.edit_translation_path(translation_key)
94 | expect(response).to have_http_status(200)
95 | end
96 | end
97 |
98 | context "update" do
99 | it "succeeds" do
100 | translation_value = FactoryBot.create(:translation_value, translation_key: translation_key, locale: :fr)
101 |
102 | assert_changed ->(){ translation_value.translation } do
103 | patch rails_i18n_manager.translation_path(translation_key), params: {
104 | translation_key: {
105 | translation_values_attributes: {
106 | "0" => {
107 | id: translation_value.id,
108 | translation: SecureRandom.hex(6),
109 | }
110 | }
111 | }
112 | }
113 | expect(response).to redirect_to(rails_i18n_manager.edit_translation_path(translation_key))
114 | translation_value.reload
115 | end
116 | end
117 |
118 | it "renders edit page when there are validation errors" do
119 | assert_not_changed ->(){ default_translation_value.translation } do
120 | patch rails_i18n_manager.translation_path(translation_key), params: {
121 | translation_key: {
122 | translation_values_attributes: {
123 | "0" => {
124 | id: default_translation_value.id,
125 | translation: "",
126 | }
127 | }
128 | }
129 | }
130 |
131 | expect(response).to render_template("translations/edit")
132 | default_translation_value.reload
133 | end
134 | end
135 | end
136 |
137 | context "translate_missing" do
138 | it "succeeds" do
139 | post rails_i18n_manager.translate_missing_translations_path
140 | expect(response).to redirect_to(rails_i18n_manager.translations_path(filters: {status: "All Active Translations"}))
141 |
142 | post rails_i18n_manager.translate_missing_translations_path(filters: {app_name: translation_app.name})
143 | expect(response).to redirect_to(rails_i18n_manager.translations_path(filters: {app_name: translation_app.name, status: "All Active Translations"}))
144 |
145 | post rails_i18n_manager.translate_missing_translations_path(translation_key_id: translation_key.id)
146 | expect(response).to redirect_to(rails_i18n_manager.translation_path(translation_key))
147 | end
148 | end
149 |
150 | context "import" do
151 | it "renders multipart form enctype" do
152 | get rails_i18n_manager.import_translations_path
153 | expect(response).to have_http_status(200)
154 | expect(response.body).to have_tag(:form, action: "#{rails_i18n_manager.import_translations_path}", enctype: "multipart/form-data")
155 | end
156 |
157 | it "behaves as expected when nothing uploaded" do
158 | get rails_i18n_manager.import_translations_path
159 | expect(response).to have_http_status(200)
160 |
161 | post rails_i18n_manager.import_translations_path, params: {}
162 | expect(response).to have_http_status(200)
163 |
164 | post rails_i18n_manager.import_translations_url, params: {translation_app_id: translation_app.id}
165 | expect(response).to have_http_status(200)
166 | end
167 |
168 | it "accepts for .yml and .yaml files" do
169 | yaml = <<~YAML
170 | en:
171 | foo:
172 | bar:
173 | baz:
174 | YAML
175 |
176 | #.yml
177 | filename = Rails.root.join("tmp/#{SecureRandom.hex(6)}.yml")
178 | File.write(filename, yaml, mode: "wb")
179 |
180 | post rails_i18n_manager.import_translations_path, params: {
181 | import_form: {
182 | translation_app_id: translation_app.id,
183 | file: Rack::Test::UploadedFile.new(filename)
184 | }
185 | }
186 | expect(assigns(:form).errors.full_messages).to be_empty
187 | expect(response).to redirect_to(rails_i18n_manager.translations_path)
188 |
189 | # .yaml
190 | filename = Rails.root.join("tmp/#{SecureRandom.hex(6)}.yaml")
191 | File.write(filename, yaml, mode: "wb")
192 |
193 | post rails_i18n_manager.import_translations_path, params: {
194 | import_form: {
195 | translation_app_id: translation_app.id,
196 | file: Rack::Test::UploadedFile.new(filename)
197 | }
198 | }
199 | expect(assigns(:form).errors.full_messages).to be_empty
200 | expect(response).to redirect_to(rails_i18n_manager.translations_path)
201 | end
202 |
203 | it "accepts .json files" do
204 | json_content = <<~JSON_CONTENT
205 | {
206 | "en": {
207 | "foo": null,
208 | "bar": "bar",
209 | "baz": null
210 | }
211 | }
212 | JSON_CONTENT
213 |
214 | filename = Rails.root.join("tmp/#{SecureRandom.hex(6)}.json")
215 | File.write(filename, json_content, mode: "wb")
216 |
217 | post rails_i18n_manager.import_translations_path, params: {
218 | import_form: {
219 | translation_app_id: translation_app.id,
220 | file: Rack::Test::UploadedFile.new(filename)
221 | }
222 | }
223 | expect(assigns(:form).errors.full_messages).to be_empty
224 | expect(response).to redirect_to(rails_i18n_manager.translations_path)
225 | end
226 |
227 | it "renders errors when another file type provided" do
228 | json_content = <<~JSON_CONTENT
229 | {
230 | "en": "foo"
231 | }
232 | JSON_CONTENT
233 |
234 | filename = Rails.root.join("tmp/#{SecureRandom.hex(6)}.conf")
235 | File.write(filename, json_content, mode: "wb")
236 |
237 | post rails_i18n_manager.import_translations_path, params: {
238 | import_form: {
239 | translation_app_id: translation_app.id,
240 | file: Rack::Test::UploadedFile.new(filename)
241 | }
242 | }
243 | expect(assigns(:form).errors.full_messages).to eq(["File Invalid file format. Must be yaml or json file."])
244 | expect(response).to have_http_status(200)
245 | end
246 | end
247 |
248 | context "destroy" do
249 | it "works" do
250 | expect(translation_key.active).to eq(true)
251 | assert_no_difference ->(){ TranslationKey.count } do
252 | delete rails_i18n_manager.translation_path(translation_key)
253 | end
254 |
255 | translation_key.update_columns(active: false)
256 |
257 | assert_difference ->(){ TranslationKey.count }, -1 do
258 | delete rails_i18n_manager.translation_path(translation_key)
259 | expect(response).to redirect_to(rails_i18n_manager.translations_path)
260 | end
261 | end
262 | end
263 |
264 | end
265 | end
266 |
--------------------------------------------------------------------------------