canvas-lms/config/initializers/i18n.rb

385 lines
12 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module CanvasI18nFallbacks
# see BCP-47 "Tags for Identifying Languages" for the grammar
# definition that led to this pattern match. It is not 100%
# strictly implemented but this will be more than sufficient
# for Canvas
LANG_PAT = /
^
([a-z]{2,3}) # language
(-[a-z]{4})? # optional script
(-(?:[a-z]{2}|[0-9]{3}))? # optional region
((?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*) # optional variants
((?:-[a-wy-z](?:-[a-z0-9]{2,8})*)*) # optional extensions
(-x(?:-[a-z0-9]{1,8})+)* # optional private use
$
/ix
# This fallback order is more intelligent than simply lopping off
# elements from the end. For instance, in Canvas we use the private
# tag x-k12 in several locales, and we would want to keep that as long
# as possible, for instance we would like sv-FI-x-k12 to fall back to
# sv-x-k12 first, and then sv.
FALLBACK_ORDER = [
[0, 1, 2, 3, 4, 5].freeze, # everything
[0, 1, 2, 3, 5].freeze, # remove extensions
[0, 1, 2, 5].freeze, # remove variants
[0, 1, 5].freeze, # remove region
[0, 1, 2].freeze, # remove private tags
[0, 2, 5].freeze, # remove script
[0, 2].freeze, # only language and region
[0, 5].freeze, # only language and private tags
[0, 1].freeze, # only language and script
[0].freeze # only language code
].freeze
def self.fallbacks(locale)
result = locale.match LANG_PAT
return [] unless result
existing_elements = result.captures.map(&:present?)
order = []
FALLBACK_ORDER.each do |a|
order.push(a.dup.select { |e| existing_elements[e] })
end
order.uniq.map do |ordering|
ordering.map { |idx| result.captures[idx] }.join.to_sym
end
end
end
# Now monkey-patch the i18n gem's Fallbacks::compute method to call our
# fallback generator rather than its own.
# rubocop:disable Style/OptionalBooleanParameter
module I18n
module Locale
class Fallbacks < Hash
def compute(tags, include_defaults = true, exclude = [])
result = Array(tags).flat_map do |tag|
tags = CanvasI18nFallbacks.fallbacks(tag).map(&:to_sym) - exclude
tags.each { |t| tags += compute(@map[t], false, exclude + tags) if @map[t] }
tags
end
result.push(*defaults) if include_defaults
result.uniq!
result.compact!
result
end
end
end
end
# rubocop:enable Style/OptionalBooleanParameter
Rails.configuration.to_prepare do
Rails.application.config.i18n.enforce_available_locales = true
Rails.application.config.i18n.fallbacks = true
# create a unique backend class with the behaviors we want
backend_class = Class.new(I18n::Backend::MetaLazyLoadable)
backend_class.prepend(I18n::Backend::DontTrustPluralizations)
backend_class.include(I18n::Backend::CSV)
backend_class.include(I18n::Backend::Fallbacks)
I18n.backend = backend_class.new(
meta_keys: %w[aliases community crowdsourced custom locales],
lazy_load: !Rails.application.config.eager_load
)
end
module FormatInterpolatedNumbers
def interpolate_hash(string, values)
values = values.dup
values.each do |key, value|
next unless value.is_a?(Numeric)
values[key] = ActiveSupport::NumberHelper.number_to_delimited(value)
end
super(string, values)
end
end
I18n.singleton_class.prepend(FormatInterpolatedNumbers)
I18nliner.infer_interpolation_values = false
module I18nliner
module RehashArrays
def infer_pluralization_hash(default, *args)
if default.is_a?(Array) && default.all? { |a| a.is_a?(Array) && a.size == 2 && a.first.is_a?(Symbol) }
# this was a pluralization hash but rails 4 made it an array in the view helpers
return default.to_h
end
super
end
end
CallHelpers.extend(RehashArrays)
end
if ENV["LOLCALIZE"]
require "i18n_tasks"
I18n.extend I18nTasks::Lolcalize
end
module I18nUtilities
def before_label(text_or_key, default_value = nil, *args)
if default_value
text_or_key = "labels.#{text_or_key}" unless text_or_key.to_s.start_with?("#")
text_or_key = respond_to?(:t) ? t(text_or_key, default_value, *args) : I18n.t(text_or_key, default_value, *args)
end
I18n.t("#before_label_wrapper", "%{text}:", text: text_or_key)
end
def _label_symbol_translation(method, text, options)
if text.is_a?(Hash)
options = text
text = nil
end
text = method if text.nil? && method.is_a?(Symbol)
if text.is_a?(Symbol)
text = "labels.#{text}" unless text.to_s.start_with?("#")
text = t(text, options.delete(:en))
end
text = before_label(text) if options.delete(:before)
[text, options]
end
def n(...)
I18n.n(...)
end
end
ActionView::Base.include I18nUtilities
ActionView::Helpers::FormHelper.include I18nUtilities
ActionView::Helpers::FormTagHelper.include I18nUtilities
module I18nFormHelper
# a convenience method to put the ":" after the label text (or do whatever
# the selected locale dictates)
def blabel(object_name, method, text = nil, options = {})
if text.is_a?(Hash)
options = text
text = nil
end
options[:before] = true
label(object_name, method, text, options)
end
# when removing this, be sure to remove it from i18nliner_extensions.rb
def label(object_name, method, text = nil, options = {})
text, options = _label_symbol_translation(method, text, options)
super(object_name, method, text, options)
end
end
ActionView::Base.include(I18nFormHelper)
ActionView::Helpers::FormHelper.prepend(I18nFormHelper)
module I18nFormTagHelper
def label_tag(method, text = nil, options = {})
text, options = _label_symbol_translation(method, text, options)
super(method, text, options)
end
end
ActionView::Helpers::FormTagHelper.prepend(I18nFormTagHelper)
ActionView::Helpers::FormBuilder.class_eval do
def blabel(method, text = nil, options = {})
if text.is_a?(Hash)
options = text
text = nil
end
options[:before] = true
label(method, text, options)
end
end
module NumberLocalizer
# precision (default nil): if nil, use the precision of the passed in number.
# if you want to cap precision, and have less precise numbers not have trailing zeros, you should be
# rounding the number before passing to this helper, and not passing precision
# percentage (default false): format as a percentage
def n(number, precision: nil, percentage: false)
if percentage
# no precision? default to the number's precision, not to some arbitrary precision
if precision.nil?
precision = 5
strip_insignificant_zeros = true
end
return ActiveSupport::NumberHelper.number_to_percentage(number,
precision:,
strip_insignificant_zeros:)
end
if precision.nil?
return ActiveSupport::NumberHelper.number_to_delimited(number)
end
ActiveSupport::NumberHelper.number_to_rounded(number, precision:)
end
def form_proper_noun_singular_genitive(noun)
if I18n.locale.to_s.start_with?("de") && %(s ß x z).include?(noun.last)
"#{noun}'"
else
I18n.t("#proper_noun_singular_genitive", "%{noun}'s", noun:)
end
end
end
I18n.singleton_class.include(NumberLocalizer)
I18n.extend(Module.new do
attr_accessor :localizer
# Public: If a localizer has been set, use it to set the locale and then
# delete it.
#
# Returns nothing.
def set_locale_with_localizer
if localizer
local_localizer, self.localizer = localizer, nil
self.locale = local_localizer.call
end
end
def translate(*args)
set_locale_with_localizer
begin
super
rescue I18n::MissingInterpolationArgument
# if we change an en default and its interpolation logic without
# changing its key, we might have broken translations during the
# window where we're waiting for updated translations. broken as in
# crashy, not just missing. if that's the case, just fall back to
# english, rather than asploding
key, options = I18nliner::CallHelpers.infer_arguments(args)
raise if (options[:locale] || locale) == default_locale
super(key, options.merge(locale: default_locale))
end
end
alias_method :t, :translate
def locale
set_locale_with_localizer
super
end
def bigeasy_locale
backend.send(:lookup, locale.to_s, "bigeasy_locale") || locale.to_s.tr("-", "_")
end
def fullcalendar_locale
backend.send(:lookup, locale.to_s, "fullcalendar_locale") || locale.to_s.downcase
end
def rtl?
backend.send(:lookup, locale.to_s, "rtl")
end
def moment_locale
backend.send(:lookup, locale.to_s, "moment_locale") || locale.to_s.downcase
end
def dow_offset
backend.send(:lookup, locale.to_s, "dow_offset") || 0
end
end)
# see also corresponding extractor logic in
# i18n_extraction/i18nliner_extensions
require "i18n_extraction/i18nliner_scope_extensions"
module I18nTemplate
def render(view, *, **)
old_i18nliner_scope = view.i18nliner_scope
if @virtual_path
view.i18nliner_scope = I18nliner::Scope.new(@virtual_path.gsub(%r{/_?}, "."))
end
super
ensure
view.i18nliner_scope = old_i18nliner_scope
end
end
ActionView::Template.prepend(I18nTemplate)
ActionView::Base.class_eval do
attr_accessor :i18nliner_scope
end
ActionController::Base.class_eval do
def i18nliner_scope
@i18nliner_scope ||= I18nliner::Scope.new(controller_path.tr("/", "."))
end
end
ActiveRecord::Base.class_eval do
include I18nUtilities
extend I18nUtilities
def i18nliner_scope
self.class.i18nliner_scope
end
def self.i18nliner_scope
@i18nliner_scope ||= I18nliner::Scope.new(name.underscore)
end
class << self
# so that we don't load up the locales until we need them
class LocalesProxy
def include?(item)
I18n.available_locales.map(&:to_s).include?(item)
end
end
LOCALE_LIST = LocalesProxy.new
def validates_locale(*args)
options = args.last.is_a?(Hash) ? args.pop : {}
args << :locale if args.empty?
if options[:allow_nil] && !options[:allow_empty]
before_validation do |record|
args.each do |field|
record.write_attribute(field, nil) if record.read_attribute(field) == ""
end
end
end
args.each do |field|
validates_inclusion_of field, options.merge(in: LOCALE_LIST, if: :"#{field}_changed?")
end
end
end
end
module ToSentenceWithSimpleOr
def to_sentence(options = {})
if options == :or
super(two_words_connector: I18n.t("support.array.or.two_words_connector"),
last_word_connector: I18n.t("support.array.or.last_word_connector"))
else
super
end
end
end
Array.prepend(ToSentenceWithSimpleOr)