air18n 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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