├── 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 | Gem Version 5 | CI Status 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 | ![Screenshot](/screenshot_list.png) 18 |

    19 | ![Screenshot](/screenshot_import.png) 20 |

    21 | ![Screenshot](/screenshot_edit.png) 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 | --------------------------------------------------------------------------------