air18n 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +18 -0
- data/.rvmrc +48 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +29 -0
- data/Rakefile +9 -0
- data/air18n.gemspec +25 -0
- data/lib/air18n.rb +83 -0
- data/lib/air18n/backend.rb +194 -0
- data/lib/air18n/class_methods.rb +292 -0
- data/lib/air18n/less_silly_chain.rb +54 -0
- data/lib/air18n/logging_helper.rb +13 -0
- data/lib/air18n/mock_priority.rb +25 -0
- data/lib/air18n/phrase.rb +92 -0
- data/lib/air18n/phrase_screenshot.rb +76 -0
- data/lib/air18n/phrase_translation.rb +348 -0
- data/lib/air18n/prim_and_proper.rb +94 -0
- data/lib/air18n/priority.rb +13 -0
- data/lib/air18n/pseudo_locales.rb +53 -0
- data/lib/air18n/reflection.rb +10 -0
- data/lib/air18n/screenshot.rb +45 -0
- data/lib/air18n/testing_support/factories.rb +3 -0
- data/lib/air18n/testing_support/factories/phrase.rb +8 -0
- data/lib/air18n/testing_support/factories/phrase_screenshot.rb +8 -0
- data/lib/air18n/testing_support/factories/phrase_translation.rb +17 -0
- data/lib/air18n/version.rb +3 -0
- data/lib/air18n/xss_detector.rb +47 -0
- data/lib/generators/air18n/migration/migration_generator.rb +39 -0
- data/lib/generators/air18n/migration/templates/active_record/migration.rb +63 -0
- data/spec/database.yml +3 -0
- data/spec/factories.rb +2 -0
- data/spec/lib/air18n/air18n_spec.rb +144 -0
- data/spec/lib/air18n/backend_spec.rb +173 -0
- data/spec/lib/air18n/phrase_translation_spec.rb +80 -0
- data/spec/lib/air18n/prim_and_proper_spec.rb +21 -0
- data/spec/lib/air18n/pseudo_locales_spec.rb +17 -0
- data/spec/lib/air18n/xss_detector_spec.rb +47 -0
- data/spec/spec_helper.rb +62 -0
- metadata +212 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# This is an RVM Project .rvmrc file, used to automatically load the ruby
|
4
|
+
# development environment upon cd'ing into the directory
|
5
|
+
|
6
|
+
# First we specify our desired <ruby>[@<gemset>], the @gemset name is optional,
|
7
|
+
# Only full ruby name is supported here, for short names use:
|
8
|
+
# echo "rvm use 1.9.3" > .rvmrc
|
9
|
+
environment_id="ruby-1.9.3-p125@air18n"
|
10
|
+
|
11
|
+
# Uncomment the following lines if you want to verify rvm version per project
|
12
|
+
# rvmrc_rvm_version="1.12.3 (stable)" # 1.10.1 seams as a safe start
|
13
|
+
# eval "$(echo ${rvm_version}.${rvmrc_rvm_version} | awk -F. '{print "[[ "$1*65536+$2*256+$3" -ge "$4*65536+$5*256+$6" ]]"}' )" || {
|
14
|
+
# echo "This .rvmrc file requires at least RVM ${rvmrc_rvm_version}, aborting loading."
|
15
|
+
# return 1
|
16
|
+
# }
|
17
|
+
|
18
|
+
# First we attempt to load the desired environment directly from the environment
|
19
|
+
# file. This is very fast and efficient compared to running through the entire
|
20
|
+
# CLI and selector. If you want feedback on which environment was used then
|
21
|
+
# insert the word 'use' after --create as this triggers verbose mode.
|
22
|
+
if [[ -d "${rvm_path:-$HOME/.rvm}/environments"
|
23
|
+
&& -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
|
24
|
+
then
|
25
|
+
\. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
|
26
|
+
[[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]] &&
|
27
|
+
\. "${rvm_path:-$HOME/.rvm}/hooks/after_use" || true
|
28
|
+
else
|
29
|
+
# If the environment file has not yet been created, use the RVM CLI to select.
|
30
|
+
rvm --create "$environment_id" || {
|
31
|
+
echo "Failed to create RVM environment '${environment_id}'."
|
32
|
+
return 1
|
33
|
+
}
|
34
|
+
fi
|
35
|
+
|
36
|
+
# If you use bundler, this might be useful to you:
|
37
|
+
# if [[ -s Gemfile ]] && {
|
38
|
+
# ! builtin command -v bundle >/dev/null ||
|
39
|
+
# builtin command -v bundle | grep $rvm_path/bin/bundle >/dev/null
|
40
|
+
# }
|
41
|
+
# then
|
42
|
+
# printf "%b" "The rubygem 'bundler' is not installed. Installing it now.\n"
|
43
|
+
# gem install bundler
|
44
|
+
# fi
|
45
|
+
# if [[ -s Gemfile ]] && builtin command -v bundle >/dev/null
|
46
|
+
# then
|
47
|
+
# bundle install | grep -vE '^Using|Your bundle is complete'
|
48
|
+
# fi
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Jason Katz-Brown
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Air18n
|
2
|
+
|
3
|
+
Rails plugin for amazing internationalization.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'air18n'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install air18n
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/air18n.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/air18n/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Jason Katz-Brown", "Nick Grandy", "Naseem Hakim", "Horace Ko", "Matt Baker"]
|
6
|
+
gem.email = ["jason@airbnb.com", "nick@airbnb.com", "naseem@airbnb.com", "horace@airbnb.com", "matt.baker@airbnb.com"]
|
7
|
+
gem.summary = %q{Dynamic I18n backend}
|
8
|
+
gem.description = %q{Rails plugin with dyanmic I18n backend for amazing internationalization.}
|
9
|
+
gem.homepage = "http://www.github.com/airbnb/air18n"
|
10
|
+
|
11
|
+
gem.add_runtime_dependency 'i18n', '>= 0.5.0'
|
12
|
+
gem.add_runtime_dependency 'activerecord', '~> 3.0'
|
13
|
+
gem.add_development_dependency "rspec"
|
14
|
+
gem.add_development_dependency 'sqlite3'
|
15
|
+
gem.add_development_dependency 'guard'
|
16
|
+
gem.add_development_dependency 'guard-rspec'
|
17
|
+
gem.add_development_dependency 'factory_girl', '~> 3.0'
|
18
|
+
|
19
|
+
gem.files = `git ls-files`.split($\)
|
20
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
21
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
22
|
+
gem.name = "air18n"
|
23
|
+
gem.require_paths = ["lib"]
|
24
|
+
gem.version = Air18n::VERSION
|
25
|
+
end
|
data/lib/air18n.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require "i18n"
|
2
|
+
|
3
|
+
require 'air18n/class_methods'
|
4
|
+
require 'air18n/backend'
|
5
|
+
require 'air18n/priority'
|
6
|
+
|
7
|
+
require 'air18n/reflection'
|
8
|
+
|
9
|
+
module Air18n
|
10
|
+
AIR18N_RESERVED_KEYS = [:routes_context, :suppress_ct, :default_is_low_priority, :disable_xss_check]
|
11
|
+
|
12
|
+
def self.included(base)
|
13
|
+
base.class_eval do
|
14
|
+
# Currencies that correspond to users who want distance unit to be
|
15
|
+
# miles.
|
16
|
+
self.const_set(:MILES_CURRENCIES, ['USD', 'GBP'])
|
17
|
+
|
18
|
+
class << self
|
19
|
+
extend ActiveSupport::Memoizable
|
20
|
+
|
21
|
+
module_eval do
|
22
|
+
include ClassMethods
|
23
|
+
end
|
24
|
+
|
25
|
+
# Country, as a symbol. This is separate from locale, and should set based
|
26
|
+
# only on the domain name or IP address or some other kind of geographic
|
27
|
+
# location.
|
28
|
+
attr_accessor :country
|
29
|
+
|
30
|
+
# Like country; set it to :US if we're on the global domain to show
|
31
|
+
# correct terms/conditions
|
32
|
+
attr_accessor :legal_country
|
33
|
+
|
34
|
+
# An instance of I18n::Locale::Fallbacks for computing locale fallbacks.
|
35
|
+
attr_reader :fallbacks
|
36
|
+
|
37
|
+
# Manages prioritization of translation keys. If set, must implement
|
38
|
+
# methods of Air18n::Priority.
|
39
|
+
attr_accessor :priority
|
40
|
+
|
41
|
+
# Used as routes context if not specified in options passed to translate().
|
42
|
+
attr_accessor :fallback_routes_context
|
43
|
+
|
44
|
+
# Currency for the current request.
|
45
|
+
@currency = 'USD'
|
46
|
+
attr_reader :currency
|
47
|
+
|
48
|
+
# Distance unit, 'MI' or 'KM'.
|
49
|
+
@distance_unit = 'KM'
|
50
|
+
attr_reader :distance_unit
|
51
|
+
|
52
|
+
# Whether to wrap phrases in HTML that allows them to be translated by
|
53
|
+
# the user from directly on the page.
|
54
|
+
@contextual_translation = false
|
55
|
+
|
56
|
+
alias_method :t_with_routes_context, :translate_with_routes_context
|
57
|
+
alias_method_chain :t, :routes_context
|
58
|
+
alias_method_chain :translate, :routes_context
|
59
|
+
|
60
|
+
memoize :localized_path_explicit_locale
|
61
|
+
memoize :language_from_locale
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Make sure we don't try to interpolate our Air18n-specific reserved keys.
|
68
|
+
I18n::Backend::Base.module_eval do
|
69
|
+
def interpolate_with_new_reserved_keys(locale, string, values = {})
|
70
|
+
interpolate_without_new_reserved_keys(locale, string, values.except(*Air18n::AIR18N_RESERVED_KEYS))
|
71
|
+
end
|
72
|
+
alias_method_chain :interpolate, :new_reserved_keys
|
73
|
+
end
|
74
|
+
|
75
|
+
I18n.send(:include, Air18n)
|
76
|
+
I18n.reset(:en)
|
77
|
+
|
78
|
+
# This should really be done by default! Make the simple backend also use
|
79
|
+
# fallbacks.
|
80
|
+
I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks)
|
81
|
+
|
82
|
+
# Use the fancy version of I18n locale tags.
|
83
|
+
I18n::Locale::Tag.implementation = I18n::Locale::Tag::Rfc4646
|
@@ -0,0 +1,194 @@
|
|
1
|
+
require 'i18n'
|
2
|
+
|
3
|
+
require 'air18n/logging_helper'
|
4
|
+
require 'air18n/prim_and_proper'
|
5
|
+
require 'air18n/pseudo_locales'
|
6
|
+
|
7
|
+
module Air18n
|
8
|
+
class Backend
|
9
|
+
include I18n::Backend::Base
|
10
|
+
|
11
|
+
attr_accessor :translation_data, :initialized, :phrase_screenshots
|
12
|
+
|
13
|
+
# Stores translations for a given locale.
|
14
|
+
def store_translations locale, data, options = {}
|
15
|
+
@translation_data ||= {}
|
16
|
+
@translation_data[locale.to_sym] = data
|
17
|
+
end
|
18
|
+
|
19
|
+
def available_locales
|
20
|
+
if @translation_data
|
21
|
+
@translation_data.inject([]) do |carry, (locale, translations)|
|
22
|
+
carry << locale unless translations.empty?
|
23
|
+
carry
|
24
|
+
end
|
25
|
+
else
|
26
|
+
[]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def reload_translations(locales)
|
31
|
+
# We don't want to load translations of unused phrases, so we exclude
|
32
|
+
# unused from the set of translations we initialize.
|
33
|
+
translations_hash = PhraseTranslation.translations_for_locales(locales, :exclude_unused => true)
|
34
|
+
|
35
|
+
# In development, people might not be running the counter server to keep
|
36
|
+
# track of which phrases are used. In this ase, load all phrases, except UGC.
|
37
|
+
if translations_hash.all? { |locale, translations| translations.empty? }
|
38
|
+
translations_hash = PhraseTranslation.translations_for_locales(locales, :exclude_ugc => true)
|
39
|
+
end
|
40
|
+
|
41
|
+
num_translations = translations_hash.inject(0) { |carry, (locale, translations)| carry + translations.size }
|
42
|
+
LoggingHelper.info "Loaded #{num_translations} translations for locales #{locales.inspect}."
|
43
|
+
|
44
|
+
number_of_translations = 0
|
45
|
+
translations_hash.each_pair do |locale, data|
|
46
|
+
store_translations(locale, data)
|
47
|
+
number_of_translations += data.size
|
48
|
+
|
49
|
+
@translations_last_loaded_at ||= {}
|
50
|
+
@translations_last_loaded_at[locale] = Time.now
|
51
|
+
end
|
52
|
+
|
53
|
+
LoggingHelper.info "Translation data size: #{Marshal.dump(@translation_data).size}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def init_translations(locale)
|
57
|
+
@phrase_screenshots ||= PhraseScreenshot.all_phrase_urls
|
58
|
+
if @translation_data.nil? || !@translation_data.include?(locale)
|
59
|
+
reload_translations([locale])
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def check_for_new_translations(locale)
|
64
|
+
# When TranslateController makes a new translation, it sets
|
65
|
+
# translations_last_updated_at to the current time in the cache.
|
66
|
+
# If we haven't reset the i18n backends since then, we take the opportunity to reset them.
|
67
|
+
last_updated_at = Rails.cache.read("#{locale}_translations_last_updated_at")
|
68
|
+
if last_updated_at && last_updated_at.to_i > @translations_last_loaded_at[locale].to_i
|
69
|
+
reload_translations([locale])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def guess_translation(text, orig_locale, other_locale)
|
74
|
+
@prim_and_proper ||= PrimAndProper.new
|
75
|
+
@prim_and_proper.guess(text, orig_locale, other_locale)
|
76
|
+
end
|
77
|
+
|
78
|
+
# We will only take 10 screenshots per Rails instance per key.
|
79
|
+
# This is to avoid having to moderate 200 screenshots for every new site-wide
|
80
|
+
# phrase.
|
81
|
+
# Because this is a per-Rails-thread cap, we call it approximate.
|
82
|
+
APPROX_MAX_SCREENSHOTS_PER_KEY = 10
|
83
|
+
|
84
|
+
# This method does the meat of Air18n functionality.
|
85
|
+
# 1) Finds the translations for specified key and locale, falling back to
|
86
|
+
# appropriate locales if necessary based on I18n.fallbacks.
|
87
|
+
# 2) Queues up screenshot-taking jobs if we don't have a screenshot for a
|
88
|
+
# (key, options[:routes_context])
|
89
|
+
# 3) Populates the phrases database if key and options[:default] aren't
|
90
|
+
# already in there.
|
91
|
+
# 4) Updates the phrases database if options[:default] doesn't match the
|
92
|
+
# English text in the database. Alternatively, you can use
|
93
|
+
# options[:default_is_low_priority] if you don't want this behavior.
|
94
|
+
# 5) Makes the :xx locale translations.
|
95
|
+
# 6) Asks PrimAndProper to make en-GB translations and other best-guess
|
96
|
+
# translations if appropriate.
|
97
|
+
def lookup(locale, key, scope = [], options = {})
|
98
|
+
# Useful i18n logging for debugging translation lookup problems.
|
99
|
+
# LoggingHelper.info "Lookup! key is #{key.inspect}, options are #{options.inspect}"
|
100
|
+
# caller.each { |l| LoggingHelper.info " " + l }
|
101
|
+
|
102
|
+
# Sometimes translate() is called with an array of keys. We don't handle that case.
|
103
|
+
if key.blank? || !key.is_a?(String)
|
104
|
+
return nil
|
105
|
+
end
|
106
|
+
|
107
|
+
# Force locale to symbol to allow e.g.
|
108
|
+
# I18n.t('foo', :default => 'Foo', :locale => @current_user.preferred_locale)
|
109
|
+
locale = locale.to_sym
|
110
|
+
|
111
|
+
default = options[:default] && options[:default].is_a?(String) && options[:default]
|
112
|
+
overrides_previous_default = !options[:default_is_low_priority]
|
113
|
+
|
114
|
+
init_translations(locale)
|
115
|
+
|
116
|
+
# Only create new screenshots while using default locale, and ignore keys
|
117
|
+
# that come in hash format or with a wacky namespace
|
118
|
+
if options[:routes_context] && locale == I18n.default_locale
|
119
|
+
# Check to see if we have screenshot for this phrase/routes context combo
|
120
|
+
unless @phrase_screenshots[key] && (@phrase_screenshots[key].include?(options[:routes_context]) || @phrase_screenshots[key].size >= APPROX_MAX_SCREENSHOTS_PER_KEY)
|
121
|
+
I18n.phrase_needs_screenshot(options[:routes_context], {key => @translation_data[I18n.default_locale][key] || default || key})
|
122
|
+
@phrase_screenshots[key] = (@phrase_screenshots[key] || []) << options[:routes_context]
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
fallback_chain = I18n.fallbacks_for(locale)
|
127
|
+
result = nil
|
128
|
+
locale_fallen_back_to = nil
|
129
|
+
for fallback_locale in fallback_chain
|
130
|
+
if @translation_data.include?(fallback_locale)
|
131
|
+
result = @translation_data[fallback_locale][key]
|
132
|
+
locale_fallen_back_to = fallback_locale
|
133
|
+
break if result
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if result && locale_fallen_back_to != locale
|
138
|
+
# See if we can guess a translation instead of using the fallback
|
139
|
+
# translation.
|
140
|
+
guess = guess_translation(result, locale_fallen_back_to, locale)
|
141
|
+
result = guess if guess
|
142
|
+
end
|
143
|
+
|
144
|
+
# If there was a default-language translation, check if it matches the default.
|
145
|
+
# If the phrase is not in the phrases table, it will be created.
|
146
|
+
if locale == I18n.default_locale && default && result != default
|
147
|
+
# If it doesn't, we need to update the 'phrases' table so that
|
148
|
+
# the 'value' column reflects the latest English default text.
|
149
|
+
LoggingHelper.info "Default English text for key '#{key}' changed! Old default '#{result}' != new default '#{default}'. Route context: #{options[:routes_context]}"
|
150
|
+
phrase = Phrase.find_or_create_by_key(key)
|
151
|
+
if phrase
|
152
|
+
if (overrides_previous_default && phrase.value != default) ||
|
153
|
+
(!overrides_previous_default && phrase.value != default && phrase.value.blank?)
|
154
|
+
phrase.value = default
|
155
|
+
|
156
|
+
begin
|
157
|
+
phrase.save
|
158
|
+
rescue Exception => e
|
159
|
+
# If many requests happen simultaneously for a page with a new
|
160
|
+
# phrase, a "duplicate entry" exception will happen naturally.
|
161
|
+
# And if phrase creation fails for some other reason, we will try
|
162
|
+
# again to create it next time automatically.
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
@translation_data[locale][key] = default
|
168
|
+
result = default
|
169
|
+
end
|
170
|
+
|
171
|
+
# Helps debug translation loading and default setting.
|
172
|
+
# LoggingHelper.info "Airbnb Backend looking up key #{key.inspect}. result is #{result.inspect}. Default is #{default.inspect}"
|
173
|
+
|
174
|
+
# if a default is given, use it here
|
175
|
+
if result.nil? && default
|
176
|
+
result = default
|
177
|
+
end
|
178
|
+
|
179
|
+
unless options[:disable_xss_check]
|
180
|
+
xss_detection = XssDetector::safe?(default, result)
|
181
|
+
if !xss_detection[:safe]
|
182
|
+
# Kill the translation if the result is unsafe.
|
183
|
+
LoggingHelper.error "Killing unsafe translation! Default is #{default.inspect}, result is #{result.inspect}, reason for kill is #{xss_detection[:result]}"
|
184
|
+
result = default
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Handle pseudo-locales.
|
189
|
+
result = PseudoLocales.translate(locale, result)
|
190
|
+
|
191
|
+
result
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,292 @@
|
|
1
|
+
require 'air18n/less_silly_chain'
|
2
|
+
require 'air18n/logging_helper'
|
3
|
+
require 'air18n/xss_detector'
|
4
|
+
|
5
|
+
module Air18n
|
6
|
+
module ClassMethods
|
7
|
+
# Returns only the language part of the current locale, as a symbol.
|
8
|
+
# E.g. "pt" if the locale is pt-BR.
|
9
|
+
def language
|
10
|
+
language_from_locale full_locale
|
11
|
+
end
|
12
|
+
|
13
|
+
# Returns the language code of I18n.default_locale, as a symbol.
|
14
|
+
def default_language
|
15
|
+
language_from_locale default_locale
|
16
|
+
end
|
17
|
+
|
18
|
+
# Deprecated.
|
19
|
+
# This returns the same as language().
|
20
|
+
# TODO(jason) Switch all use of locale() to either language() or
|
21
|
+
# full_locale().
|
22
|
+
def locale_with_language_hack
|
23
|
+
language
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns full locale as a symbol, like :es, :en, :"es-419" (Latin-American
|
27
|
+
# Spanish) or :"pt-BR" or :"en-GB".
|
28
|
+
def full_locale
|
29
|
+
config.locale
|
30
|
+
end
|
31
|
+
|
32
|
+
# The one true way to set the locale.
|
33
|
+
def full_locale=(locale)
|
34
|
+
config.locale = locale.to_sym
|
35
|
+
end
|
36
|
+
|
37
|
+
# Deprecated.
|
38
|
+
# Sets locale. Using full_locale= is preferable.
|
39
|
+
def locale=(locale)
|
40
|
+
LoggingHelper.info "I18n.locale=() is deprecated. Use I18n.full_locale=()."
|
41
|
+
self.full_locale = locale
|
42
|
+
end
|
43
|
+
|
44
|
+
# For a locale (like :en, :en-GB, :pt-PT), returns the "language"
|
45
|
+
# part (like :en or :pt).
|
46
|
+
# If locale is invalid or nil, returns the default language.
|
47
|
+
def language_from_locale locale
|
48
|
+
tags = I18n::Locale::Tag.tag(locale)
|
49
|
+
tags ? tags.language.to_sym : language_from_locale(I18n.default_locale)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Gets a hash containing phrases that need to be screenshot in the
|
53
|
+
# specified routes_context.
|
54
|
+
# After this method returns the phrases, it forgets about them, so you
|
55
|
+
# can only retrieve them once.
|
56
|
+
def retrieve_phrases_needing_screenshot(routes_context)
|
57
|
+
return {} unless @phrases_needing_screenshot && @phrases_needing_screenshot.include?(routes_context)
|
58
|
+
@phrases_needing_screenshot.delete(routes_context)
|
59
|
+
end
|
60
|
+
|
61
|
+
def phrase_needs_screenshot(routes_context, phrase)
|
62
|
+
@phrases_needing_screenshot ||= Hash.new { |h, routes_context| h[routes_context] = {} }
|
63
|
+
@phrases_needing_screenshot[routes_context].merge!(phrase)
|
64
|
+
end
|
65
|
+
|
66
|
+
def contextual_translation
|
67
|
+
if full_locale != default_locale
|
68
|
+
@contextual_translation
|
69
|
+
else
|
70
|
+
false
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def contextual_translation= x
|
75
|
+
@contextual_translation= x
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns OrderedHash mapping key => times looked up today or in the last week.
|
79
|
+
def still_used_keys_hash
|
80
|
+
# If you want to test with the full allotment of still-used phrases from
|
81
|
+
# production, do this on mainframe:
|
82
|
+
# File.open('/tmp/still_used', 'w') { |f| f.write Counter.retrieve_counts_from_today_and_last_week("t").to_json }
|
83
|
+
# Then copy /tmp/still_used to your local machine, and uncomment the next line.
|
84
|
+
# @still_used_keys ||= File.open('/tmp/still_used') { |f| JSON.parse(f.read()) }
|
85
|
+
|
86
|
+
@priority ? @priority.key_usage : {}
|
87
|
+
end
|
88
|
+
|
89
|
+
# Returns list of keys looked up today or in the last week.
|
90
|
+
def still_used_keys
|
91
|
+
still_used_keys_hash.keys
|
92
|
+
end
|
93
|
+
|
94
|
+
# Returns list of phrase IDs that are still used.
|
95
|
+
def still_used_phrase_ids
|
96
|
+
@still_used_phrase_ids ||= begin
|
97
|
+
key_to_phrase_id = {}
|
98
|
+
# Uses raw SQL for speed.
|
99
|
+
Phrase.connection.select_all("SELECT `id`, `key` FROM phrases").each do |record|
|
100
|
+
key_to_phrase_id[record['key']] = record['id']
|
101
|
+
end
|
102
|
+
|
103
|
+
still_used_keys.map do |key|
|
104
|
+
LoggingHelper.error "Mystery phrase key: #{key}" if !key_to_phrase_id.include? key
|
105
|
+
key_to_phrase_id[key]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Phrases that are from user-generated content (like hosting names) have a
|
111
|
+
# key prefixed with "ugc.".
|
112
|
+
def phrase_key_is_ugc?(key)
|
113
|
+
key.starts_with?('ugc.')
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns true if key should be translated.
|
117
|
+
# A key needs to be translated if it is either UGC or was looked up by t()
|
118
|
+
# today or in the last week.
|
119
|
+
def needs_translation?(key)
|
120
|
+
phrase_key_is_ugc?(key) || still_used_keys_hash.include?(key)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Create Air18n backend with given default_locale and translation prioritizer.
|
124
|
+
def reset(default_locale)
|
125
|
+
self.default_locale = default_locale
|
126
|
+
|
127
|
+
# Use locale fallbacks that always include default locale as the last
|
128
|
+
# fallback.
|
129
|
+
@fallbacks = I18n::Locale::Fallbacks.new(default_locale)
|
130
|
+
|
131
|
+
# Make the single instance of Air18n::Backend.
|
132
|
+
@air18n_backend = Air18n::Backend.new
|
133
|
+
|
134
|
+
# This activates Air18n by putting Air18n::Backend before I18n.backend.
|
135
|
+
self.backend = Air18n::LessSillyChain.new(@air18n_backend, I18n::Backend::Simple.new)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Return the fallback locales for the_locale.
|
139
|
+
# If opts[:exclude_default] is set, the default locale, which is otherwise
|
140
|
+
# always the last one in the returned list, will be excluded.
|
141
|
+
#
|
142
|
+
# For example, fallbacks_for(:"pt-BR") is [:"pt-BR", :pt, :en] with
|
143
|
+
# exclude_default = false and [:"pt-BR", :pt] with exclude_default = true.
|
144
|
+
def fallbacks_for(the_locale, opts = {})
|
145
|
+
# We dup() because otherwise the fallbacks hash might itself be modified
|
146
|
+
# by the user of this method, and that would be terrible.
|
147
|
+
ret = @fallbacks[the_locale].dup
|
148
|
+
|
149
|
+
# We make two assumptions here:
|
150
|
+
# 1) There is only one default locale (that is, it has no less-specific
|
151
|
+
# children)
|
152
|
+
# 1) The default locale is just a language. (Like :en, and not :"en-US".)
|
153
|
+
if opts[:exclude_default] && ret.last == self.default_locale && ret.last != language_from_locale(the_locale)
|
154
|
+
ret.pop
|
155
|
+
end
|
156
|
+
|
157
|
+
ret
|
158
|
+
end
|
159
|
+
|
160
|
+
# Returns whether the_locale has other_locale in its non-default fallbacks.
|
161
|
+
def falls_back_to?(the_locale, other_locale)
|
162
|
+
fallbacks_for(the_locale, :exclude_default => true).include?(other_locale)
|
163
|
+
end
|
164
|
+
|
165
|
+
def check_for_new_translations
|
166
|
+
@air18n_backend.check_for_new_translations(I18n.full_locale)
|
167
|
+
end
|
168
|
+
|
169
|
+
def currency=(c)
|
170
|
+
@currency = c
|
171
|
+
@distance_unit = self::MILES_CURRENCIES.include?(c) ? "MI" : "KM"
|
172
|
+
end
|
173
|
+
|
174
|
+
# Parses a date using the current locale setting.
|
175
|
+
# 2-digit years are interpreted as 19XX (>= 69) or 20XX (< 69).
|
176
|
+
def parse_date s, locale = nil
|
177
|
+
return s if s.nil?
|
178
|
+
|
179
|
+
if /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\Z/.match s
|
180
|
+
# If this looks like an ISO date, YYYY-mm-dd, let [Date.parse] handle
|
181
|
+
# parsing since [Date.strptime] varies by platform (darwin vs. linux)
|
182
|
+
Date.parse(s, true)
|
183
|
+
else
|
184
|
+
locale ||= I18n.full_locale
|
185
|
+
s = s.to_str
|
186
|
+
format = I18n.t('date.formats.default', :locale => locale.to_sym)
|
187
|
+
begin
|
188
|
+
date = Date.strptime(s, format)
|
189
|
+
if date.year < 100
|
190
|
+
date = Date.strptime(s, format.gsub("%Y", "%y"))
|
191
|
+
end
|
192
|
+
rescue
|
193
|
+
# When date is not parsable in user's locale attempt default
|
194
|
+
# date handling for cases like booking where dates can be
|
195
|
+
# in the international format "%Y-%m-%d". Failure to parse
|
196
|
+
# after this will raise an error.
|
197
|
+
date = Date.parse(s, true)
|
198
|
+
end
|
199
|
+
date
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Use when formatted string will be used by jQuery UI or calendar text fields
|
204
|
+
def format_date d, locale = nil
|
205
|
+
# temp handle iso date strings
|
206
|
+
if d.is_a?(String)
|
207
|
+
d = Date.parse(d)
|
208
|
+
end
|
209
|
+
|
210
|
+
locale ||= I18n.full_locale
|
211
|
+
format = I18n.t('date.formats.default', :locale => locale.to_sym)
|
212
|
+
if d.nil?
|
213
|
+
format.gsub('%m', 'mm').gsub('%d', 'dd').gsub('%Y', 'yy')
|
214
|
+
else
|
215
|
+
I18n.l(d, :format => format)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Replaces LOCALE and COUNTRY, if they are present in path, with
|
220
|
+
# I18n.locale and I18n.country. You can explicitly set the locale and
|
221
|
+
# country with options[:locale] and options[:country].
|
222
|
+
#
|
223
|
+
# Use this if the resource you reference lives under public/. For example,
|
224
|
+
# to reference an image file, pass just call e.g.
|
225
|
+
# localized_public_path("/images/buttons/awesome-LOCALE.png").
|
226
|
+
def localized_public_path(path_relative_to_public, options = {})
|
227
|
+
localized_path(path_relative_to_public, options.merge({:absolute_prefix_on_disk => Rails.public_path}))
|
228
|
+
end
|
229
|
+
|
230
|
+
# Replaces LOCALE and COUNTRY, if they are present in path, with
|
231
|
+
# I18n.locale and I18n.country. You can explicitly set the locale and
|
232
|
+
# country with options[:locale] and options[:country].
|
233
|
+
#
|
234
|
+
# If path is not absolute, you must also set
|
235
|
+
# options[:absolute_prefix_on_disk] so that absolute_prefix_on_disk+path is
|
236
|
+
# a valid absolute path (after locale or country interpolations).
|
237
|
+
# If you are referencing something in public/, you should use
|
238
|
+
# localized_public_path.
|
239
|
+
#
|
240
|
+
# If the file does not exist after interpolation, LOCALE will be replaced
|
241
|
+
# with "en" and COUNTRY will be replaced with "us".
|
242
|
+
def localized_path(path, options = {})
|
243
|
+
options[:locale] ||= I18n.locale
|
244
|
+
options[:country] ||= I18n.country
|
245
|
+
localized_path_explicit_locale(path, options)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Same as localized_path but explicitly uses options[:locale] and
|
249
|
+
# options[:country] so that it can be memoized.
|
250
|
+
def localized_path_explicit_locale(path, options = {})
|
251
|
+
replaced = path.gsub('LOCALE', options[:locale].to_s).gsub('COUNTRY', options[:country].to_s)
|
252
|
+
|
253
|
+
# TODO(jason) Use full locale and its fallbacks.
|
254
|
+
absolute_path = options[:absolute_prefix_on_disk] ? File.join(options[:absolute_prefix_on_disk], "images", replaced) : replaced
|
255
|
+
if File.exist?(absolute_path)
|
256
|
+
replaced
|
257
|
+
else
|
258
|
+
path.gsub('COUNTRY', "US").gsub('LOCALE', "en")
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# Add :routes_context option, which is used for translation screenshotting.
|
263
|
+
# I18n.fallback_routes_context is set by an ApplicationController
|
264
|
+
# before_filter.
|
265
|
+
def translate_with_routes_context(key, options = {})
|
266
|
+
if !options.include?(:routes_context) && I18n.fallback_routes_context
|
267
|
+
options = options.merge(:routes_context => I18n.fallback_routes_context)
|
268
|
+
end
|
269
|
+
result = translate_without_routes_context(key, options)
|
270
|
+
|
271
|
+
# Handle contextual translation.
|
272
|
+
if result && I18n.contextual_translation && (!options[:suppress_ct])
|
273
|
+
phrase = Phrase.by_key(key)
|
274
|
+
|
275
|
+
if phrase
|
276
|
+
# Provides styling to wrap a string into a div so that it can be
|
277
|
+
# clicked on for easy translation.
|
278
|
+
result = "<phrase id='#{Digest::MD5.hexdigest(result + rand(1000).to_s)[0..8]}' data-phrase-id='#{phrase.id}'>#{result}</phrase>"
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
if key.is_a?(String)
|
283
|
+
# Handle bookkeeping of which translation keys are still used.
|
284
|
+
@priority.key_used(key) if @priority
|
285
|
+
end
|
286
|
+
|
287
|
+
result = result.html_safe if result && result.respond_to?(:html_safe)
|
288
|
+
|
289
|
+
result
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|