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
@@ -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,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
|