air18n 0.1.15 → 0.1.16
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/air18n/backend.rb +10 -1
- data/lib/air18n/phrase_translation.rb +47 -23
- data/lib/air18n/smart_count.rb +174 -0
- data/lib/air18n/version.rb +1 -1
- data/lib/air18n/xss_detector.rb +9 -1
- data/spec/lib/air18n/backend_spec.rb +25 -0
- data/spec/lib/air18n/phrase_translation_spec.rb +67 -0
- data/spec/lib/air18n/smart_count_spec.rb +146 -0
- data/spec/lib/air18n/xss_detector_spec.rb +61 -12
- metadata +5 -2
data/lib/air18n/backend.rb
CHANGED
@@ -3,6 +3,7 @@ require 'i18n'
|
|
3
3
|
require 'air18n/logging_helper'
|
4
4
|
require 'air18n/prim_and_proper'
|
5
5
|
require 'air18n/pseudo_locales'
|
6
|
+
require 'air18n/smart_count'
|
6
7
|
|
7
8
|
module Air18n
|
8
9
|
class Backend
|
@@ -111,6 +112,8 @@ module Air18n
|
|
111
112
|
# 5) Makes the :xx locale translations.
|
112
113
|
# 6) Asks PrimAndProper to make en-GB translations and other best-guess
|
113
114
|
# translations if appropriate.
|
115
|
+
# 7) Chooses the correct translation form based on options[:smart_count] if
|
116
|
+
# present.
|
114
117
|
def lookup(locale, key, scope = [], options = {})
|
115
118
|
# Useful i18n logging for debugging translation lookup problems.
|
116
119
|
# LoggingHelper.info "Lookup! key is #{key.inspect}, options are #{options.inspect}"
|
@@ -202,7 +205,7 @@ module Air18n
|
|
202
205
|
end
|
203
206
|
|
204
207
|
unless options[:disable_xss_check]
|
205
|
-
xss_detection = XssDetector::safe?(default, result)
|
208
|
+
xss_detection = XssDetector::safe?(default, result, I18n.default_locale, locale_fallen_back_to)
|
206
209
|
if !xss_detection[:safe]
|
207
210
|
# Kill the translation if the result is unsafe.
|
208
211
|
LoggingHelper.error "Killing unsafe translation! Default is #{default.inspect}, result is #{result.inspect}, reason for kill is #{xss_detection[:result]}"
|
@@ -210,9 +213,15 @@ module Air18n
|
|
210
213
|
end
|
211
214
|
end
|
212
215
|
|
216
|
+
# Strip whitespace from both sides. For fun?
|
217
|
+
result = result.strip if result.present?
|
218
|
+
|
213
219
|
# Handle pseudo-locales.
|
214
220
|
result = PseudoLocales.translate(locale, result)
|
215
221
|
|
222
|
+
# Handle smart counts.
|
223
|
+
result = SmartCount.choose(result, locale_fallen_back_to, options[SmartCount::INTERPOLATION_VARIABLE_NAME])
|
224
|
+
|
216
225
|
result
|
217
226
|
end
|
218
227
|
end
|
@@ -14,7 +14,9 @@ module Air18n
|
|
14
14
|
before_create :set_latest
|
15
15
|
before_create :set_source_hash
|
16
16
|
|
17
|
+
validate :check_plural_forms
|
17
18
|
validate :check_matching_variables
|
19
|
+
validate :check_max_length
|
18
20
|
|
19
21
|
# Sets is_latest of this translation, and removes the is_latest flag from all
|
20
22
|
# previous translations.
|
@@ -296,7 +298,14 @@ module Air18n
|
|
296
298
|
true
|
297
299
|
end
|
298
300
|
|
299
|
-
def
|
301
|
+
def check_plural_forms
|
302
|
+
result = SmartCount::valid?(self.value, self.locale)
|
303
|
+
if !result[:valid]
|
304
|
+
self.errors.add(:value, result[:reason])
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def check_max_length
|
300
309
|
if /maxlength:(\d+)/ =~ key
|
301
310
|
max_length = $1.to_i
|
302
311
|
length = value.size
|
@@ -304,29 +313,39 @@ module Air18n
|
|
304
313
|
self.errors.add(:value, "Translation has #{length} characters; maximum length #{max_length} characters.")
|
305
314
|
end
|
306
315
|
end
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
316
|
+
end
|
317
|
+
|
318
|
+
def check_matching_variables
|
319
|
+
our_variables = self.variables
|
320
|
+
their_variables = self.phrase.variables
|
321
|
+
|
322
|
+
if SmartCount::applies?(self.phrase.value) || SmartCount::applies?(self.value)
|
323
|
+
our_variables = SmartCount::dedupe_things_like_tags_or_variables(
|
324
|
+
self.locale, our_variables)
|
325
|
+
their_variables = SmartCount::dedupe_things_like_tags_or_variables(
|
326
|
+
I18n.default_locale, their_variables)
|
327
|
+
end
|
328
|
+
|
329
|
+
LoggingHelper.info "our variables: #{our_variables.inspect}"
|
330
|
+
LoggingHelper.info "their variables: #{their_variables.inspect}"
|
331
|
+
|
332
|
+
our_extra = our_variables - their_variables
|
333
|
+
their_extra = their_variables - our_variables
|
334
|
+
problems = []
|
335
|
+
if !their_extra.empty? || !our_extra.empty?
|
336
|
+
if !their_extra.empty?
|
337
|
+
problems << "Var #{quote_vars their_extra} missing from translation"
|
321
338
|
end
|
322
|
-
|
323
|
-
|
339
|
+
if !our_extra.empty?
|
340
|
+
problems << "Var #{quote_vars our_extra} should not be in translation"
|
324
341
|
end
|
342
|
+
elsif our_variables.group_by{|t| t} != their_variables.group_by{|t| t}
|
343
|
+
# This is to catch smart-count-related cases.
|
344
|
+
problems << "Vars don't match: #{quote_vars their_variables} vs. #{quote_vars our_variables}"
|
345
|
+
end
|
346
|
+
unless problems.empty?
|
347
|
+
self.errors.add(:value, problems.join('; '))
|
325
348
|
end
|
326
|
-
end
|
327
|
-
|
328
|
-
def variables_match?
|
329
|
-
self.variables == self.phrase.variables
|
330
349
|
end
|
331
350
|
|
332
351
|
def variables
|
@@ -335,10 +354,10 @@ module Air18n
|
|
335
354
|
|
336
355
|
def self.detect_variables(search_in)
|
337
356
|
case search_in
|
338
|
-
when String then
|
357
|
+
when String then search_in.scan(/\{\{(\w+)\}\}/).flatten + search_in.scan(/\%\{(\w+)\}/).flatten
|
339
358
|
when Array then search_in.inject(Set[]) { |carry, item| carry + detect_variables(item) }
|
340
359
|
when Hash then search_in.values.inject(Set[]) { |carry, item| carry + detect_variables(item) }
|
341
|
-
else
|
360
|
+
else []
|
342
361
|
end
|
343
362
|
end
|
344
363
|
|
@@ -403,5 +422,10 @@ module Air18n
|
|
403
422
|
end
|
404
423
|
return [true]
|
405
424
|
end
|
425
|
+
|
426
|
+
private
|
427
|
+
def quote_vars(vars)
|
428
|
+
vars.map { |var| "%{#{var}}" }.join(", ")
|
429
|
+
end
|
406
430
|
end
|
407
431
|
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
module Air18n
|
2
|
+
module SmartCount
|
3
|
+
# Class for choosing the right plural form of a phrase based on an
|
4
|
+
# interpolation variable with special name "smart_count" (set below in
|
5
|
+
# OPTION_NAME constant).
|
6
|
+
#
|
7
|
+
# If a phrase uses the smart_count interpolation variable, translators can
|
8
|
+
# specify multiple translations, one per possible pluralization form of the
|
9
|
+
# language. If there is more than one pluralization form, translations are
|
10
|
+
# separated by ";" (set below in DELIMITER constant).
|
11
|
+
|
12
|
+
INTERPOLATION_VARIABLE_NAME = :smart_count
|
13
|
+
INTERPOLATION_VARIABLE_PLACEHOLDER = "%{#{INTERPOLATION_VARIABLE_NAME}}"
|
14
|
+
DELIMITER = ';'
|
15
|
+
|
16
|
+
# C.F.
|
17
|
+
# https://developer.mozilla.org/en-US/docs/Localization_and_Plurals
|
18
|
+
# http://translate.sourceforge.net/wiki/l10n/pluralforms
|
19
|
+
PLURAL_TYPES = {
|
20
|
+
:chinese_like => {
|
21
|
+
:num_forms => 1,
|
22
|
+
:doc => ['One form for all counts.'],
|
23
|
+
:rule => lambda { |n| 0 } },
|
24
|
+
|
25
|
+
:german_like => {
|
26
|
+
:num_forms => 2,
|
27
|
+
:doc => ['Count is 1', 'Everything else (0, 2, 3, ...)'],
|
28
|
+
:rule => lambda { |n| n != 1 ? 1 : 0 } },
|
29
|
+
|
30
|
+
:french_like => {
|
31
|
+
:num_forms => 2,
|
32
|
+
:doc => ['Count is 0 or 1', 'Everything else (2, 3, 4, ...)'],
|
33
|
+
:rule => lambda { |n| n > 1 ? 1 : 0 } },
|
34
|
+
|
35
|
+
:russian_like => {
|
36
|
+
:num_forms => 3,
|
37
|
+
:doc => ['Count ends in 1, excluding 11 (1, 21, 31, ...)', 'Count ends in 2-4, excluding 12-14 (2, 3, 4, 22, ...)', 'Everything else (0, 5, 6, ...)'],
|
38
|
+
:rule => lambda { |n| n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 } },
|
39
|
+
|
40
|
+
:czech_like => {
|
41
|
+
:num_forms => 3,
|
42
|
+
:doc => ['Count is 1', 'Count is 2, 3, 4', 'Everything else (0, 5, 6, ...)'],
|
43
|
+
:rule => lambda { |n| (n == 1) ? 0 : (n >= 2 && n <= 4) ? 1 : 2 } },
|
44
|
+
|
45
|
+
:polish_like => {
|
46
|
+
:num_forms => 3,
|
47
|
+
:doc => ['Count is 1', 'Count ends in 2-4, excluding 12-14 (2, 3, 4, 22, ...)', 'Everything else (0, 5, 6, ...)'],
|
48
|
+
:rule => lambda { |n| (n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2) } },
|
49
|
+
|
50
|
+
:icelandic_like => {
|
51
|
+
:num_forms => 2,
|
52
|
+
:doc => ['Count ends in 1, excluding 11 (1, 21, 31, ...)', 'Everything else (0, 2, 3, ...)'],
|
53
|
+
:rule => lambda { |n| (n % 10 != 1 || n % 100 == 11) ? 1 : 0 } },
|
54
|
+
}
|
55
|
+
|
56
|
+
PLURAL_TYPE_NAME_TO_LANGUAGES = {
|
57
|
+
:chinese_like => [:id, :ja, :ko, :ms, :th, :tr, :zh, ],
|
58
|
+
:german_like => [:da, :de, :en, :es, :fi, :el, :he, :hu, :it, :nl, :no, :pt, :sv, ],
|
59
|
+
:french_like => [:fr, :tl, ],
|
60
|
+
:russian_like => [:hr, :ru, ],
|
61
|
+
:czech_like => [:cs],
|
62
|
+
:polish_like => [:pl],
|
63
|
+
:icelandic_like => [:is],
|
64
|
+
}
|
65
|
+
|
66
|
+
LANGUAGE_TO_PLURAL_TYPE_NAME = PLURAL_TYPE_NAME_TO_LANGUAGES.inject({}) do |carry, (type, languages)|
|
67
|
+
languages.each { |l| carry[l] = type }
|
68
|
+
carry
|
69
|
+
end
|
70
|
+
|
71
|
+
module_function
|
72
|
+
|
73
|
+
# Chooses the right version of 'text' for the value of the 'smart_count' in
|
74
|
+
# 'locale'.
|
75
|
+
#
|
76
|
+
# Examples:
|
77
|
+
# choose("%{smart_count} bewertung; %{smart_count} bewertungen",
|
78
|
+
# :de, 0)
|
79
|
+
# => %{smart_count} bewertungen
|
80
|
+
# choose("%{smart_count} bewertung; %{smart_count} bewertungen",
|
81
|
+
# :de, 1)
|
82
|
+
# => %{smart_count} bewertung
|
83
|
+
# choose("%{smart_count} bewertung; %{smart_count} bewertungen",
|
84
|
+
# :de, 2)
|
85
|
+
# => %{smart_count} bewertungen
|
86
|
+
#
|
87
|
+
# If 'count' is nil or 'text' contains too few alternative versions, this
|
88
|
+
# method degrades gracefully by returning the first version of text.
|
89
|
+
#
|
90
|
+
# Returns if nil if text is blank.
|
91
|
+
def choose(text, locale, count)
|
92
|
+
return text if text.blank?
|
93
|
+
|
94
|
+
texts = text.split(DELIMITER)
|
95
|
+
if count
|
96
|
+
chosen_text = texts[plural_type_index(locale, count)] || texts.first
|
97
|
+
chosen_text.strip
|
98
|
+
else
|
99
|
+
texts.first.strip
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def plural_type_name(locale)
|
104
|
+
LANGUAGE_TO_PLURAL_TYPE_NAME[I18n.language_from_locale(locale)] ||
|
105
|
+
LANGUAGE_TO_PLURAL_TYPE_NAME[I18n.default_language]
|
106
|
+
end
|
107
|
+
|
108
|
+
def plural_type_index(locale, count)
|
109
|
+
PLURAL_TYPES[plural_type_name(locale)][:rule].call(count)
|
110
|
+
end
|
111
|
+
|
112
|
+
def hint(text, locale)
|
113
|
+
if applies?(text)
|
114
|
+
PLURAL_TYPES[plural_type_name(locale)][:doc].join(DELIMITER + ' ')
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def applies?(text)
|
119
|
+
!!text.index(INTERPOLATION_VARIABLE_PLACEHOLDER)
|
120
|
+
end
|
121
|
+
|
122
|
+
def num_forms(locale)
|
123
|
+
PLURAL_TYPES[plural_type_name(locale)][:num_forms]
|
124
|
+
end
|
125
|
+
|
126
|
+
# Takes a list of things (for example tags or variables), counts up how
|
127
|
+
# many of each thing there are, then returns a new list that has each thing
|
128
|
+
# but with a possibly different count.
|
129
|
+
#
|
130
|
+
# The count of each thing in the returned list is determined by:
|
131
|
+
# - If the count is a multiple of the number of plural forms for 'locale',
|
132
|
+
# count is divided by the number of plural forms.
|
133
|
+
# - Otherwise count is set to 0.
|
134
|
+
#
|
135
|
+
# The returned list is reordered so that all instances of the same thing
|
136
|
+
# are consecutive, and are ordered in the same order as the first of each
|
137
|
+
# thing in the original list.
|
138
|
+
#
|
139
|
+
# This method is useful for ensuring that HTML tags or interpolation
|
140
|
+
# variables match in smart-count phrases between languages that might have
|
141
|
+
# different numbers of plural forms.
|
142
|
+
def dedupe_things_like_tags_or_variables(locale, things)
|
143
|
+
return things if things.blank?
|
144
|
+
|
145
|
+
num_forms = num_forms(locale)
|
146
|
+
grouped_things = things.group_by { |t| t }
|
147
|
+
deduped = grouped_things.inject([]) do |carry, (thing, list_of_things)|
|
148
|
+
count_of_thing = list_of_things.size
|
149
|
+
if count_of_thing % num_forms == 0
|
150
|
+
deduped_count_of_thing = count_of_thing / num_forms
|
151
|
+
else
|
152
|
+
deduped_count_of_thing = 0
|
153
|
+
end
|
154
|
+
deduped_count_of_thing.times { carry << thing }
|
155
|
+
carry
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def valid?(text, locale)
|
160
|
+
if applies?(text)
|
161
|
+
size = text.split(DELIMITER).size
|
162
|
+
num_forms = num_forms(locale)
|
163
|
+
if size == num_forms
|
164
|
+
{ :valid => true }
|
165
|
+
else
|
166
|
+
{ :valid => false,
|
167
|
+
:reason => "Must specify #{num_forms} forms separated by \"#{DELIMITER}\" (#{size} #{size == 1? 'was' : 'were'} specified)" }
|
168
|
+
end
|
169
|
+
else
|
170
|
+
{ :valid => true }
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
data/lib/air18n/version.rb
CHANGED
data/lib/air18n/xss_detector.rb
CHANGED
@@ -2,11 +2,19 @@ module Air18n
|
|
2
2
|
module XssDetector
|
3
3
|
module_function
|
4
4
|
|
5
|
-
def safe?(text_a, text_b)
|
5
|
+
def safe?(text_a, text_b, locale_a, locale_b)
|
6
6
|
text_a = text_a.to_s
|
7
7
|
text_b = text_b.to_s
|
8
8
|
tags_a = normalize_tags(extract_tags(text_a))
|
9
9
|
tags_b = normalize_tags(extract_tags(text_b))
|
10
|
+
if SmartCount::applies?(text_a) || SmartCount::applies?(text_b)
|
11
|
+
LoggingHelper.info "tags_a were: #{tags_a.inspect}"
|
12
|
+
LoggingHelper.info "tags_b were: #{tags_b.inspect}"
|
13
|
+
tags_a = SmartCount::dedupe_things_like_tags_or_variables(locale_a, tags_a)
|
14
|
+
tags_b = SmartCount::dedupe_things_like_tags_or_variables(locale_b, tags_b)
|
15
|
+
LoggingHelper.info "tags_a now: #{tags_a.inspect}"
|
16
|
+
LoggingHelper.info "tags_b now: #{tags_b.inspect}"
|
17
|
+
end
|
10
18
|
if has_dubious_escape_characters?(text_a) || has_dubious_escape_characters?(text_b)
|
11
19
|
{ :safe => false, :reason => 'Backslashes are not allowed' }
|
12
20
|
elsif tags_a.group_by{|t| t} != tags_b.group_by{|t| t}
|
@@ -148,6 +148,31 @@ describe Air18n::Backend do
|
|
148
148
|
end
|
149
149
|
end
|
150
150
|
|
151
|
+
context 'Smart counting' do
|
152
|
+
it 'should count smartly' do
|
153
|
+
backend = Air18n::Backend.new
|
154
|
+
backend.lookup(
|
155
|
+
:en, 'smart count reviews', @scope,
|
156
|
+
:default => "%{smart_count} review; %{smart_count} reviews",
|
157
|
+
:smart_count => 1
|
158
|
+
).should == '%{smart_count} review'
|
159
|
+
backend.lookup(
|
160
|
+
:en, 'smart count reviews', @scope,
|
161
|
+
:default => "%{smart_count} review; %{smart_count} reviews",
|
162
|
+
:smart_count => 3
|
163
|
+
).should == '%{smart_count} reviews'
|
164
|
+
|
165
|
+
# If a phrase is untranslated in Chinese, this can turn into "3 review"
|
166
|
+
# on the Chinese site if we are not careful and use the Chinese plural
|
167
|
+
# rules for this English fallback. This test catches this bug.
|
168
|
+
backend.lookup(
|
169
|
+
:zh, 'smart count reviews', @scope,
|
170
|
+
:default => "%{smart_count} review; %{smart_count} reviews",
|
171
|
+
:smart_count => 3
|
172
|
+
).should == '%{smart_count} reviews'
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
151
176
|
context 'Keeping track of which phrases have screenshots' do
|
152
177
|
before do
|
153
178
|
@backend = Air18n::Backend.new
|
@@ -54,6 +54,73 @@ describe Air18n::PhraseTranslation do
|
|
54
54
|
translation.errors.should include :value
|
55
55
|
translation.errors[:value].should include "Var %{time_zone} missing from translation; Var %{time_zon} should not be in translation"
|
56
56
|
end
|
57
|
+
|
58
|
+
context 'smart_count' do
|
59
|
+
before :all do
|
60
|
+
@phrase = FactoryGirl.create(:phrase, :key => 'variables with smart count reviews', :value => "%{smart_count} review in %{location};%{smart_count} reviews in %{location}")
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'Should check variables with smart count in language with one plural form' do
|
64
|
+
translation = FactoryGirl.create(
|
65
|
+
:phrase_translation,
|
66
|
+
:phrase => @phrase,
|
67
|
+
:locale => :ja,
|
68
|
+
:key => 'variables with smart count reviews',
|
69
|
+
:value => '%{location}で%{smart_count}レビュー')
|
70
|
+
validated = translation.save
|
71
|
+
validated.should == true
|
72
|
+
translation.value = '%{smart_count}レビュー'
|
73
|
+
translation.save.should be_false
|
74
|
+
translation.errors.should include :value
|
75
|
+
translation.errors[:value].should include "Var %{location} missing from translation"
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'Should check variables with smart count in language with two plural forms' do
|
79
|
+
translation = FactoryGirl.create(
|
80
|
+
:phrase_translation,
|
81
|
+
:phrase => @phrase,
|
82
|
+
:locale => :fr,
|
83
|
+
:key => 'variables with smart count reviews',
|
84
|
+
:value => 'a %{location} %{smart_count} commentaire; a %{location} %{smart_count} commentaires')
|
85
|
+
validated = translation.save
|
86
|
+
validated.should == true
|
87
|
+
translation.value = 'a %{location} %{smart_count} commentaire; a %{location} %{smart_countz} commentaires'
|
88
|
+
translation.save.should be_false
|
89
|
+
translation.errors.should include :value
|
90
|
+
translation.errors[:value].should include "Var %{smart_count} missing from translation"
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'Should check variables with smart count in language with three plural forms' do
|
94
|
+
translation = FactoryGirl.create(
|
95
|
+
:phrase_translation,
|
96
|
+
:phrase => @phrase,
|
97
|
+
:locale => :pl,
|
98
|
+
:key => 'variables with smart count reviews',
|
99
|
+
:value => 'a %{location} %{smart_count} ends-in-one-except-eleven; a %{location} %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen; a %{location} %{smart_count} everything-else')
|
100
|
+
validated = translation.save
|
101
|
+
validated.should == true
|
102
|
+
translation.value = 'a %{location} %{smart_count} ends-in-one-except-eleven; a %{location} %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen; a %{smart_count} everything-else'
|
103
|
+
|
104
|
+
translation.save.should be_false
|
105
|
+
translation.errors.should include :value
|
106
|
+
translation.errors[:value].should include "Var %{location} missing from translation"
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'Should complain about missing plural forms' do
|
110
|
+
translation = FactoryGirl.create(
|
111
|
+
:phrase_translation,
|
112
|
+
:phrase => @phrase,
|
113
|
+
:locale => :de,
|
114
|
+
:key => 'variables with smart count reviews',
|
115
|
+
:value => 'a %{location} %{smart_count} bewertung; a %{location} %{smart_count} bewertungen')
|
116
|
+
validated = translation.save
|
117
|
+
validated.should == true
|
118
|
+
translation.value = 'a %{location} %{smart_count} bewertung, a %{location} %{smart_count} bewertungen'
|
119
|
+
translation.save.should be_false
|
120
|
+
translation.errors.should include :value
|
121
|
+
translation.errors[:value].should include "Must specify 2 forms separated by \";\" (1 was specified)"
|
122
|
+
end
|
123
|
+
end
|
57
124
|
end
|
58
125
|
|
59
126
|
context 'helpers' do
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'air18n/smart_count'
|
5
|
+
|
6
|
+
describe Air18n::SmartCount do
|
7
|
+
def test_choose(text, locale, answers)
|
8
|
+
answers.each_with_index do |answer, n|
|
9
|
+
Air18n::SmartCount::choose(text, locale, n).should == answer
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "choose" do
|
14
|
+
it "should work in Japanese" do
|
15
|
+
review_text = "%{smart_count} レビュー"
|
16
|
+
answers =
|
17
|
+
["%{smart_count} レビュー"] * 26
|
18
|
+
test_choose(review_text, :de, answers)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should work in German" do
|
22
|
+
review_text = "%{smart_count} bewertung; %{smart_count} bewertungen"
|
23
|
+
answers =
|
24
|
+
["%{smart_count} bewertungen"] * 1 +
|
25
|
+
["%{smart_count} bewertung"] * 1 +
|
26
|
+
["%{smart_count} bewertungen"] * 24
|
27
|
+
test_choose(review_text, :de, answers)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should work in French" do
|
31
|
+
review_text = "%{smart_count} commentaire; %{smart_count} commentaires"
|
32
|
+
answers =
|
33
|
+
["%{smart_count} commentaire"] * 2 +
|
34
|
+
["%{smart_count} commentaires"] * 24
|
35
|
+
test_choose(review_text, :fr, answers)
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should work in Russian" do
|
39
|
+
review_text = "%{smart_count} ends-in-one-except-eleven; %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen; %{smart_count} everything-else"
|
40
|
+
answers =
|
41
|
+
["%{smart_count} everything-else"] * 1 +
|
42
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1 +
|
43
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
44
|
+
["%{smart_count} everything-else"] * 16 +
|
45
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1 +
|
46
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
47
|
+
["%{smart_count} everything-else"] * 1
|
48
|
+
test_choose(review_text, :ru, answers)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should work in Czech" do
|
52
|
+
review_text = "%{smart_count} one; %{smart_count} two-through-four; %{smart_count} everything-else"
|
53
|
+
answers =
|
54
|
+
["%{smart_count} everything-else"] * 1 +
|
55
|
+
["%{smart_count} one"] * 1 +
|
56
|
+
["%{smart_count} two-through-four"] * 3 +
|
57
|
+
["%{smart_count} everything-else"] * 21
|
58
|
+
test_choose(review_text, :cs, answers)
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should work in Polish" do
|
62
|
+
review_text = "%{smart_count} one; %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen; %{smart_count} everything-else"
|
63
|
+
answers =
|
64
|
+
["%{smart_count} everything-else"] * 1 +
|
65
|
+
["%{smart_count} one"] * 1 +
|
66
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
67
|
+
["%{smart_count} everything-else"] * 17 +
|
68
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
69
|
+
["%{smart_count} everything-else"] * 1
|
70
|
+
test_choose(review_text, :pl, answers)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe "degenerate choose" do
|
75
|
+
it "should work if too few translated forms are given" do
|
76
|
+
# One of the three Russian forms, the "everything else" form, is missing.
|
77
|
+
# The fallback should be the first form, "ends in one except eleven".
|
78
|
+
review_text = "%{smart_count} ends-in-one-except-eleven; %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"
|
79
|
+
answers =
|
80
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1 +
|
81
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1 +
|
82
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
83
|
+
["%{smart_count} ends-in-one-except-eleven"] * 16 +
|
84
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1 +
|
85
|
+
["%{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"] * 3 +
|
86
|
+
["%{smart_count} ends-in-one-except-eleven"] * 1
|
87
|
+
test_choose(review_text, :ru, answers)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should work with nil count" do
|
91
|
+
review_text = "%{smart_count} ends-in-one-except-eleven; %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen"
|
92
|
+
Air18n::SmartCount::choose(review_text, :ru, nil).should == "%{smart_count} ends-in-one-except-eleven"
|
93
|
+
end
|
94
|
+
|
95
|
+
it "should work with nil text" do
|
96
|
+
Air18n::SmartCount::choose(nil, :ru, nil).should == nil
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe 'hint' do
|
101
|
+
it 'should work' do
|
102
|
+
Air18n::SmartCount::hint('hi there', :pl).should be_nil
|
103
|
+
Air18n::SmartCount::hint('%{smart_count} reviews', :pl).should ==
|
104
|
+
'Count is 1; Count ends in 2-4, excluding 12-14 (2, 3, 4, 22, ...); Everything else (0, 5, 6, ...)'
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe 'dedupe_things_like_tags_or_variables' do
|
109
|
+
it 'should work' do
|
110
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
111
|
+
:fr, [:a, :b, :a, :b]).should == [:a, :b]
|
112
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
113
|
+
:ja, [:a, :b, :a, :b]).should == [:a, :a, :b, :b]
|
114
|
+
|
115
|
+
# Polish has 3 plural forms.
|
116
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
117
|
+
:pl, [:a, :b, :a, :b]).should == []
|
118
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
119
|
+
:pl, [:a, :b, :a, :b, :a, :b]).should == [:a, :b]
|
120
|
+
|
121
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
122
|
+
:pl, [:a, :b, :a, :a, :b, :a, :a, :b, :a]).should == [:a, :a, :b]
|
123
|
+
|
124
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
125
|
+
:pl, []).should == []
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'should be unlenient if one variable is repeated less often than others' do
|
129
|
+
Air18n::SmartCount::dedupe_things_like_tags_or_variables(
|
130
|
+
:fr, [:a, :b, :a]).should == [:a]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
describe "valid?" do
|
135
|
+
it 'should work' do
|
136
|
+
Air18n::SmartCount::valid?('%{smart_count} レビュー', :ja).should == { :valid => true }
|
137
|
+
Air18n::SmartCount::valid?('%{smart_count} commentaire; %{smart_count} commentaires', :fr).should == { :valid => true }
|
138
|
+
Air18n::SmartCount::valid?('%{smart_count} ends-in-one-except-eleven; %{smart_count} ends-in-two-through-four-except-twelve-through-fourteen; %{smart_count} everything-else', :pl).should == { :valid => true }
|
139
|
+
|
140
|
+
Air18n::SmartCount::valid?('%{smart_count} a; %{smart_count} b; %{smart_count} c', :fr).should == {
|
141
|
+
:valid => false,
|
142
|
+
:reason => "Must specify 2 forms separated by \";\" (3 were specified)"
|
143
|
+
}
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -1,6 +1,8 @@
|
|
1
|
-
|
1
|
+
# encoding: utf-8
|
2
2
|
|
3
|
+
require 'spec_helper'
|
3
4
|
require 'air18n/xss_detector'
|
5
|
+
require 'air18n/logging_helper'
|
4
6
|
|
5
7
|
describe Air18n::XssDetector do
|
6
8
|
describe "has_dubious_escape_characters?" do
|
@@ -12,31 +14,78 @@ describe Air18n::XssDetector do
|
|
12
14
|
|
13
15
|
describe "safe?" do
|
14
16
|
it "should detect safe texts" do
|
15
|
-
Air18n::XssDetector::safe?("safe", "safe").should == { :safe => true }
|
16
|
-
Air18n::XssDetector::safe?("<tag>", "<tag>").should == { :safe => true }
|
17
|
-
Air18n::XssDetector::safe?("safe", { "safe" => "whatevs" }).should == { :safe => true }
|
17
|
+
Air18n::XssDetector::safe?("safe", "safe", :en, :fr).should == { :safe => true }
|
18
|
+
Air18n::XssDetector::safe?("<tag>", "<tag>", :en, :fr).should == { :safe => true }
|
19
|
+
Air18n::XssDetector::safe?("safe", { "safe" => "whatevs" }, :en, :fr).should == { :safe => true }
|
18
20
|
end
|
19
21
|
|
20
22
|
it "should detect dubious escape characters" do
|
21
|
-
Air18n::XssDetector::safe?("Let's escape a \\' there", "safe").should == { :safe => false, :reason => "Backslashes are not allowed" }
|
22
|
-
Air18n::XssDetector::safe?("safe", "Let's escape a \\' there").should == { :safe => false, :reason => "Backslashes are not allowed" }
|
23
|
+
Air18n::XssDetector::safe?("Let's escape a \\' there", "safe", :en, :fr).should == { :safe => false, :reason => "Backslashes are not allowed" }
|
24
|
+
Air18n::XssDetector::safe?("safe", "Let's escape a \\' there", :en, :fr).should == { :safe => false, :reason => "Backslashes are not allowed" }
|
23
25
|
end
|
24
26
|
|
25
27
|
it "should detect tag mismatches" do
|
26
|
-
Air18n::XssDetector::safe?("<tag>", "safe").should == { :safe => false, :reason => "HTML tags don't match: #{['<tag>'].inspect} vs. #{[].inspect}" }
|
27
|
-
Air18n::XssDetector::safe?("safe", "<tag>").should == { :safe => false, :reason => "HTML tags don't match: #{[].inspect} vs. #{['<tag>'].inspect}" }
|
28
|
-
Air18n::XssDetector::safe?("<b>safe</b>", "<b>safe</b><b>").should == { :safe => false, :reason => "HTML tags don't match: #{['<b>', '</b>'].inspect} vs. #{['<b>', '</b>', '<b>'].inspect}" }
|
28
|
+
Air18n::XssDetector::safe?("<tag>", "safe", :en, :fr).should == { :safe => false, :reason => "HTML tags don't match: #{['<tag>'].inspect} vs. #{[].inspect}" }
|
29
|
+
Air18n::XssDetector::safe?("safe", "<tag>", :en, :fr).should == { :safe => false, :reason => "HTML tags don't match: #{[].inspect} vs. #{['<tag>'].inspect}" }
|
30
|
+
Air18n::XssDetector::safe?("<b>safe</b>", "<b>safe</b><b>", :en, :fr).should == { :safe => false, :reason => "HTML tags don't match: #{['<b>', '</b>'].inspect} vs. #{['<b>', '</b>', '<b>'].inspect}" }
|
29
31
|
end
|
30
32
|
|
31
33
|
it "should allow quote mismatches in tags" do
|
32
|
-
Air18n::XssDetector::safe?("<a href='/help/question/280' target='_blank'>Why is this required?</a>", "<a href='/help/question/280' target='_blank'>...</a>").should == { :safe => true }
|
33
|
-
Air18n::XssDetector::safe?("<tag href='hi'>", "<tag href=\"hi\">").should == { :safe => true }
|
34
|
+
Air18n::XssDetector::safe?("<a href='/help/question/280' target='_blank'>Why is this required?</a>", "<a href='/help/question/280' target='_blank'>...</a>", :en, :fr).should == { :safe => true }
|
35
|
+
Air18n::XssDetector::safe?("<tag href='hi'>", "<tag href=\"hi\">", :en, :fr).should == { :safe => true }
|
34
36
|
end
|
35
37
|
|
36
38
|
it "should allow reordering tags" do
|
37
39
|
Air18n::XssDetector::safe?(
|
38
40
|
"<span class='count'>%{count}</span> %{pluralized_review} left in <a href='#listings/search/%{encoded_search}' class='search_link'>%{city}, %{state} %{country}</a>",
|
39
|
-
"in <a href='#listings/search/%{encoded_search}' class='search_link'>%{city}, %{state} %{country}</a> <span class='count'>%{count}</span> %{pluralized_review} left"
|
41
|
+
"in <a href='#listings/search/%{encoded_search}' class='search_link'>%{city}, %{state} %{country}</a> <span class='count'>%{count}</span> %{pluralized_review} left",
|
42
|
+
:en, :ko
|
43
|
+
).should == { :safe => true }
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should work with smart count with one plural form" do
|
47
|
+
Air18n::XssDetector::safe?(
|
48
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
49
|
+
"<span class='text'>レビュー</span> <span class='count'>%{smart_count}</span>",
|
50
|
+
:en, :ja
|
51
|
+
).should == { :safe => true }
|
52
|
+
|
53
|
+
# "spank" instead of "span" in the last tag.
|
54
|
+
Air18n::XssDetector::safe?(
|
55
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
56
|
+
"<span class='text'>レビュー</span> <span class='count'>%{smart_count}</spank>",
|
57
|
+
:en, :ja
|
58
|
+
).should == { :safe => false, :reason => "HTML tags don't match: [\"<span class='count'>\", \"</span>\", \"</span>\", \"<span class='text'>\"] vs. [\"<span class='text'>\", \"</span>\", \"<span class='count'>\", \"</spank>\"]" }
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should work with smart count with two plural forms" do
|
62
|
+
Air18n::XssDetector::safe?(
|
63
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
64
|
+
"<span class='text'>commentaire</span> <span class='count'>%{smart_count}</span>;<span class='text'>commentaires</span> <span class='count'>%{smart_count}</span>",
|
65
|
+
:en, :fr
|
66
|
+
).should == { :safe => true }
|
67
|
+
|
68
|
+
# Second form not styled.
|
69
|
+
Air18n::LoggingHelper.info "GRRRRR"
|
70
|
+
Air18n::XssDetector::safe?(
|
71
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
72
|
+
"<span class='text'>commentaire</span> <span class='count'>%{smart_count}</span>;commentaires %{smart_count}",
|
73
|
+
:en, :fr
|
74
|
+
).should == { :safe => false, :reason => "HTML tags don't match: [\"<span class='count'>\", \"</span>\", \"</span>\", \"<span class='text'>\"] vs. [\"</span>\"]" }
|
75
|
+
|
76
|
+
# Only one form being given leads to the same error.
|
77
|
+
Air18n::XssDetector::safe?(
|
78
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
79
|
+
"<span class='text'>commentaire</span> <span class='count'>%{smart_count}</span>",
|
80
|
+
:en, :fr
|
81
|
+
).should == { :safe => false, :reason => "HTML tags don't match: [\"<span class='count'>\", \"</span>\", \"</span>\", \"<span class='text'>\"] vs. [\"</span>\"]" }
|
82
|
+
end
|
83
|
+
|
84
|
+
it "should work with smart count with three plural forms" do
|
85
|
+
Air18n::XssDetector::safe?(
|
86
|
+
"<span class='count'>%{smart_count}</span> <span class='text'>review</span>;<span class='count'>%{smart_count}</span> <span class='text'>reviews</span>",
|
87
|
+
"<span class='text'>ends-in-one-except-eleven</span> <span class='count'>%{smart_count}</span>;<span class='text'>ends-in-two-through-four-except-twelve-through-fourteen</span> <span class='count'>%{smart_count}</span>;<span class='text'>everything-else</span> <span class='count'>%{smart_count}</span>",
|
88
|
+
:en, :ru
|
40
89
|
).should == { :safe => true }
|
41
90
|
end
|
42
91
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: air18n
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.16
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -13,7 +13,7 @@ authors:
|
|
13
13
|
autorequire:
|
14
14
|
bindir: bin
|
15
15
|
cert_chain: []
|
16
|
-
date: 2012-
|
16
|
+
date: 2012-10-02 00:00:00.000000000 Z
|
17
17
|
dependencies:
|
18
18
|
- !ruby/object:Gem::Dependency
|
19
19
|
name: i18n
|
@@ -161,6 +161,7 @@ files:
|
|
161
161
|
- lib/air18n/pseudo_locales.rb
|
162
162
|
- lib/air18n/reflection.rb
|
163
163
|
- lib/air18n/screenshot.rb
|
164
|
+
- lib/air18n/smart_count.rb
|
164
165
|
- lib/air18n/testing_support/factories.rb
|
165
166
|
- lib/air18n/testing_support/factories/phrase.rb
|
166
167
|
- lib/air18n/testing_support/factories/phrase_screenshot.rb
|
@@ -177,6 +178,7 @@ files:
|
|
177
178
|
- spec/lib/air18n/phrase_translation_spec.rb
|
178
179
|
- spec/lib/air18n/prim_and_proper_spec.rb
|
179
180
|
- spec/lib/air18n/pseudo_locales_spec.rb
|
181
|
+
- spec/lib/air18n/smart_count_spec.rb
|
180
182
|
- spec/lib/air18n/xss_detector_spec.rb
|
181
183
|
- spec/spec_helper.rb
|
182
184
|
homepage: http://www.github.com/airbnb/air18n
|
@@ -212,5 +214,6 @@ test_files:
|
|
212
214
|
- spec/lib/air18n/phrase_translation_spec.rb
|
213
215
|
- spec/lib/air18n/prim_and_proper_spec.rb
|
214
216
|
- spec/lib/air18n/pseudo_locales_spec.rb
|
217
|
+
- spec/lib/air18n/smart_count_spec.rb
|
215
218
|
- spec/lib/air18n/xss_detector_spec.rb
|
216
219
|
- spec/spec_helper.rb
|