traduco 0.9.0
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/Gemfile +2 -0
- data/LICENSE +33 -0
- data/README.md +182 -0
- data/bin/traduco +8 -0
- data/library/traduco.rb +10 -0
- data/library/traduco/cli.rb +186 -0
- data/library/traduco/encoding.rb +20 -0
- data/library/traduco/formatters.rb +12 -0
- data/library/traduco/formatters/abstract.rb +164 -0
- data/library/traduco/formatters/android.rb +152 -0
- data/library/traduco/formatters/apple.rb +125 -0
- data/library/traduco/formatters/flash.rb +110 -0
- data/library/traduco/formatters/gettext.rb +98 -0
- data/library/traduco/formatters/jquery.rb +96 -0
- data/library/traduco/l10nfile.rb +209 -0
- data/library/traduco/runner.rb +301 -0
- data/test/fixtures/en-1.json +12 -0
- data/test/fixtures/en-1.po +16 -0
- data/test/fixtures/en-1.strings +10 -0
- data/test/fixtures/en-2.po +23 -0
- data/test/fixtures/fr-1.xml +10 -0
- data/test/fixtures/strings-1.txt +17 -0
- data/test/fixtures/strings-2.txt +5 -0
- data/test/fixtures/strings-3.txt +5 -0
- data/test/fixtures/test-output-1.txt +12 -0
- data/test/fixtures/test-output-2.txt +12 -0
- data/test/fixtures/test-output-3.txt +18 -0
- data/test/fixtures/test-output-4.txt +21 -0
- data/test/fixtures/test-output-5.txt +11 -0
- data/test/fixtures/test-output-6.txt +10 -0
- data/test/fixtures/test-output-7.txt +14 -0
- data/test/fixtures/test-output-8.txt +9 -0
- data/test/fixtures/test-output-9.txt +21 -0
- data/test/traduco_test.rb +98 -0
- metadata +125 -0
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'traduco/formatters/abstract'
|
2
|
+
require 'traduco/formatters/android'
|
3
|
+
require 'traduco/formatters/apple'
|
4
|
+
require 'traduco/formatters/flash'
|
5
|
+
require 'traduco/formatters/gettext'
|
6
|
+
require 'traduco/formatters/jquery'
|
7
|
+
|
8
|
+
module Traduco
|
9
|
+
module Formatters
|
10
|
+
FORMATTERS = [Formatters::Apple, Formatters::Android, Formatters::Gettext, Formatters::JQuery, Formatters::Flash]
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module Traduco
|
2
|
+
module Formatters
|
3
|
+
class Abstract
|
4
|
+
attr_accessor :strings
|
5
|
+
attr_accessor :options
|
6
|
+
|
7
|
+
def self.can_handle_directory?(path)
|
8
|
+
return false
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(strings, options)
|
12
|
+
@strings = strings
|
13
|
+
@options = options
|
14
|
+
end
|
15
|
+
|
16
|
+
def iosify_substitutions(str)
|
17
|
+
# 1) use "@" instead of "s" for substituting strings
|
18
|
+
str.gsub!(/%([0-9\$]*)s/, '%\1@')
|
19
|
+
|
20
|
+
# 2) if substitutions are numbered, see if we can remove the numbering safely
|
21
|
+
expectedSub = 1
|
22
|
+
startFound = false
|
23
|
+
foundSub = 0
|
24
|
+
str.each_char do |c|
|
25
|
+
if startFound
|
26
|
+
if c == "%"
|
27
|
+
# this is a literal %, keep moving
|
28
|
+
startFound = false
|
29
|
+
elsif c.match(/\d/)
|
30
|
+
foundSub *= 10
|
31
|
+
foundSub += Integer(c)
|
32
|
+
elsif c == "$"
|
33
|
+
if expectedSub == foundSub
|
34
|
+
# okay to keep going
|
35
|
+
startFound = false
|
36
|
+
expectedSub += 1
|
37
|
+
else
|
38
|
+
# the numbering appears to be important (or non-existent), leave it alone
|
39
|
+
return str
|
40
|
+
end
|
41
|
+
end
|
42
|
+
elsif c == "%"
|
43
|
+
startFound = true
|
44
|
+
foundSub = 0
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# if we got this far, then the numbering (if any) is in order left-to-right and safe to remove
|
49
|
+
if expectedSub > 1
|
50
|
+
str.gsub!(/%\d+\$(.)/, '%\1')
|
51
|
+
end
|
52
|
+
|
53
|
+
return str
|
54
|
+
end
|
55
|
+
|
56
|
+
def androidify_substitutions(str)
|
57
|
+
# 1) use "s" instead of "@" for substituting strings
|
58
|
+
str.gsub!(/%([0-9\$]*)@/, '%\1s')
|
59
|
+
|
60
|
+
# 1a) escape strings that begin with a lone "@"
|
61
|
+
str.sub!(/^@ /, '\\@ ')
|
62
|
+
|
63
|
+
# 2) if there is more than one substitution in a string, make sure they are numbered
|
64
|
+
substituteCount = 0
|
65
|
+
startFound = false
|
66
|
+
str.each_char do |c|
|
67
|
+
if startFound
|
68
|
+
if c == "%"
|
69
|
+
# ignore as this is a literal %
|
70
|
+
elsif c.match(/\d/)
|
71
|
+
# leave the string alone if it already has numbered substitutions
|
72
|
+
return str
|
73
|
+
else
|
74
|
+
substituteCount += 1
|
75
|
+
end
|
76
|
+
startFound = false
|
77
|
+
elsif c == "%"
|
78
|
+
startFound = true
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
if substituteCount > 1
|
83
|
+
currentSub = 1
|
84
|
+
startFound = false
|
85
|
+
newstr = ""
|
86
|
+
str.each_char do |c|
|
87
|
+
if startFound
|
88
|
+
if !(c == "%")
|
89
|
+
newstr = newstr + "#{currentSub}$"
|
90
|
+
currentSub += 1
|
91
|
+
end
|
92
|
+
startFound = false
|
93
|
+
elsif c == "%"
|
94
|
+
startFound = true
|
95
|
+
end
|
96
|
+
newstr = newstr + c
|
97
|
+
end
|
98
|
+
return newstr
|
99
|
+
else
|
100
|
+
return str
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def set_translation_for_key(key, lang, value)
|
105
|
+
if @strings.strings_map.include?(key)
|
106
|
+
@strings.strings_map[key].translations[lang] = value
|
107
|
+
elsif @options[:consume_all]
|
108
|
+
STDERR.puts "Adding new string '#{key}' to strings data file."
|
109
|
+
arr = @strings.sections.select { |s| s.name == 'Uncategorized' }
|
110
|
+
current_section = arr ? arr[0] : nil
|
111
|
+
if !current_section
|
112
|
+
current_section = StringsSection.new('Uncategorized')
|
113
|
+
@strings.sections.insert(0, current_section)
|
114
|
+
end
|
115
|
+
current_row = StringsRow.new(key)
|
116
|
+
current_section.rows << current_row
|
117
|
+
@strings.strings_map[key] = current_row
|
118
|
+
@strings.strings_map[key].translations[lang] = value
|
119
|
+
else
|
120
|
+
STDERR.puts "Warning: '#{key}' not found in strings data file."
|
121
|
+
end
|
122
|
+
if !@strings.language_codes.include?(lang)
|
123
|
+
@strings.add_language_code(lang)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def set_comment_for_key(key, comment)
|
128
|
+
if @strings.strings_map.include?(key)
|
129
|
+
@strings.strings_map[key].comment = comment
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def default_file_name
|
134
|
+
raise NotImplementedError.new("You must implement default_file_name in your formatter class.")
|
135
|
+
end
|
136
|
+
|
137
|
+
def determine_language_given_path(path)
|
138
|
+
raise NotImplementedError.new("You must implement determine_language_given_path in your formatter class.")
|
139
|
+
end
|
140
|
+
|
141
|
+
def read_file(path, lang)
|
142
|
+
raise NotImplementedError.new("You must implement read_file in your formatter class.")
|
143
|
+
end
|
144
|
+
|
145
|
+
def write_file(path, lang)
|
146
|
+
raise NotImplementedError.new("You must implement write_file in your formatter class.")
|
147
|
+
end
|
148
|
+
|
149
|
+
def write_all_files(path)
|
150
|
+
if !File.directory?(path)
|
151
|
+
raise Traduco::Error.new("Directory does not exist: #{path}")
|
152
|
+
end
|
153
|
+
|
154
|
+
file_name = @options[:file_name] || default_file_name
|
155
|
+
Dir.foreach(path) do |item|
|
156
|
+
lang = determine_language_given_path(item)
|
157
|
+
if lang
|
158
|
+
write_file(File.join(path, item, file_name), lang)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'cgi'
|
3
|
+
require 'rexml/document'
|
4
|
+
|
5
|
+
module Traduco
|
6
|
+
module Formatters
|
7
|
+
class Android < Abstract
|
8
|
+
FORMAT_NAME = 'android'
|
9
|
+
EXTENSION = '.xml'
|
10
|
+
DEFAULT_FILE_NAME = 'strings.xml'
|
11
|
+
LANG_CODES = Hash[
|
12
|
+
'zh' => 'zh-Hans',
|
13
|
+
'zh-rCN' => 'zh-Hans',
|
14
|
+
'zh-rHK' => 'zh-Hant',
|
15
|
+
'en-rGB' => 'en-UK',
|
16
|
+
'in' => 'id',
|
17
|
+
'nb' => 'no'
|
18
|
+
# TODO: spanish
|
19
|
+
]
|
20
|
+
DEFAULT_LANG_CODES = Hash[
|
21
|
+
'zh-TW' => 'zh-Hant' # if we don't have a zh-TW translation, try zh-Hant before en
|
22
|
+
]
|
23
|
+
|
24
|
+
def self.can_handle_directory?(path)
|
25
|
+
Dir.entries(path).any? { |item| /^values.*$/.match(item) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def default_file_name
|
29
|
+
return DEFAULT_FILE_NAME
|
30
|
+
end
|
31
|
+
|
32
|
+
def determine_language_given_path(path)
|
33
|
+
path_arr = path.split(File::SEPARATOR)
|
34
|
+
path_arr.each do |segment|
|
35
|
+
if segment == 'values'
|
36
|
+
return @strings.language_codes[0]
|
37
|
+
else
|
38
|
+
match = /^values-(.*)$/.match(segment)
|
39
|
+
if match
|
40
|
+
lang = match[1]
|
41
|
+
lang = LANG_CODES.fetch(lang, lang)
|
42
|
+
lang.sub!('-r', '-')
|
43
|
+
return lang
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
return
|
49
|
+
end
|
50
|
+
|
51
|
+
def read_file(path, lang)
|
52
|
+
resources_regex = /<resources>(.*)<\/resources>/m
|
53
|
+
key_regex = /<string name="(\w+)">/
|
54
|
+
comment_regex = /<!-- (.*) -->/
|
55
|
+
value_regex = /<string name="\w+">(.*)<\/string>/
|
56
|
+
key = nil
|
57
|
+
value = nil
|
58
|
+
comment = nil
|
59
|
+
|
60
|
+
File.open(path, 'r:UTF-8') do |f|
|
61
|
+
content_match = resources_regex.match(f.read)
|
62
|
+
if content_match
|
63
|
+
for line in content_match[1].split(/\r?\n/)
|
64
|
+
key_match = key_regex.match(line)
|
65
|
+
if key_match
|
66
|
+
key = key_match[1]
|
67
|
+
value_match = value_regex.match(line)
|
68
|
+
if value_match
|
69
|
+
value = value_match[1]
|
70
|
+
value = CGI.unescapeHTML(value)
|
71
|
+
value.gsub!('\\\'', '\'')
|
72
|
+
value.gsub!('\\"', '"')
|
73
|
+
value = iosify_substitutions(value)
|
74
|
+
else
|
75
|
+
value = ""
|
76
|
+
end
|
77
|
+
set_translation_for_key(key, lang, value)
|
78
|
+
if comment and comment.length > 0 and !comment.start_with?("SECTION:")
|
79
|
+
set_comment_for_key(key, comment)
|
80
|
+
end
|
81
|
+
comment = nil
|
82
|
+
end
|
83
|
+
|
84
|
+
comment_match = comment_regex.match(line)
|
85
|
+
if comment_match
|
86
|
+
comment = comment_match[1]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def write_file(path, lang)
|
94
|
+
default_lang = nil
|
95
|
+
if DEFAULT_LANG_CODES.has_key?(lang)
|
96
|
+
default_lang = DEFAULT_LANG_CODES[lang]
|
97
|
+
end
|
98
|
+
File.open(path, 'w:UTF-8') do |f|
|
99
|
+
f.puts "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Android Strings File -->\n<!-- Generated by Traduco #{Traduco::VERSION} -->\n<!-- Language: #{lang} -->"
|
100
|
+
f.write '<resources>'
|
101
|
+
@strings.sections.each do |section|
|
102
|
+
printed_section = false
|
103
|
+
section.rows.each do |row|
|
104
|
+
if row.matches_tags?(@options[:tags], @options[:untagged])
|
105
|
+
if !printed_section
|
106
|
+
f.puts ''
|
107
|
+
if section.name && section.name.length > 0
|
108
|
+
section_name = section.name.gsub('--', '—')
|
109
|
+
f.puts "\t<!-- SECTION: #{section_name} -->"
|
110
|
+
end
|
111
|
+
printed_section = true
|
112
|
+
end
|
113
|
+
|
114
|
+
key = row.key
|
115
|
+
|
116
|
+
value = row.translated_string_for_lang(lang, default_lang)
|
117
|
+
if !value && @options[:include_untranslated]
|
118
|
+
value = row.translated_string_for_lang(@strings.language_codes[0])
|
119
|
+
end
|
120
|
+
|
121
|
+
if value # if values is nil, there was no appropriate translation, so let Android handle the defaulting
|
122
|
+
value = String.new(value) # use a copy to prevent modifying the original
|
123
|
+
|
124
|
+
# Android enforces the following rules on the values
|
125
|
+
# 1) apostrophes and quotes must be escaped with a backslash
|
126
|
+
value.gsub!('\'', '\\\\\'')
|
127
|
+
value.gsub!('"', '\\\\"')
|
128
|
+
# 2) HTML escape the string
|
129
|
+
value = CGI.escapeHTML(value)
|
130
|
+
# 3) fix substitutions (e.g. %s/%@)
|
131
|
+
value = androidify_substitutions(value)
|
132
|
+
|
133
|
+
comment = row.comment
|
134
|
+
if comment
|
135
|
+
comment = comment.gsub('--', '—')
|
136
|
+
end
|
137
|
+
|
138
|
+
if comment && comment.length > 0
|
139
|
+
f.puts "\t<!-- #{comment} -->\n"
|
140
|
+
end
|
141
|
+
f.puts "\t<string name=\"#{key}\">#{value}</string>"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
f.puts '</resources>'
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module Traduco
|
2
|
+
module Formatters
|
3
|
+
class Apple < Abstract
|
4
|
+
FORMAT_NAME = 'apple'
|
5
|
+
EXTENSION = '.strings'
|
6
|
+
DEFAULT_FILE_NAME = 'Localizable.strings'
|
7
|
+
|
8
|
+
def self.can_handle_directory?(path)
|
9
|
+
Dir.entries(path).any? { |item| /^.+\.lproj$/.match(item) }
|
10
|
+
end
|
11
|
+
|
12
|
+
def default_file_name
|
13
|
+
return DEFAULT_FILE_NAME
|
14
|
+
end
|
15
|
+
|
16
|
+
def determine_language_given_path(path)
|
17
|
+
path_arr = path.split(File::SEPARATOR)
|
18
|
+
path_arr.each do |segment|
|
19
|
+
match = /^(.+)\.lproj$/.match(segment)
|
20
|
+
if match
|
21
|
+
return match[1]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
return
|
26
|
+
end
|
27
|
+
|
28
|
+
def read_file(path, lang)
|
29
|
+
encoding = Traduco::Encoding.encoding_for_path(path)
|
30
|
+
sep = nil
|
31
|
+
if !encoding.respond_to?(:encode)
|
32
|
+
# This code is not necessary in 1.9.3 and does not work as it did in 1.8.7.
|
33
|
+
if encoding.end_with? 'LE'
|
34
|
+
sep = "\x0a\x00"
|
35
|
+
elsif encoding.end_with? 'BE'
|
36
|
+
sep = "\x00\x0a"
|
37
|
+
else
|
38
|
+
sep = "\n"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
if encoding.index('UTF-16')
|
43
|
+
mode = "rb:#{encoding}"
|
44
|
+
else
|
45
|
+
mode = "r:#{encoding}"
|
46
|
+
end
|
47
|
+
|
48
|
+
File.open(path, mode) do |f|
|
49
|
+
last_comment = nil
|
50
|
+
while line = (sep) ? f.gets(sep) : f.gets
|
51
|
+
if encoding.index('UTF-16')
|
52
|
+
if line.respond_to? :encode!
|
53
|
+
line.encode!('UTF-8')
|
54
|
+
else
|
55
|
+
require 'iconv'
|
56
|
+
line = Iconv.iconv('UTF-8', encoding, line).join
|
57
|
+
end
|
58
|
+
end
|
59
|
+
match = /"((?:[^"\\]|\\.)+)"\s*=\s*"((?:[^"\\]|\\.)*)"/.match(line)
|
60
|
+
if match
|
61
|
+
key = match[1]
|
62
|
+
key.gsub!('\\"', '"')
|
63
|
+
value = match[2]
|
64
|
+
value.gsub!('\\"', '"')
|
65
|
+
value = iosify_substitutions(value)
|
66
|
+
set_translation_for_key(key, lang, value)
|
67
|
+
if last_comment
|
68
|
+
set_comment_for_key(key, last_comment)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
if @options[:consume_comments]
|
72
|
+
match = /\/\* (.*) \*\//.match(line)
|
73
|
+
if match
|
74
|
+
last_comment = match[1]
|
75
|
+
else
|
76
|
+
last_comment = nil
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def write_file(path, lang)
|
84
|
+
default_lang = @strings.language_codes[0]
|
85
|
+
encoding = @options[:output_encoding] || 'UTF-8'
|
86
|
+
File.open(path, "w:#{encoding}") do |f|
|
87
|
+
f.puts "/**\n * Apple Strings File\n * Generated by Traduco #{Traduco::VERSION}\n * Language: #{lang}\n */"
|
88
|
+
@strings.sections.each do |section|
|
89
|
+
printed_section = false
|
90
|
+
section.rows.each do |row|
|
91
|
+
if row.matches_tags?(@options[:tags], @options[:untagged])
|
92
|
+
f.puts ''
|
93
|
+
if !printed_section
|
94
|
+
if section.name && section.name.length > 0
|
95
|
+
f.print "/********** #{section.name} **********/\n\n"
|
96
|
+
end
|
97
|
+
printed_section = true
|
98
|
+
end
|
99
|
+
|
100
|
+
key = row.key
|
101
|
+
key = key.gsub('"', '\\\\"')
|
102
|
+
|
103
|
+
value = row.translated_string_for_lang(lang, default_lang)
|
104
|
+
if value
|
105
|
+
value = value.gsub('"', '\\\\"')
|
106
|
+
|
107
|
+
comment = row.comment
|
108
|
+
if comment
|
109
|
+
comment = comment.gsub('*/', '* /')
|
110
|
+
end
|
111
|
+
|
112
|
+
if comment && comment.length > 0
|
113
|
+
f.print "/* #{comment} */\n"
|
114
|
+
end
|
115
|
+
|
116
|
+
f.print "\"#{key}\" = \"#{value}\";\n"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|