twine 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +2 -0
- data/LICENSE +30 -0
- data/README.md +120 -0
- data/bin/twine +3 -0
- data/lib/twine/cli.rb +166 -0
- data/lib/twine/formatters/abstract.rb +58 -0
- data/lib/twine/formatters/android.rb +94 -0
- data/lib/twine/formatters/apple.rb +79 -0
- data/lib/twine/formatters.rb +9 -0
- data/lib/twine/runner.rb +265 -0
- data/lib/twine/stringsfile.rb +151 -0
- data/lib/twine/version.rb +3 -0
- data/lib/twine.rb +10 -0
- metadata +70 -0
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
Software License Agreement (BSD License)
|
2
|
+
|
3
|
+
Copyright (c) 2012, Mobiata, LLC
|
4
|
+
All rights reserved.
|
5
|
+
|
6
|
+
Redistribution and use of this software in source and binary forms, with or
|
7
|
+
without modification, are permitted provided that the following conditions are
|
8
|
+
met:
|
9
|
+
|
10
|
+
* Redistributions of source code must retain the above copyright notice, this
|
11
|
+
list of conditions and the following disclaimer.
|
12
|
+
|
13
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
15
|
+
and/or other materials provided with the distribution.
|
16
|
+
|
17
|
+
* Neither the name of the organization nor the names of its contributors may be
|
18
|
+
used to endorse or promote products derived from this software without
|
19
|
+
specific prior written permission.
|
20
|
+
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
22
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
23
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
25
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
26
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
27
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
28
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
29
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
30
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
# Twine
|
2
|
+
|
3
|
+
Twine is a command line tool for managing your strings and their translations. These strings are all stored in a master text file and then Twine uses this file to import and export strings in a variety of file types, including iOS and Mac OS X `.strings` files as well as Android `.xml` files. This allows individuals and companies to easily share strings across multiple projects, as well as export strings in any format the user wants.
|
4
|
+
|
5
|
+
## Install
|
6
|
+
|
7
|
+
### As a Gem
|
8
|
+
|
9
|
+
Twine is most easily installed as a Gem.
|
10
|
+
|
11
|
+
sudo gem install twine
|
12
|
+
|
13
|
+
### From Source
|
14
|
+
|
15
|
+
You can also run Twine directly from source. However, it requires [rubyzip][rubyzip] in order to create and read standard zip files.
|
16
|
+
|
17
|
+
sudo gem install rubyzip
|
18
|
+
git clone git://github.com/mobiata/twine.git
|
19
|
+
cd twine
|
20
|
+
./twine --help
|
21
|
+
|
22
|
+
Make sure you run the `twine` executable at the root of the project as it properly sets up your Ruby library path. The `bin/twine` executable does not.
|
23
|
+
|
24
|
+
## String File Format
|
25
|
+
|
26
|
+
Twine stores all of its strings in a single file. The format of this file is a slight variant of the [Git][git] config file format, which itself is based on the old [Windows INI file][INI] format. The entire file is broken up into sections, which are created by placing the section name between two pairs of square brackets. Sections are optional, but they are a recommended way of breaking your strings into smaller, more manageable chunks.
|
27
|
+
|
28
|
+
Each grouping section contains N string definitions. These string definitions start with the string key placed within a single pair of square brackets. This string definition then contains a number of key-value pairs, including a comment, a comma-separated list of tags (which are used by Twine to select a subset of strings), and all of the translations.
|
29
|
+
|
30
|
+
### Tags
|
31
|
+
|
32
|
+
Tags are used by Twine as a way to only work with a subset of your strings at any given point in time. Each string can be assigned zero or more tags which are separated by commas. When a string has no tags, that string will never be selected by Twine. You can get a list of all strings currently missing tags by executing the `generate-report` command.
|
33
|
+
|
34
|
+
### Whitespace
|
35
|
+
|
36
|
+
Whitepace in this file is mostly ignored. If you absolutely need to put spaces at the beginning or end of your translated string, you can wrap the entire string in a pair of `` ` `` characters. If your actual string needs to start *and* end with a grave accent, you can wrap it in another pair of `` ` `` characters. See the example, below.
|
37
|
+
|
38
|
+
### Example
|
39
|
+
|
40
|
+
[[General]]
|
41
|
+
[yes]
|
42
|
+
en = Yes
|
43
|
+
es = Sí
|
44
|
+
fr = Oui
|
45
|
+
ja = はい
|
46
|
+
[no]
|
47
|
+
en = No
|
48
|
+
fr = Non
|
49
|
+
ja = いいえ
|
50
|
+
|
51
|
+
[[Errors]]
|
52
|
+
[path_not_found_error]
|
53
|
+
en = The file '%@' could not be found.
|
54
|
+
tags = app1,app6
|
55
|
+
comment = An error describing when a path on the filesystem could not be found.
|
56
|
+
[network_unavailable_error]
|
57
|
+
en = The network is currently unavailable.
|
58
|
+
tags = app1
|
59
|
+
comment = An error describing when the device can not connect to the internet.
|
60
|
+
|
61
|
+
[[Escaping Example]]
|
62
|
+
[list_item_separator]
|
63
|
+
en = `, `
|
64
|
+
tags = mytag
|
65
|
+
comment = A string that should be placed between multiple items in a list. For example: Red, Green, Blue
|
66
|
+
[grave_accent_quoted_string]
|
67
|
+
en = ``%@``
|
68
|
+
tags = myothertag
|
69
|
+
comment = This string will evaluate to `%@`.
|
70
|
+
|
71
|
+
## Usage
|
72
|
+
|
73
|
+
Usage: twine COMMAND STRINGS_FILE [INPUT_OR_OUTPUT_PATH] [--lang LANG1,LANG2...] [--tag TAG1,TAG2,TAG3...] [--format FORMAT]
|
74
|
+
|
75
|
+
### Commands
|
76
|
+
|
77
|
+
#### `generate-string-file`
|
78
|
+
|
79
|
+
This command creates an Apple or Android strings file from the master strings data file.
|
80
|
+
|
81
|
+
> twine generate-string-file /path/to/strings.txt values-ja.xml --tag common,app1
|
82
|
+
> twine generate-string-file /path/to/strings.txt Localizable.strings --lang ja --tag mytag
|
83
|
+
> twine generate-string-file /path/to/strings.txt all-english.strings --lang en
|
84
|
+
|
85
|
+
#### `generate-all-string-files`
|
86
|
+
|
87
|
+
This command is a convenient way to call `generate-string-file` multiple times. It uses standard Mac OS X, iOS, and Android conventions to figure out exactly which files to create given a parent directory. For example, if you point it to a parent directory containing `en.lproj`, `fr.lproj`, and `ja.lproj` subdirectories, Twine will create a `Localizable.strings` file of the appropriate language in each of them. This is often the command you will want to execute during the build phase of your project.
|
88
|
+
|
89
|
+
> twine generate-all-string-files /path/to/strings.txt /path/to/project/locales/directory --tag common,app1
|
90
|
+
|
91
|
+
#### `consume-string-file`
|
92
|
+
|
93
|
+
This command slurps all of the strings from a `.strings` or `.xml` file and incorporates the translated text into the master strings data file. This is a simple way to incorporate any changes made to a single file by one of your translators. It will only identify strings that already exist in the master data file.
|
94
|
+
|
95
|
+
> twine consume-string-file /path/to/strings.txt fr.strings
|
96
|
+
> twine consume-string-file /path/to/strings.txt Localizable.strings --lang ja
|
97
|
+
> twine consume-string-file /path/to/strings.txt es.xml
|
98
|
+
|
99
|
+
#### `generate-loc-drop`
|
100
|
+
|
101
|
+
This command is a convenient way to generate a zip file containing files created by the `generate-string-file` command. It is often used for creating a single zip containing a large number of strings in all languages which you can then hand off to your translation team.
|
102
|
+
|
103
|
+
> twine generate-loc-drop /path/to/strings.txt LocDrop1.zip
|
104
|
+
> twine generate-loc-drop /path/to/strings.txt LocDrop2.zip --lang en,fr,ja,ko --tag common,app1
|
105
|
+
|
106
|
+
#### `consume-loc-drop`
|
107
|
+
|
108
|
+
This command is a convenient way of taking a zip file and executing the `consume-string-file` command on each file within the archive. It is most often used to incorporate all of the changes made by the translation team after they have completed work on a localization drop.
|
109
|
+
|
110
|
+
> twine consume-loc-drop /path/to/strings.txt LocDrop2.zip
|
111
|
+
|
112
|
+
#### `generate-report`
|
113
|
+
|
114
|
+
This command gives you useful information about your strings. It will tell you how many strings you have, how many have been translated into each language, and whether your master strings data file has any duplicate string keys.
|
115
|
+
|
116
|
+
> twine generate-report /path/to/strings.txt
|
117
|
+
|
118
|
+
[rubyzip]: http://rubygems.org/gems/rubyzip
|
119
|
+
[git]: http://git-scm.org/
|
120
|
+
[INI]: http://en.wikipedia.org/wiki/INI_file
|
data/bin/twine
ADDED
data/lib/twine/cli.rb
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Twine
|
4
|
+
class CLI
|
5
|
+
def initialize(args, options)
|
6
|
+
@options = options
|
7
|
+
@args = args
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.parse_args(args, options)
|
11
|
+
new(args, options).parse_args
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse_args
|
15
|
+
parser = OptionParser.new(@args) do |opts|
|
16
|
+
opts.banner = 'Usage: twine COMMAND STRINGS_FILE [INPUT_OR_OUTPUT_PATH] [--lang LANG1,LANG2...] [--tag TAG1,TAG2,TAG3...] [--format FORMAT]'
|
17
|
+
opts.separator ''
|
18
|
+
opts.separator 'The purpose of this script is to convert back and forth between multiple data formats, allowing us to treat our strings (and translations) as data stored in a text file. We can then use the data file to create drops for the localization team, consume similar drops returned by the localization team, generate reports on the strings, as well as create iOS and Android string files to ship with our products.'
|
19
|
+
opts.separator ''
|
20
|
+
opts.separator 'Commands:'
|
21
|
+
opts.separator ''
|
22
|
+
opts.separator 'generate-strings-file -- Generates a string file in a certain LANGUAGE given a particular FORMAT. This script will attempt to guess both the language and the format given the filename and extension. For example, "ko.xml" will generate a Korean language file for Android.'
|
23
|
+
opts.separator ''
|
24
|
+
opts.separator 'generate-all-string-files -- Generates all the string files necessary for a given project. The parent directory to all of the locale-specific directories in your project should be specified as the INPUT_OR_OUTPUT_PATH. This command will most often be executed by your build script so that each build always contains the most recent strings.'
|
25
|
+
opts.separator ''
|
26
|
+
opts.separator 'consume-string-file -- Slurps all of the strings from a translated strings file into the specified STRINGS_FILE. If you have some files returned to you by your translators you can use this command to incorporate all of their changes. This script will attempt to guess both the language and the format given the filename and extension. For example, "ja.strings" will assume that the file is a Japanese iOS strings file.'
|
27
|
+
opts.separator ''
|
28
|
+
opts.separator 'generate-loc-drop -- Generates a zip archive of strings files in any format. The purpose of this command is to create a very simple archive that can be handed off to a translation team. The translation team can unzip the archive, translate all of the strings in the archived files, zip everything back up, and then hand that final archive back to be consumed by the consume-loc-drop command.'
|
29
|
+
opts.separator ''
|
30
|
+
opts.separator 'consume-loc-drop -- Consumes an archive of translated files. This archive should be in the same format as the one created by the generate-loc-drop command.'
|
31
|
+
opts.separator ''
|
32
|
+
opts.separator 'generate-report -- Generates a report containing data about your strings. For example, it will tell you if you have any duplicate strings or if any of your strings are missing tags. In addition, it will tell you how many strings you have and how many of those strings have been translated into each language.'
|
33
|
+
opts.separator ''
|
34
|
+
opts.separator 'General Options:'
|
35
|
+
opts.separator ''
|
36
|
+
opts.on('-l', '--lang LANGUAGES', Array, 'The language code(s) to use for the specified action.') do |langs|
|
37
|
+
@options[:languages] = langs
|
38
|
+
end
|
39
|
+
opts.on('-t', '--tag TAGS', Array, 'The tag(s) to use for the specified action. Only strings with that tag will be processed.') do |tags|
|
40
|
+
@options[:tags] = tags
|
41
|
+
end
|
42
|
+
opts.on('-f', '--format FORMAT', 'The file format to read or write (iOS, Android). Additional formatters can be placed in the formats/ directory.') do |format|
|
43
|
+
lformat = format.downcase
|
44
|
+
found_format = false
|
45
|
+
Formatters::FORMATTERS.each do |formatter|
|
46
|
+
if formatter::FORMAT_NAME == lformat
|
47
|
+
found_format = true
|
48
|
+
break
|
49
|
+
end
|
50
|
+
end
|
51
|
+
if !found_format
|
52
|
+
puts "Invalid format: #{format}"
|
53
|
+
end
|
54
|
+
@options[:format] = lformat
|
55
|
+
end
|
56
|
+
opts.on('-h', '--help', 'Show this message.') do |h|
|
57
|
+
puts opts.help
|
58
|
+
exit
|
59
|
+
end
|
60
|
+
opts.on('--version', 'Print the version number and exit.') do |x|
|
61
|
+
puts "Twine version #{Twine::VERSION}"
|
62
|
+
exit
|
63
|
+
end
|
64
|
+
opts.separator ''
|
65
|
+
opts.separator 'Examples:'
|
66
|
+
opts.separator ''
|
67
|
+
opts.separator '> twine generate-string-file strings.txt ko.xml --tag FT'
|
68
|
+
opts.separator '> twine generate-all-string-files strings.txt Resources/Locales/ --tag FT,FB'
|
69
|
+
opts.separator '> twine consume-string-file strings.txt ja.strings'
|
70
|
+
opts.separator '> twine generate-loc-drop strings.txt LocDrop5.zip --tag FT,FB --format android --lang de,en,en-GB,ja,ko'
|
71
|
+
opts.separator '> twine consume-loc-drop strings.txt LocDrop5.zip'
|
72
|
+
opts.separator '> twine generate-report strings.txt'
|
73
|
+
end
|
74
|
+
parser.parse!
|
75
|
+
|
76
|
+
if @args.length == 0
|
77
|
+
puts parser.help
|
78
|
+
exit
|
79
|
+
end
|
80
|
+
|
81
|
+
@options[:command] = @args[0]
|
82
|
+
|
83
|
+
if !VALID_COMMANDS.include? @options[:command]
|
84
|
+
puts "Invalid command: #{@options[:command]}"
|
85
|
+
exit
|
86
|
+
end
|
87
|
+
|
88
|
+
if @args.length == 1
|
89
|
+
puts 'You must specify your strings file.'
|
90
|
+
exit
|
91
|
+
end
|
92
|
+
|
93
|
+
@options[:strings_file] = @args[1]
|
94
|
+
|
95
|
+
case @options[:command]
|
96
|
+
when 'generate-string-file'
|
97
|
+
if @args.length == 3
|
98
|
+
@options[:output_path] = @args[2]
|
99
|
+
elsif @args.length > 3
|
100
|
+
puts "Unknown argument: #{@args[3]}"
|
101
|
+
exit
|
102
|
+
else
|
103
|
+
puts 'Not enough arguments.'
|
104
|
+
exit
|
105
|
+
end
|
106
|
+
if @options[:languages] and @options[:languages].length > 1
|
107
|
+
puts 'Please only specify a single language for the generate-string-file command.'
|
108
|
+
exit
|
109
|
+
end
|
110
|
+
when 'generate-all-string-files'
|
111
|
+
if ARGV.length == 3
|
112
|
+
@options[:output_path] = @args[2]
|
113
|
+
elsif @args.length > 3
|
114
|
+
puts "Unknown argument: #{@args[3]}"
|
115
|
+
exit
|
116
|
+
else
|
117
|
+
puts 'Not enough arguments.'
|
118
|
+
exit
|
119
|
+
end
|
120
|
+
when 'consume-string-file'
|
121
|
+
if @args.length == 3
|
122
|
+
@options[:input_path] = @args[2]
|
123
|
+
elsif @args.length > 3
|
124
|
+
puts "Unknown argument: #{@args[3]}"
|
125
|
+
exit
|
126
|
+
else
|
127
|
+
puts 'Not enough arguments.'
|
128
|
+
exit
|
129
|
+
end
|
130
|
+
if @options[:languages] and @options[:languages].length > 1
|
131
|
+
puts 'Please only specify a single language for the consume-string-file command.'
|
132
|
+
exit
|
133
|
+
end
|
134
|
+
when 'generate-loc-drop'
|
135
|
+
if @args.length == 3
|
136
|
+
@options[:output_path] = @args[2]
|
137
|
+
elsif @args.length > 3
|
138
|
+
puts "Unknown argument: #{@args[3]}"
|
139
|
+
exit
|
140
|
+
else
|
141
|
+
puts 'Not enough arguments.'
|
142
|
+
exit
|
143
|
+
end
|
144
|
+
if !@options[:format]
|
145
|
+
puts 'You must specify a format.'
|
146
|
+
exit
|
147
|
+
end
|
148
|
+
when 'consume-loc-drop'
|
149
|
+
if @args.length == 3
|
150
|
+
@options[:input_path] = @args[2]
|
151
|
+
elsif @args.length > 3
|
152
|
+
puts "Unknown argument: #{@args[3]}"
|
153
|
+
exit
|
154
|
+
else
|
155
|
+
puts 'Not enough arguments.'
|
156
|
+
exit
|
157
|
+
end
|
158
|
+
when 'generate-report'
|
159
|
+
if @args.length > 2
|
160
|
+
puts "Unknown argument: #{@args[2]}"
|
161
|
+
exit
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module Twine
|
2
|
+
module Formatters
|
3
|
+
class Abstract
|
4
|
+
def self.can_handle_directory?(path)
|
5
|
+
return false
|
6
|
+
end
|
7
|
+
|
8
|
+
def default_file_name
|
9
|
+
raise NotImplementedError.new("You must implement default_file_name in your formatter class.")
|
10
|
+
end
|
11
|
+
|
12
|
+
def determine_language_given_path(path)
|
13
|
+
raise NotImplementedError.new("You must implement determine_language_given_path in your formatter class.")
|
14
|
+
end
|
15
|
+
|
16
|
+
def read_file(path, lang, strings)
|
17
|
+
raise NotImplementedError.new("You must implement read_file in your formatter class.")
|
18
|
+
end
|
19
|
+
|
20
|
+
def write_file(path, lang, tags, strings)
|
21
|
+
raise NotImplementedError.new("You must implement write_file in your formatter class.")
|
22
|
+
end
|
23
|
+
|
24
|
+
def write_all_files(path, tags, strings)
|
25
|
+
if !File.directory?(path)
|
26
|
+
raise Twine::Error.new("Directory does not exist: #{path}")
|
27
|
+
end
|
28
|
+
|
29
|
+
Dir.foreach(path) do |item|
|
30
|
+
lang = determine_language_given_path(item)
|
31
|
+
if lang
|
32
|
+
write_file(File.join(path, item, default_file_name), lang, tags, strings)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def row_matches_tags?(row, tags)
|
38
|
+
if tags == nil || tags.length == 0
|
39
|
+
return true
|
40
|
+
end
|
41
|
+
|
42
|
+
if tags != nil && row.tags != nil
|
43
|
+
tags.each do |tag|
|
44
|
+
if row.tags.include? tag
|
45
|
+
return true
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
return false
|
51
|
+
end
|
52
|
+
|
53
|
+
def translated_string_for_row_and_lang(row, lang, default_lang)
|
54
|
+
row.translations[lang] || row.translations[default_lang]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'cgi'
|
4
|
+
require 'rexml/document'
|
5
|
+
|
6
|
+
module Twine
|
7
|
+
module Formatters
|
8
|
+
class Android < Abstract
|
9
|
+
FORMAT_NAME = 'android'
|
10
|
+
EXTENSION = '.xml'
|
11
|
+
DEFAULT_FILE_NAME = 'strings.xml'
|
12
|
+
|
13
|
+
def self.can_handle_directory?(path)
|
14
|
+
Dir.entries(path).any? { |item| /^values-.+$/.match(item) }
|
15
|
+
end
|
16
|
+
|
17
|
+
def default_file_name
|
18
|
+
return DEFAULT_FILE_NAME
|
19
|
+
end
|
20
|
+
|
21
|
+
def determine_language_given_path(path)
|
22
|
+
path_arr = path.split(File::SEPARATOR)
|
23
|
+
path_arr.each do |segment|
|
24
|
+
match = /^values-(.*)$/.match(path_arr)
|
25
|
+
if match
|
26
|
+
lang = match[1]
|
27
|
+
lang.sub!('-r', '-')
|
28
|
+
return lang
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
def read_file(path, lang, strings)
|
36
|
+
File.open(path, 'r:UTF-8') do |f|
|
37
|
+
doc = REXML::Document.new(f)
|
38
|
+
doc.elements.each('resources/string') do |ele|
|
39
|
+
key = ele.attributes["name"]
|
40
|
+
if strings.strings_map.include? key
|
41
|
+
value = ele.text
|
42
|
+
value.gsub!('\\\'', '\'')
|
43
|
+
value.gsub!('%s', '%@')
|
44
|
+
strings.strings_map[key].translations[lang] = value
|
45
|
+
else
|
46
|
+
puts "#{key} not found in strings data file."
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def write_file(path, lang, tags, strings)
|
53
|
+
default_lang = strings.language_codes[0]
|
54
|
+
File.open(path, 'w:UTF-8') do |f|
|
55
|
+
f.puts "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Android Strings File -->\n<!-- Generated by Twine -->\n<!-- Language: #{lang} -->"
|
56
|
+
f.puts '<resources>'
|
57
|
+
section_num = -1
|
58
|
+
strings.sections.each do |section|
|
59
|
+
section_num += 1
|
60
|
+
if section_num > 0
|
61
|
+
f.puts ''
|
62
|
+
end
|
63
|
+
|
64
|
+
section_name = section.name.gsub('--', '—')
|
65
|
+
f.puts "\t<!-- #{section_name} -->"
|
66
|
+
section.rows.each do |row|
|
67
|
+
if row_matches_tags?(row, tags)
|
68
|
+
key = row.key
|
69
|
+
key = CGI.escapeHTML(key)
|
70
|
+
|
71
|
+
value = translated_string_for_row_and_lang(row, lang, default_lang)
|
72
|
+
value.gsub!('\'', '\\\\\'')
|
73
|
+
value.gsub!('%@', '%s')
|
74
|
+
value = CGI.escapeHTML(value)
|
75
|
+
|
76
|
+
comment = row.comment
|
77
|
+
if comment
|
78
|
+
comment = comment.gsub('--', '—')
|
79
|
+
end
|
80
|
+
|
81
|
+
if comment && comment.length > 0
|
82
|
+
f.puts "\t<!-- #{comment} -->\n"
|
83
|
+
end
|
84
|
+
f.puts "\t<string name=\"#{key}\">#{value}</string>"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
f.puts '</resources>'
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Twine
|
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, strings)
|
29
|
+
File.open(path, 'r:UTF-16') do |f|
|
30
|
+
while line = f.gets
|
31
|
+
match = /"((?:[^"\\]|\\.)+)"\s*=\s*"((?:[^"\\]|\\.)*)/.match(line)
|
32
|
+
if match
|
33
|
+
key = match[1]
|
34
|
+
key.gsub!('\\"', '"')
|
35
|
+
if strings.strings_map.include? key
|
36
|
+
value = match[2]
|
37
|
+
value.gsub!('\\"', '"')
|
38
|
+
strings.strings_map[key].translations[lang] = value
|
39
|
+
else
|
40
|
+
puts "#{key} not found in strings data file."
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def write_file(path, lang, tags, strings)
|
48
|
+
default_lang = strings.language_codes[0]
|
49
|
+
File.open(path, 'w:UTF-16') do |f|
|
50
|
+
f.puts "/**\n * iOS Strings File\n * Generated by Twine\n * Language: #{lang}\n */"
|
51
|
+
strings.sections.each do |section|
|
52
|
+
f.puts "\n/* #{section.name} */"
|
53
|
+
section.rows.each do |row|
|
54
|
+
if row_matches_tags?(row, tags)
|
55
|
+
key = row.key
|
56
|
+
key = key.gsub('"', '\\\\"')
|
57
|
+
|
58
|
+
value = translated_string_for_row_and_lang(row, lang, default_lang)
|
59
|
+
value = value.gsub('"', '\\\\"')
|
60
|
+
|
61
|
+
comment = row.comment
|
62
|
+
if comment
|
63
|
+
comment = comment.gsub('*/', '* /')
|
64
|
+
end
|
65
|
+
|
66
|
+
f.print "\"#{key}\" = \"#{value}\";"
|
67
|
+
if comment && comment.length > 0
|
68
|
+
f.print " /* #{comment} */\n"
|
69
|
+
else
|
70
|
+
f.print "\n"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/twine/runner.rb
ADDED
@@ -0,0 +1,265 @@
|
|
1
|
+
require 'tmpdir'
|
2
|
+
|
3
|
+
module Twine
|
4
|
+
VALID_COMMANDS = ['generate-string-file', 'generate-all-string-files', 'consume-string-file', 'generate-loc-drop', 'consume-loc-drop', 'generate-report']
|
5
|
+
|
6
|
+
class Runner
|
7
|
+
def initialize(args)
|
8
|
+
@options = {}
|
9
|
+
@args = args
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.run(args)
|
13
|
+
new(args).run
|
14
|
+
end
|
15
|
+
|
16
|
+
def run
|
17
|
+
# Parse all CLI arguments.
|
18
|
+
CLI::parse_args(@args, @options)
|
19
|
+
|
20
|
+
begin
|
21
|
+
read_strings_data
|
22
|
+
execute_command
|
23
|
+
rescue Twine::Error => e
|
24
|
+
puts e.message
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def read_strings_data
|
29
|
+
@strings = StringsFile.new
|
30
|
+
@strings.read @options[:strings_file]
|
31
|
+
end
|
32
|
+
|
33
|
+
def execute_command
|
34
|
+
case @options[:command]
|
35
|
+
when 'generate-string-file'
|
36
|
+
generate_string_file
|
37
|
+
when 'generate-all-string-files'
|
38
|
+
generate_all_string_files
|
39
|
+
when 'consume-string-file'
|
40
|
+
consume_string_file
|
41
|
+
when 'generate-loc-drop'
|
42
|
+
generate_loc_drop
|
43
|
+
when 'consume-loc-drop'
|
44
|
+
consume_loc_drop
|
45
|
+
when 'generate-report'
|
46
|
+
generate_report
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def generate_string_file
|
51
|
+
lang = nil
|
52
|
+
if @options[:languages]
|
53
|
+
lang = @options[:languages][0]
|
54
|
+
end
|
55
|
+
|
56
|
+
read_write_string_file(@options[:output_path], false, lang, @options[:format], @options[:tags])
|
57
|
+
end
|
58
|
+
|
59
|
+
def generate_all_string_files
|
60
|
+
if !File.directory?(@options[:output_path])
|
61
|
+
raise Twine::Error.new("Directory does not exist: #{@options[:output_path]}")
|
62
|
+
end
|
63
|
+
|
64
|
+
format = @options[:format]
|
65
|
+
if !format
|
66
|
+
format = determine_format_given_directory(@options[:output_path])
|
67
|
+
end
|
68
|
+
if !format
|
69
|
+
raise Twine::Error.new "Could not determine format given the contents of #{@options[:output_path]}"
|
70
|
+
end
|
71
|
+
|
72
|
+
formatter = formatter_for_format(format)
|
73
|
+
|
74
|
+
formatter.write_all_files(@options[:output_path], @options[:tags], @strings)
|
75
|
+
end
|
76
|
+
|
77
|
+
def consume_string_file
|
78
|
+
lang = nil
|
79
|
+
if @options[:languages]
|
80
|
+
lang = @options[:languages][0]
|
81
|
+
end
|
82
|
+
|
83
|
+
read_write_string_file(@options[:input_path], true, lang, @options[:format], nil)
|
84
|
+
@strings.write(@options[:strings_file])
|
85
|
+
end
|
86
|
+
|
87
|
+
def read_write_string_file(path, is_read, lang, format, tags)
|
88
|
+
if is_read && !File.file?(path)
|
89
|
+
raise Twine::Error.new("File does not exist: #{path}")
|
90
|
+
end
|
91
|
+
|
92
|
+
if !format
|
93
|
+
format = determine_format_given_path(path)
|
94
|
+
end
|
95
|
+
if !format
|
96
|
+
raise Twine::Error.new "Unable to determine format of #{path}"
|
97
|
+
end
|
98
|
+
|
99
|
+
formatter = formatter_for_format(format)
|
100
|
+
|
101
|
+
if !lang
|
102
|
+
lang = determine_language_given_path(path)
|
103
|
+
end
|
104
|
+
if !lang
|
105
|
+
lang = formatter.determine_language_given_path(path)
|
106
|
+
end
|
107
|
+
if !lang
|
108
|
+
raise Twine::Error.new "Unable to determine language for #{path}"
|
109
|
+
end
|
110
|
+
|
111
|
+
if is_read
|
112
|
+
formatter.read_file(path, lang, @strings)
|
113
|
+
else
|
114
|
+
formatter.write_file(path, lang, tags, @strings)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def generate_loc_drop
|
119
|
+
begin
|
120
|
+
require 'zip/zip'
|
121
|
+
rescue LoadError
|
122
|
+
raise Twine::Error.new "You must 'gem install rubyzip' in order to create or consume localization drops."
|
123
|
+
end
|
124
|
+
|
125
|
+
if File.file?(@options[:output_path])
|
126
|
+
File.delete(@options[:output_path])
|
127
|
+
end
|
128
|
+
|
129
|
+
Dir.mktmpdir do |dir|
|
130
|
+
Zip::ZipFile.open(@options[:output_path], Zip::ZipFile::CREATE) do |zipfile|
|
131
|
+
zipfile.mkdir('Locales')
|
132
|
+
|
133
|
+
formatter = formatter_for_format(@options[:format])
|
134
|
+
@strings.language_codes.each do |lang|
|
135
|
+
if @options[:languages] == nil || @options[:languages].length == 0 || @options[:languages].include?(lang)
|
136
|
+
file_name = lang + formatter.class::EXTENSION
|
137
|
+
real_path = File.join(dir, file_name)
|
138
|
+
zip_path = File.join('Locales', file_name)
|
139
|
+
formatter.write_file(real_path, lang, @options[:tags], @strings)
|
140
|
+
zipfile.add(zip_path, real_path)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def consume_loc_drop
|
148
|
+
if !File.file?(@options[:input_path])
|
149
|
+
raise Twine::Error.new("File does not exist: #{@options[:input_path]}")
|
150
|
+
end
|
151
|
+
|
152
|
+
begin
|
153
|
+
require 'zip/zip'
|
154
|
+
rescue LoadError
|
155
|
+
raise Twine::Error.new "You must 'gem install rubyzip' in order to create or consume localization drops."
|
156
|
+
end
|
157
|
+
|
158
|
+
Dir.mktmpdir do |dir|
|
159
|
+
Zip::ZipFile.open(@options[:input_path]) do |zipfile|
|
160
|
+
zipfile.each do |entry|
|
161
|
+
if !entry.name.end_with?'/'
|
162
|
+
real_path = File.join(dir, entry.name)
|
163
|
+
FileUtils.mkdir_p(File.dirname(real_path))
|
164
|
+
zipfile.extract(entry.name, real_path)
|
165
|
+
read_write_string_file(real_path, true, nil, nil, @options[:tags])
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
@strings.write @options[:strings_file]
|
172
|
+
end
|
173
|
+
|
174
|
+
def generate_report
|
175
|
+
total_strings = 0
|
176
|
+
strings_per_lang = {}
|
177
|
+
all_keys = Set.new
|
178
|
+
duplicate_keys = Set.new
|
179
|
+
keys_without_tags = Set.new
|
180
|
+
@strings.language_codes.each do |code|
|
181
|
+
strings_per_lang[code] = 0
|
182
|
+
end
|
183
|
+
|
184
|
+
@strings.sections.each do |section|
|
185
|
+
section.rows.each do |row|
|
186
|
+
total_strings += 1
|
187
|
+
|
188
|
+
if all_keys.include? row.key
|
189
|
+
duplicate_keys.add(row.key)
|
190
|
+
else
|
191
|
+
all_keys.add(row.key)
|
192
|
+
end
|
193
|
+
|
194
|
+
row.translations.each_key do |code|
|
195
|
+
strings_per_lang[code] += 1
|
196
|
+
end
|
197
|
+
|
198
|
+
if row.tags == nil || row.tags.length == 0
|
199
|
+
keys_without_tags.add(row.key)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Print the report.
|
205
|
+
puts "Total number of strings = #{total_strings}"
|
206
|
+
@strings.language_codes.each do |code|
|
207
|
+
puts "#{code}: #{strings_per_lang[code]}"
|
208
|
+
end
|
209
|
+
|
210
|
+
if duplicate_keys.length > 0
|
211
|
+
puts "\nDuplicate string keys:"
|
212
|
+
duplicate_keys.each do |key|
|
213
|
+
puts key
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
if keys_without_tags.length > 0
|
218
|
+
puts "\nStrings without tags:"
|
219
|
+
keys_without_tags.each do |key|
|
220
|
+
puts key
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def determine_language_given_path(path)
|
226
|
+
code = File.basename(path, File.extname(path))
|
227
|
+
if !@strings.language_codes.include? code
|
228
|
+
code = nil
|
229
|
+
end
|
230
|
+
|
231
|
+
code
|
232
|
+
end
|
233
|
+
|
234
|
+
def determine_format_given_path(path)
|
235
|
+
ext = File.extname(path)
|
236
|
+
Formatters::FORMATTERS.each do |formatter|
|
237
|
+
if formatter::EXTENSION == ext
|
238
|
+
return formatter::FORMAT_NAME
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
return
|
243
|
+
end
|
244
|
+
|
245
|
+
def determine_format_given_directory(directory)
|
246
|
+
Formatters::FORMATTERS.each do |formatter|
|
247
|
+
if formatter.can_handle_directory?(directory)
|
248
|
+
return formatter::FORMAT_NAME
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
return
|
253
|
+
end
|
254
|
+
|
255
|
+
def formatter_for_format(format)
|
256
|
+
Formatters::FORMATTERS.each do |formatter|
|
257
|
+
if formatter::FORMAT_NAME == format
|
258
|
+
return formatter.new
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
return
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module Twine
|
2
|
+
class StringsSection
|
3
|
+
attr_reader :name
|
4
|
+
attr_reader :rows
|
5
|
+
|
6
|
+
def initialize(name)
|
7
|
+
@name = name
|
8
|
+
@rows = []
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class StringsRow
|
13
|
+
attr_reader :key
|
14
|
+
attr_accessor :comment
|
15
|
+
attr_accessor :tags
|
16
|
+
attr_reader :translations
|
17
|
+
|
18
|
+
def initialize(key)
|
19
|
+
@key = key
|
20
|
+
@comment = nil
|
21
|
+
@tags = nil
|
22
|
+
@translations = {}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class StringsFile
|
27
|
+
attr_reader :sections
|
28
|
+
attr_reader :strings_map
|
29
|
+
attr_reader :language_codes
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
@sections = []
|
33
|
+
@strings_map = {}
|
34
|
+
@language_codes = []
|
35
|
+
end
|
36
|
+
|
37
|
+
def read(path)
|
38
|
+
if !File.file?(path)
|
39
|
+
raise Twine::Error.new("File does not exist: #{path}")
|
40
|
+
end
|
41
|
+
|
42
|
+
File.open(path, 'r:UTF-8') do |f|
|
43
|
+
line_num = 0
|
44
|
+
current_section = nil
|
45
|
+
current_row = nil
|
46
|
+
while line = f.gets
|
47
|
+
parsed = false
|
48
|
+
line.strip!
|
49
|
+
line_num += 1
|
50
|
+
|
51
|
+
if line.length == 0
|
52
|
+
next
|
53
|
+
end
|
54
|
+
|
55
|
+
if line.length > 4 && line[0, 2] == '[['
|
56
|
+
match = /^\[\[(.+)\]\]$/.match(line)
|
57
|
+
if match
|
58
|
+
current_section = StringsSection.new(match[1].strip)
|
59
|
+
@sections << current_section
|
60
|
+
parsed = true
|
61
|
+
end
|
62
|
+
elsif line.length > 2 && line[0, 1] == '['
|
63
|
+
match = /^\[(.+)\]$/.match(line)
|
64
|
+
if match
|
65
|
+
current_row = StringsRow.new(match[1].strip)
|
66
|
+
@strings_map[current_row.key] = current_row
|
67
|
+
if !current_section
|
68
|
+
current_section = StringsSection.new('')
|
69
|
+
@sections << current_section
|
70
|
+
end
|
71
|
+
current_section.rows << current_row
|
72
|
+
parsed = true
|
73
|
+
end
|
74
|
+
else
|
75
|
+
match = /^([^=]+)=(.+)$/.match(line)
|
76
|
+
if match
|
77
|
+
key = match[1].strip
|
78
|
+
value = match[2].strip
|
79
|
+
if value[0,1] == '`' && value[-1,1] == '`'
|
80
|
+
value = value[1..-2]
|
81
|
+
end
|
82
|
+
|
83
|
+
case key
|
84
|
+
when "comment"
|
85
|
+
current_row.comment = value
|
86
|
+
when 'tags'
|
87
|
+
current_row.tags = value.split(',')
|
88
|
+
else
|
89
|
+
if !@language_codes.include? key
|
90
|
+
@language_codes << key
|
91
|
+
end
|
92
|
+
current_row.translations[key] = value
|
93
|
+
end
|
94
|
+
parsed = true
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
if !parsed
|
99
|
+
raise Twine::Error.new("Unable to parse line #{line_num} of #{path}: #{line}")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Developer Language
|
104
|
+
dev_lang = @language_codes[0]
|
105
|
+
@language_codes.delete(dev_lang)
|
106
|
+
@language_codes.sort!
|
107
|
+
@language_codes.insert(0, dev_lang)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def write(path)
|
112
|
+
dev_lang = @language_codes[0]
|
113
|
+
|
114
|
+
File.open(path, 'w:UTF-8') do |f|
|
115
|
+
@sections.each do |section|
|
116
|
+
if f.pos > 0
|
117
|
+
f.puts ''
|
118
|
+
end
|
119
|
+
|
120
|
+
f.puts "[[#{section.name}]]"
|
121
|
+
|
122
|
+
section.rows.each do |row|
|
123
|
+
value = row.translations[dev_lang]
|
124
|
+
if value[0,1] == ' ' || value[-1,1] == ' ' || (value[0,1] == '`' && value[-1,1] == '`')
|
125
|
+
value = '`' + value + '`'
|
126
|
+
end
|
127
|
+
|
128
|
+
f.puts "\t[#{row.key}]"
|
129
|
+
f.puts "\t\t#{dev_lang} = #{value}"
|
130
|
+
if row.tags && row.tags.length > 0
|
131
|
+
tag_str = row.tags.join(',')
|
132
|
+
f.puts "\t\ttags = #{tag_str}"
|
133
|
+
end
|
134
|
+
if row.comment && row.comment.length > 0
|
135
|
+
f.puts "\t\tcomment = #{row.comment}"
|
136
|
+
end
|
137
|
+
@language_codes[1..-1].each do |lang|
|
138
|
+
value = row.translations[lang]
|
139
|
+
if value && value != row.translations[dev_lang]
|
140
|
+
if value[0,1] == ' ' || value[-1,1] == ' ' || (value[0,1] == '`' && value[-1,1] == '`')
|
141
|
+
value = '`' + value + '`'
|
142
|
+
end
|
143
|
+
f.puts "\t\t#{lang} = #{value}"
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
data/lib/twine.rb
ADDED
metadata
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: twine
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Sebastian Celis
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-02-06 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rubyzip
|
16
|
+
requirement: &70351938160380 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.9.5
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70351938160380
|
25
|
+
description: ! " Twine is a command line tool for managing your strings and their
|
26
|
+
translations.\n \n It is geared toward Mac OS X, iOS, and Android developers.\n"
|
27
|
+
email: twine@mobiata.com
|
28
|
+
executables:
|
29
|
+
- twine
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- Gemfile
|
34
|
+
- README.md
|
35
|
+
- LICENSE
|
36
|
+
- lib/twine/cli.rb
|
37
|
+
- lib/twine/formatters/abstract.rb
|
38
|
+
- lib/twine/formatters/android.rb
|
39
|
+
- lib/twine/formatters/apple.rb
|
40
|
+
- lib/twine/formatters.rb
|
41
|
+
- lib/twine/runner.rb
|
42
|
+
- lib/twine/stringsfile.rb
|
43
|
+
- lib/twine/version.rb
|
44
|
+
- lib/twine.rb
|
45
|
+
- bin/twine
|
46
|
+
homepage: https://github.com/mobiata/twine
|
47
|
+
licenses: []
|
48
|
+
post_install_message:
|
49
|
+
rdoc_options: []
|
50
|
+
require_paths:
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ! '>='
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
none: false
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
requirements: []
|
65
|
+
rubyforge_project:
|
66
|
+
rubygems_version: 1.8.11
|
67
|
+
signing_key:
|
68
|
+
specification_version: 3
|
69
|
+
summary: Manage strings and their translations for your iOS and Android projects.
|
70
|
+
test_files: []
|