air18n 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|