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.
- 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
|