air18n 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. data/.gitignore +18 -0
  2. data/.rvmrc +48 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +22 -0
  5. data/README.md +29 -0
  6. data/Rakefile +9 -0
  7. data/air18n.gemspec +25 -0
  8. data/lib/air18n.rb +83 -0
  9. data/lib/air18n/backend.rb +194 -0
  10. data/lib/air18n/class_methods.rb +292 -0
  11. data/lib/air18n/less_silly_chain.rb +54 -0
  12. data/lib/air18n/logging_helper.rb +13 -0
  13. data/lib/air18n/mock_priority.rb +25 -0
  14. data/lib/air18n/phrase.rb +92 -0
  15. data/lib/air18n/phrase_screenshot.rb +76 -0
  16. data/lib/air18n/phrase_translation.rb +348 -0
  17. data/lib/air18n/prim_and_proper.rb +94 -0
  18. data/lib/air18n/priority.rb +13 -0
  19. data/lib/air18n/pseudo_locales.rb +53 -0
  20. data/lib/air18n/reflection.rb +10 -0
  21. data/lib/air18n/screenshot.rb +45 -0
  22. data/lib/air18n/testing_support/factories.rb +3 -0
  23. data/lib/air18n/testing_support/factories/phrase.rb +8 -0
  24. data/lib/air18n/testing_support/factories/phrase_screenshot.rb +8 -0
  25. data/lib/air18n/testing_support/factories/phrase_translation.rb +17 -0
  26. data/lib/air18n/version.rb +3 -0
  27. data/lib/air18n/xss_detector.rb +47 -0
  28. data/lib/generators/air18n/migration/migration_generator.rb +39 -0
  29. data/lib/generators/air18n/migration/templates/active_record/migration.rb +63 -0
  30. data/spec/database.yml +3 -0
  31. data/spec/factories.rb +2 -0
  32. data/spec/lib/air18n/air18n_spec.rb +144 -0
  33. data/spec/lib/air18n/backend_spec.rb +173 -0
  34. data/spec/lib/air18n/phrase_translation_spec.rb +80 -0
  35. data/spec/lib/air18n/prim_and_proper_spec.rb +21 -0
  36. data/spec/lib/air18n/pseudo_locales_spec.rb +17 -0
  37. data/spec/lib/air18n/xss_detector_spec.rb +47 -0
  38. data/spec/spec_helper.rb +62 -0
  39. metadata +212 -0
@@ -0,0 +1,54 @@
1
+ require 'i18n'
2
+
3
+ module Air18n
4
+ # I18n::Backend::Chain has the rather silly behavior that options[:default] is
5
+ # removed from the options hash for all Backends except for the last. It's
6
+ # not clear why, know why because this behavior is not documented.
7
+ # LessSillyChain works just like Backend::Chain but without this behavior.
8
+ class LessSillyChain < I18n::Backend::Chain
9
+ def translate(locale, key, options = {})
10
+ namespace = nil
11
+
12
+ rescued_exception = nil
13
+ caught_exception = nil
14
+ backends.each do |backend|
15
+ rescued_exception = nil
16
+ caught_exception = nil
17
+
18
+ # We jump through two hoops to make this work with both i18n-0.5.0 and
19
+ # i18n-0.6.0.
20
+ # - i18n-0.5.0: Backends raise TranslationMissingData exception when
21
+ # a translation is missing
22
+ # - i18n-0.6.0: Backends throw TranslationMissing "exceptions" to the
23
+ # :exception tag when a translation is missing.
24
+ #
25
+ # To make both happy, we both rescue and catch.
26
+ caught_exception = catch(:exception) do
27
+ begin
28
+ translation = backend.translate(locale, key, options.merge(:raise => true))
29
+ if namespace_lookup?(translation, options)
30
+ namespace ||= {}
31
+ namespace.merge!(translation)
32
+ elsif !translation.nil?
33
+ return translation
34
+ end
35
+ rescue Exception => e
36
+ rescued_exception = e
37
+ end
38
+
39
+ # Nothing caught.
40
+ nil
41
+ end
42
+ end
43
+
44
+ return namespace if namespace
45
+
46
+ if caught_exception
47
+ throw(:exception, caught_exception)
48
+ end
49
+ if rescued_exception
50
+ raise(rescued_exception)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ module Air18n
2
+ module LoggingHelper
3
+ module_function
4
+
5
+ def info text
6
+ ActiveRecord::Base.logger.info text
7
+ end
8
+
9
+ def error text
10
+ ActiveRecord::Base.logger.error text
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module Air18n
2
+ class MockPriority
3
+ include Priority
4
+ def initialize(key_usage = {})
5
+ @key_usage = key_usage
6
+ end
7
+
8
+ def key_used(key)
9
+ @key_usage[key] ||= 0
10
+ @key_usage[key] += 1
11
+ end
12
+
13
+ def key_usage
14
+ @key_usage
15
+ end
16
+
17
+ def self.mock_priority(key_usage)
18
+ @old_priority = I18n.priority
19
+ mock_priority = Air18n::MockPriority.new(key_usage)
20
+ I18n.priority = mock_priority
21
+ yield mock_priority
22
+ I18n.priority = @old_priority
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,92 @@
1
+ module Air18n
2
+ class Phrase < ActiveRecord::Base
3
+ has_many :phrase_translations, :dependent=>:delete_all
4
+ has_many :phrase_screenshots, :foreign_key => 'phrase_key', :primary_key => 'key', :dependent=>:delete_all
5
+
6
+ after_update :mark_translations_stale
7
+
8
+ validates_uniqueness_of :key
9
+
10
+ def mark_translations_stale
11
+ translations = PhraseTranslation.find_all_by_phrase_id(id)
12
+ translations.each do |translation|
13
+ translation.is_stale = true
14
+
15
+ # Skip validations. This is because for historical reasons some
16
+ # translations are invalid. We want to mark these stale regardless.
17
+ translation.save!(:validate => false)
18
+ end
19
+ end
20
+
21
+ def self.by_key lookup
22
+ if !@by_key then
23
+ @by_key = {}
24
+ Phrase.all.group_by{|e| e.key}.each_pair do |k,v|
25
+ if v.is_a? Array
26
+ @by_key[k] = v.first
27
+ else
28
+ end
29
+ end
30
+ end
31
+
32
+ val = @by_key[lookup]
33
+ val
34
+ end
35
+
36
+ # phrases that have no corresponding phrase translations by human
37
+ scope :needing_first_translation, lambda{|locale| where("phrases.id NOT IN (select phrase_id from phrase_translations WHERE locale='#{locale}' AND is_latest = ?)", true) }
38
+
39
+ scope :needing_updated_translation, lambda{|locale|
40
+ joins(:phrase_translations).where("phrase_translations.locale = '#{locale}' AND phrase_translations.is_latest = ? AND phrase_translations.is_stale = ?", true, true)
41
+ }
42
+
43
+ scope :not_needing_updated_translation, lambda{|locale|
44
+ joins(:phrase_translations).where("phrase_translations.locale = '#{locale}' AND phrase_translations.is_latest = ? AND phrase_translations.is_stale = ?", true, false)
45
+ }
46
+
47
+ scope :needing_first_or_updated_translation, lambda{|locale|
48
+ pull_in_phrase_translations.where(
49
+ "phrases.id NOT IN (select phrase_id from phrase_translations WHERE locale='#{locale}' AND is_latest = ?) OR (phrase_translations.is_latest = ? AND phrase_translations.is_stale = ? AND phrase_translations.locale = '#{locale}')", true, true, true
50
+ )
51
+ }
52
+
53
+ scope :pull_in_phrase_translations, lambda{|locale|
54
+ joins(
55
+ "LEFT OUTER JOIN phrase_translations ON phrase_translations.phrase_id = phrases.id AND phrase_translations.locale = '#{locale}' AND phrase_translations.is_latest = #{connection.quoted_true}"
56
+ )
57
+ }
58
+
59
+ scope :pull_in_all_phrase_translations, lambda { joins(
60
+ "LEFT OUTER JOIN phrase_translations ON phrase_translations.phrase_id = phrases.id AND phrase_translations.is_latest = #{connection.quoted_true}"
61
+ ) }
62
+
63
+ scope :pull_in_all_up_to_date_phrase_translations, lambda { joins(
64
+ "LEFT OUTER JOIN phrase_translations ON phrase_translations.phrase_id = phrases.id AND phrase_translations.is_latest = #{connection.quoted_true} AND phrase_translations.is_stale = #{connection.quoted_false}"
65
+ ) }
66
+
67
+ # The still_used scope is wrapped in a lambda expression to
68
+ # I18n.still_used_phrase_ids is reevaluated each time.
69
+ # This makes dynamic updating possible and testing easier.
70
+ scope :still_used, lambda { where('phrases.id IN (?)', I18n.still_used_phrase_ids) }
71
+ scope :no_longer_used, lambda { where('phrases.id NOT IN (?)', I18n.still_used_phrase_ids) }
72
+
73
+ scope :rich_text, lambda { where("phrases.value LIKE '%<p>%'") }
74
+
75
+ # Fetches text of the most recent translation, if available.
76
+ def latest_translation locale
77
+ PhraseTranslation.where(:phrase_id => id, :locale => locale, :is_latest => true).first
78
+ end
79
+
80
+ def variables
81
+ PhraseTranslation.detect_variables(value)
82
+ end
83
+
84
+ def self.is_rich?(text)
85
+ text.include?('<p>')
86
+ end
87
+
88
+ def is_rich_text?
89
+ !!(self.value && Phrase.is_rich?(self.value))
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,76 @@
1
+ module Air18n
2
+ class PhraseScreenshot < ActiveRecord::Base
3
+ validates_presence_of :phrase_key
4
+ validates_uniqueness_of :phrase_key, :scope => [:controller, :action]
5
+ belongs_to :phrase, :foreign_key => 'phrase_key', :primary_key => 'key'
6
+ scope :positioned, :conditions => "width IS NOT NULL AND height IS NOT NULL and x IS NOT NULL and y IS NOT NULL and width != 0 and height != 0 and x != 0 and y != 0"
7
+ scope :unpositioned, :conditions => "width IS NULL OR height IS NULL OR x IS NULL OR y IS NULL OR width = 0 OR height = 0 OR x = 0 OR y = 0"
8
+ belongs_to :screenshot, :foreign_key => 'screenshot_url', :primary_key => 'screenshot_url'
9
+
10
+ after_create :create_row_in_screenshots
11
+
12
+ # Returns 2D array of phrase_keys => array of routes_contexts for which we have screenshots
13
+ def self.all_phrase_urls
14
+ Hash.new { |h, k| h[k] = [] }.tap do |all_phrase_urls|
15
+ # Uses raw SQL for speed.
16
+ connection.select_all("SELECT `phrase_key`, `action`, `controller` FROM phrase_screenshots").each do |record|
17
+ all_phrase_urls[record['phrase_key']] << make_routes_context(record['controller'], record['action'])
18
+ end
19
+ end
20
+ end
21
+
22
+ def has_position?
23
+ return width.present? && height.present? && x.present? && y.present? &&
24
+ width > 0 && height > 0 && x > 0 && y > 0
25
+ end
26
+
27
+ def routes_context
28
+ self.class.make_routes_context(self.controller, self.action)
29
+ end
30
+
31
+ def self.make_routes_context(controller, action)
32
+ "#{controller}-#{action}"
33
+ end
34
+
35
+ def create_row_in_screenshots
36
+ if !Screenshot.find_by_screenshot_url(self.screenshot_url)
37
+ screenshot = Screenshot.create(:screenshot_url => self.screenshot_url, :status => 0)
38
+ end
39
+ end
40
+
41
+ def self.get_screenshots_ordered_by_relevance_for_many_phrases(phrases, controller_name, action_name)
42
+ screenshots = PhraseScreenshot.includes(:screenshot).where(:phrase_key => phrases.collect(&:key))
43
+ screenshots_for_each_phrase = Hash.new { |h, k| h[k] = [] }
44
+ screenshots.all.each { |screenshot| screenshots_for_each_phrase[screenshot.phrase_key] << screenshot }
45
+ screenshots_for_each_phrase.inject(Hash.new([])) do |carry, (key, screenshots_for_phrase)|
46
+ carry[key] = self.get_screenshots_ordered_by_relevance(screenshots_for_phrase, controller_name, action_name)
47
+ carry
48
+ end
49
+ end
50
+
51
+ # Returns the screenshots associated with phrase, with the screenshots for the
52
+ # specified controller and action first in the list.
53
+ # If controller_name or action_name are nil, result is unordered.
54
+ def self.get_screenshots_ordered_by_relevance(screenshots_for_a_phrase, controller_name, action_name)
55
+ if controller_name || action_name
56
+ screenshots_for_a_phrase.sort_by do |x|
57
+ priority = 0
58
+ priority -= 1 if x.action == action_name
59
+ priority -= 2 if x.controller == controller_name
60
+ priority
61
+ end
62
+ else
63
+ screenshots_for_a_phrase
64
+ end
65
+ end
66
+
67
+ def self.serialize_page_id(controller, action)
68
+ "#{controller}##{action}"
69
+ end
70
+
71
+ def self.deserialize_page_id(page_id)
72
+ controller, action = page_id.split("#")
73
+ Struct.new(:controller, :action).new(controller, action)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,348 @@
1
+ require 'air18n/logging_helper'
2
+
3
+ module Air18n
4
+ class PhraseTranslation < ActiveRecord::Base
5
+ belongs_to :phrase
6
+ belongs_to :user
7
+
8
+ validates_presence_of :key, :locale
9
+
10
+ scope :locale, lambda {|loc| { :conditions => ['locale = ?', loc.to_s] } }
11
+ scope :latest, lambda { where(:is_latest => true) }
12
+ scope :up_to_date, lambda { where(:is_stale => false) }
13
+
14
+ before_create :set_latest
15
+
16
+ validate :check_matching_variables
17
+
18
+ # Sets is_latest of this translation, and removes the is_latest flag from all
19
+ # previous translations.
20
+ def set_latest
21
+ self.is_latest = true
22
+ other_translations = PhraseTranslation.find_all_by_phrase_id_and_locale(phrase_id, locale)
23
+ other_translations.each do |other|
24
+ if other.id != id && other.is_latest
25
+ other.is_latest = false
26
+
27
+ # Skip validations. This is because in the case where a translator is
28
+ # making a new (valid) translation where the previous translation was
29
+ # invalid, we want to be able to still mark the old translation as
30
+ # invalid.
31
+ other.save!(:validate => false)
32
+ end
33
+ end
34
+ end
35
+
36
+ COST_PER_WORD_TRANSLATION = 0.07
37
+ COST_PER_WORD_VERIFICATION = 0.05
38
+
39
+ # Provides a complete set of latest translations for specified locales, in nested hash format.
40
+ # filter_opts are passed to keep_key? for optional filtering, like throwing
41
+ # away user-generated content.
42
+ def self.translations_for_locales(locales, filter_opts={})
43
+
44
+ # set up the hashes we want
45
+ all_locales = {}
46
+ locales.each do |loc|
47
+ data = translations_for_locale(loc, filter_opts)
48
+ all_locales[loc] = data
49
+ end
50
+ all_locales
51
+ end
52
+
53
+ def self.translator_activity_data user_id=0, opts={}
54
+ user_criterion = user_id > 0 ? "AND pt.user_id=#{user_id}" : "AND NOT pt.user_id=0"
55
+ since_criterion = "AND pt.created_at >= '#{opts[:since].to_formatted_s(:db)}}'" if opts[:since]
56
+ sql = "SELECT year(pt.created_at) year, month(pt.created_at) month, pt.user_id user_id, pt.locale locale, day(pt.created_at) day, p.id phrase_id, p.value phrase_value, pt.is_verification FROM phrase_translations pt, phrases p WHERE pt.phrase_id=p.id #{user_criterion} #{since_criterion} GROUP BY year(pt.created_at), month(pt.created_at), pt.user_id, pt.locale, pt.phrase_id"
57
+ res = self.connection.select_rows(sql)
58
+ phrases_per_user_locale_month_year =
59
+ Hash.new {|h, year| h[year] =
60
+ Hash.new {|h, month| h[month] =
61
+ Hash.new{|h, user_id| h[user_id] =
62
+ Hash.new{|h, locale| h[locale] =
63
+ Hash.new {|h, day| h[day] =
64
+ Hash.new {|h, is_verification| h[is_verification] =
65
+ Hash.new
66
+ } } } } } }
67
+
68
+ res.each do |row|
69
+ year, month, user_id, locale, day, phrase_id, phrase_value, is_verification = row
70
+ is_verification = (is_verification == 1)
71
+ phrases_per_user_locale_month_year[year][month][user_id][locale][day][is_verification].merge!({phrase_id => phrase_value})
72
+ end
73
+
74
+ activities = []
75
+ phrases_per_user_locale_month_year.each do |year, months|
76
+ months.each do |month, user_ids|
77
+ user_ids.each do |user_id, locales|
78
+ locales.each do |locale, days|
79
+ if opts[:daily]
80
+ days.each do |day, phrase_ids_values_by_type|
81
+ activities << {:year => year, :month => month, :day => day, :locale => locale, :user_id => user_id, :activity => construct_activity(phrase_ids_values_by_type)}
82
+ end
83
+ else
84
+ monthly_phrase_ids_values_by_type = { false => {}, true => {} }
85
+ days.each do |_, phrase_ids_values_by_type|
86
+ [false, true].each do |is_verification|
87
+ monthly_phrase_ids_values_by_type[is_verification].merge!(phrase_ids_values_by_type[is_verification])
88
+ end
89
+ end
90
+ activity = construct_activity(monthly_phrase_ids_values_by_type)
91
+ translation_ids = monthly_phrase_ids_values_by_type[false].keys
92
+ if translation_ids.empty?
93
+ activity[:num_phrases_prev_translated] = 0
94
+ else
95
+ activity[:num_phrases_prev_translated] = self.count_by_sql("select count(distinct(phrase_id)) from phrase_translations where user_id = #{user_id} and phrase_id in (#{translation_ids.join(',')}) and locale = '#{locale}' and created_at < '#{year}-#{month}-01'")
96
+ end
97
+ activities << {:year => year, :month => month, :locale => locale, :user_id => user_id, :activity => activity}
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ activities.sort do |a,b|
104
+ if a[:year] == b[:year]
105
+ if a[:month] == b[:month]
106
+ if a[:user_id] == b[:user_id]
107
+ if a[:locale] == b[:locale]
108
+ if !a[:day] || !b[:day] || a[:day] == b[:day]
109
+ 0
110
+ else
111
+ a[:day] <=> b[:day]
112
+ end
113
+ else
114
+ a[:locale] <=> b[:locale]
115
+ end
116
+ else
117
+ a[:user_id] <=> b[:user_id]
118
+ end
119
+ else
120
+ a[:month] <=> b[:month]
121
+ end
122
+ else
123
+ a[:year] <=> b[:year]
124
+ end
125
+ end
126
+ end
127
+
128
+ # Helper method for translator_activity_data which counts words and payment
129
+ # of a set of translations and verifications, in (is verification bool) => (phrase id =>
130
+ # English phrase value map).
131
+ def self.construct_activity(phrase_ids_values_by_type)
132
+ {}.tap do |activity|
133
+ activity[:num_translations], activity[:word_count_translations] = translation_word_count(phrase_ids_values_by_type[false])
134
+ activity[:num_verifications], activity[:word_count_verifications] = translation_word_count(phrase_ids_values_by_type[true])
135
+ activity[:payment] = activity[:word_count_translations] * COST_PER_WORD_TRANSLATION + activity[:word_count_verifications] * COST_PER_WORD_VERIFICATION
136
+ end
137
+ end
138
+
139
+ # Helper method for construct_activity which counts words in a set of
140
+ # translations or verifications, in phrase id => English phrase value map.
141
+ def self.translation_word_count(phrase_ids_to_values)
142
+ [phrase_ids_to_values.count, phrase_ids_to_values.values.join(' ').scan(/\w+/).size]
143
+ end
144
+
145
+ def self.activity_for_user_id uid, opts
146
+ translator_activity_data uid, opts
147
+ end
148
+
149
+ # Returns an array of integers representing user IDs who are not admins but
150
+ # who we also do not want to pay by the word for translations.
151
+ def self.fetch_unpaid_nonadmin_translator_list
152
+ rich_memory = RichMemory.find_by_memory_type(RichMemory::TYPE_TRANSLATOR_LIST)
153
+ if rich_memory
154
+ rich_memory.payload
155
+ else
156
+ []
157
+ end
158
+ end
159
+
160
+ # Adds or removes a user from the unpaid-non-admin translator list.
161
+ def self.set_nonadmin_translator_payment_status id, should_be_paid
162
+ set = fetch_unpaid_nonadmin_translator_list.to_set
163
+ if should_be_paid
164
+ set -= [id]
165
+ else
166
+ set += [id]
167
+ end
168
+ write_unpaid_nonadmin_translator_list(set.to_a)
169
+ end
170
+
171
+ # translator_list: array of integers representing user IDs.
172
+ # Throws an exception if saving fails.
173
+ def self.write_unpaid_nonadmin_translator_list translator_list
174
+ rich_memory = RichMemory.find_by_memory_type(RichMemory::TYPE_TRANSLATOR_LIST) || RichMemory.new(:memory_type => RichMemory::TYPE_TRANSLATOR_LIST)
175
+ rich_memory.payload = translator_list
176
+ rich_memory.save!
177
+ end
178
+
179
+ def self.translator_payment_data
180
+ today_last_month = 1.month.ago
181
+ ret = []
182
+ PhraseTranslation.translator_activity_data(0, :since => 2.months.ago).each do |stats|
183
+ if stats[:year] == today_last_month.year && stats[:month] == today_last_month.month && stats[:user_id] > 0
184
+ user = User.find_by_id(stats[:user_id])
185
+ stats[:smart_name] = user.try(:smart_name)
186
+
187
+ stats[:currency] = 'EUR'
188
+ stats[:is_admin] = user.try(:role_admin?)
189
+ stats[:is_paid] = !stats[:is_admin] && !fetch_unpaid_nonadmin_translator_list.include?(stats[:user_id])
190
+ stats[:usd_amount] = Currency.convert(stats[:activity][:payment], stats[:currency], "USD").round
191
+ if stats[:is_paid] && stats[:usd_amount] > 0
192
+ stats[:description] = "Translator payout "
193
+ stats[:description] << "for #{Date::MONTHNAMES[today_last_month.month]} #{today_last_month.year}. "
194
+ stats[:description] << "(#{stats[:activity][:word_count_translations]} translated words and #{stats[:activity][:word_count_verifications]} verified words in #{stats[:activity][:num_translations]} translated and #{stats[:activity][:num_verifications]} verified #{Airbnb::I18n.decode_locale(stats[:locale])} phrases.)"
195
+
196
+ stats[:line_item] = LineItem.new(
197
+ :user_id => stats[:user_id],
198
+ :ready_for_release_at => Date.today,
199
+ :sub_type => LineItem::SUB_TYPE_TRANSLATION_PAYOUT,
200
+ :native_currency => stats[:currency],
201
+ :amount => stats[:usd_amount],
202
+ :item => nil,
203
+ :description => (stats[:description] + " Thank you!!")
204
+ )
205
+ end
206
+ ret << stats
207
+ end
208
+ end
209
+ ret
210
+ end
211
+
212
+ # Returns all translations for a locale. filter_opts are passed on to
213
+ # self.keep_key? for filtering.
214
+ def self.translations_for_locale(loc, filter_opts={})
215
+ data = {}
216
+ case loc
217
+ when :en
218
+ # Uses a raw unbatched SQL query for speed.
219
+ Phrase.connection.select_all("SELECT `key`, `value` FROM phrases").each do |record|
220
+ data[record['key']] = record['value'] if keep_key?(record['key'], filter_opts)
221
+ end
222
+ else
223
+ # Uses a raw unbatched SQL query for speed.
224
+ PhraseTranslation.connection.select_all(PhraseTranslation.select("`key`, `value`").latest.where(:locale => loc).to_sql).each do |record|
225
+ data[record['key']] = record['value'] if keep_key?(record['key'], filter_opts)
226
+ end
227
+ end
228
+ data
229
+ end
230
+
231
+ def self.keep_key?(key, filter_opts={})
232
+ if filter_opts[:exclude_ugc] && I18n.phrase_key_is_ugc?(key)
233
+ return false
234
+ end
235
+ if filter_opts[:exclude_unused] && !I18n.still_used_keys_hash.include?(key)
236
+ return false
237
+ end
238
+ if filter_opts[:exclude_all]
239
+ return false
240
+ end
241
+
242
+ true
243
+ end
244
+
245
+ def check_matching_variables
246
+ unless variables_match?
247
+ our_variables = self.variables
248
+ their_variables = self.phrase.variables
249
+ our_extra = our_variables - their_variables
250
+ their_extra = their_variables - our_variables
251
+ problems = []
252
+ def quote(vars)
253
+ vars.map { |var| "%{#{var}}" }.join(", ")
254
+ end
255
+ unless their_extra.empty?
256
+ problems << "Var #{quote their_extra} missing from translation"
257
+ end
258
+ unless our_extra.empty?
259
+ problems << "Var #{quote our_extra} should not be in translation"
260
+ end
261
+ unless problems.empty?
262
+ self.errors.add(:value, problems.join('; '))
263
+ end
264
+ end
265
+ end
266
+
267
+ def variables_match?
268
+ self.variables == self.phrase.variables
269
+ end
270
+
271
+ def variables
272
+ PhraseTranslation.detect_variables(value)
273
+ end
274
+
275
+ def self.detect_variables(search_in)
276
+ case search_in
277
+ when String then Set.new(search_in.scan(/\{\{(\w+)\}\}/).flatten + search_in.scan(/\%\{(\w+)\}/).flatten)
278
+ when Array then search_in.inject(Set[]) { |carry, item| carry + detect_variables(item) }
279
+ when Hash then search_in.values.inject(Set[]) { |carry, item| carry + detect_variables(item) }
280
+ else Set[]
281
+ end
282
+ end
283
+
284
+ # For every translated key in a locale, returns list of ids
285
+ # of PhraseTranslations that are the most recent translation of a key.
286
+ def self.list_of_latest_translations(locale)
287
+ latest_translations = PhraseTranslation.select("max(id) as max_id").where("locale='#{locale}'").group(:phrase_id).collect{|e| e.max_id}
288
+ latest_translations.empty? ? [0] : latest_translations
289
+ end
290
+
291
+ def verification?
292
+ is_verification
293
+ end
294
+
295
+ def latest?
296
+ is_latest
297
+ end
298
+
299
+ def stale?
300
+ is_stale
301
+ end
302
+
303
+ # For every PhraseTranslation in Airbnb::I18n::TRANSLATED_LOCALES, resets
304
+ # is_latest and is_stale columns based on phrases.updated_at and
305
+ # phrase_translations.created_at dates.
306
+ #
307
+ # This is used by the migration that adds these columns.
308
+ def self.reset_latest_and_stale_flags_from_timestamps
309
+ phrase_id_to_updated_at = {}
310
+ Phrase.find_each do |phrase|
311
+ phrase_id_to_updated_at[phrase.id] = phrase.updated_at
312
+ end
313
+
314
+ Airbnb::I18n::TRANSLATED_LOCALES.each do |locale|
315
+ # Output progress to console in case of running this on console.
316
+ puts "Resetting flags for #{locale}..."
317
+ latest_translations = PhraseTranslation.select("max(id) as max_id").where("locale='#{locale}'").group(:phrase_id).collect{|e| e.max_id}.to_set
318
+
319
+ PhraseTranslation.where(:locale => locale).find_each do |translation|
320
+ phrase_updated_at = phrase_id_to_updated_at[translation.phrase_id]
321
+ if !phrase_updated_at
322
+ # This happens for a few phrases, very weirdly. We could destroy them
323
+ # at some point.
324
+ LoggingHelper.error "What the?! Phrase translation #{translation.inspect} has no corresponding phrase."
325
+ next
326
+ end
327
+ if phrase_updated_at > translation.created_at
328
+ translation.is_stale = true
329
+ end
330
+ if latest_translations.include?(translation.id)
331
+ translation.is_latest = true
332
+ end
333
+ translation.save!(:validate => false)
334
+ end
335
+ end
336
+ end
337
+
338
+ def verification_allowed?(latest_translation)
339
+ if latest_translation.is_verification? && !latest_translation.stale?
340
+ return [false, "Translation already verified; nothing saved."]
341
+ end
342
+ if user_id == latest_translation.user_id && !latest_translation.stale?
343
+ return [false, "You last translated this phrase, so somebody else must verify it."]
344
+ end
345
+ return [true]
346
+ end
347
+ end
348
+ end