twine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source :rubygems
2
+ gemspec
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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require 'twine'
3
+ Twine::Runner.run(ARGV)
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
@@ -0,0 +1,9 @@
1
+ require 'twine/formatters/abstract'
2
+ require 'twine/formatters/android'
3
+ require 'twine/formatters/apple'
4
+
5
+ module Twine
6
+ module Formatters
7
+ FORMATTERS = [Formatters::Apple, Formatters::Android]
8
+ end
9
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Twine
2
+ VERSION = '0.1.0'
3
+ end
data/lib/twine.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Twine
2
+ class Error < StandardError
3
+ end
4
+
5
+ require 'twine/cli'
6
+ require 'twine/formatters'
7
+ require 'twine/runner'
8
+ require 'twine/stringsfile'
9
+ require 'twine/version'
10
+ end
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: []