├── .ruby-version ├── screenshot.png ├── lib ├── crowdin-cli │ └── version.rb └── crowdin-cli.rb ├── features ├── step_definitions │ └── crowdin-cli_steps.rb ├── crowdin-cli.feature └── support │ └── env.rb ├── Gemfile ├── LICENSE ├── crowdin-cli.gemspec ├── Rakefile ├── locales └── en.yml ├── README.md └── bin └── crowdin-cli /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.0 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdin/crowdin-cli-v1/HEAD/screenshot.png -------------------------------------------------------------------------------- /lib/crowdin-cli/version.rb: -------------------------------------------------------------------------------- 1 | module Crowdin 2 | module CLI 3 | VERSION = '0.7.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /features/step_definitions/crowdin-cli_steps.rb: -------------------------------------------------------------------------------- 1 | When /^I get help for "([^"]*)"$/ do |app_name| 2 | @app_name = app_name 3 | step %(I run `#{app_name} help`) 4 | end 5 | 6 | # Add more step definitions here 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | gem 'rubyzip' 4 | gem 'gli' 5 | gem 'i18n' 6 | gem 'crowdin-api' 7 | 8 | # For development purposes only 9 | group :development do 10 | # gem 'crowdin-api', path: '../crowdin-api' 11 | # gem 'byebug' 12 | end 13 | 14 | -------------------------------------------------------------------------------- /features/crowdin-cli.feature: -------------------------------------------------------------------------------- 1 | Feature: My bootstrapped app kinda works 2 | In order to get going on coding my awesome app 3 | I want to have aruba and cucumber setup 4 | So I don't have to do it myself 5 | 6 | Scenario: App just runs 7 | When I get help for "crowdin-cli" 8 | Then the exit status should be 0 9 | -------------------------------------------------------------------------------- /lib/crowdin-cli.rb: -------------------------------------------------------------------------------- 1 | require 'crowdin-cli/version.rb' 2 | 3 | # Add requires for other files you add to your project here, so 4 | # you just need to require this one file in your bin file 5 | 6 | require 'yaml' 7 | require 'json' 8 | require 'logger' 9 | require 'gli' 10 | require 'zip' 11 | require 'i18n' 12 | require 'crowdin-api' 13 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'aruba/cucumber' 2 | 3 | ENV['PATH'] = "#{File.expand_path(File.dirname(__FILE__) + '/../../bin')}#{File::PATH_SEPARATOR}#{ENV['PATH']}" 4 | LIB_DIR = File.join(File.expand_path(File.dirname(__FILE__)),'..','..','lib') 5 | 6 | Before do 7 | # Using "announce" causes massive warnings on 1.9.2 8 | @puts = true 9 | @original_rubylib = ENV['RUBYLIB'] 10 | ENV['RUBYLIB'] = LIB_DIR + File::PATH_SEPARATOR + ENV['RUBYLIB'].to_s 11 | end 12 | 13 | After do 14 | ENV['RUBYLIB'] = @original_rubylib 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-2018 Anton Maminov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crowdin-cli.gemspec: -------------------------------------------------------------------------------- 1 | # Ensure we require the local version and not one we might have installed already 2 | require File.join([File.dirname(__FILE__),'lib','crowdin-cli','version.rb']) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.name = 'crowdin-cli' 6 | gem.version = Crowdin::CLI::VERSION 7 | 8 | gem.summary = 'Crowdin CLI.' 9 | gem.description = 'A command-line interface to sync files between your computer/server and Crowdin.' 10 | gem.author = ['Crowdin'] 11 | gem.email = ['support@crowdin.net'] 12 | gem.homepage = 'https://github.com/crowdin/crowdin-cli/' 13 | gem.license = 'LICENSE' 14 | 15 | gem.files = %w( 16 | bin/crowdin-cli 17 | lib/crowdin-cli/version.rb 18 | lib/crowdin-cli.rb 19 | locales/en.yml 20 | README.md 21 | LICENSE 22 | ) 23 | gem.require_paths << 'lib' 24 | gem.bindir = 'bin' 25 | gem.executables << 'crowdin-cli' 26 | gem.add_development_dependency 'rake' 27 | gem.add_development_dependency 'pry' 28 | gem.add_development_dependency 'rdoc' 29 | gem.add_development_dependency 'aruba' 30 | gem.add_runtime_dependency 'gli', '~> 2.16' 31 | gem.add_runtime_dependency 'rubyzip', '~> 1.0' 32 | gem.add_runtime_dependency 'crowdin-api', '~> 0.5.0' 33 | gem.add_runtime_dependency 'i18n', '~> 0.8' 34 | gem.platform = Gem::Platform::RUBY 35 | gem.required_ruby_version = '>= 2.4.0' 36 | end 37 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/clean' 2 | require 'rubygems' 3 | require 'rubygems/package_task' 4 | require 'rdoc/task' 5 | require 'cucumber' 6 | require 'cucumber/rake/task' 7 | Rake::RDocTask.new do |rd| 8 | rd.main = "README.rdoc" 9 | rd.rdoc_files.include("README.rdoc","lib/**/*.rb","bin/**/*") 10 | rd.title = 'Your application title' 11 | end 12 | 13 | spec = eval(File.read('crowdin-cli.gemspec')) 14 | 15 | Gem::PackageTask.new(spec) do |pkg| 16 | end 17 | CUKE_RESULTS = 'results.html' 18 | CLEAN << CUKE_RESULTS 19 | desc 'Run features' 20 | Cucumber::Rake::Task.new(:features) do |t| 21 | opts = "features --format html -o #{CUKE_RESULTS} --format progress -x" 22 | opts += " --tags #{ENV['TAGS']}" if ENV['TAGS'] 23 | t.cucumber_opts = opts 24 | t.fork = false 25 | end 26 | 27 | desc 'Run features tagged as work-in-progress (@wip)' 28 | Cucumber::Rake::Task.new('features:wip') do |t| 29 | tag_opts = ' --tags ~@pending' 30 | tag_opts = ' --tags @wip' 31 | t.cucumber_opts = "features --format html -o #{CUKE_RESULTS} --format pretty -x -s#{tag_opts}" 32 | t.fork = false 33 | end 34 | 35 | task :cucumber => :features 36 | task 'cucumber:wip' => 'features:wip' 37 | task :wip => 'features:wip' 38 | require 'rake/testtask' 39 | Rake::TestTask.new do |t| 40 | t.libs << "test" 41 | t.test_files = FileList['test/*_test.rb'] 42 | end 43 | 44 | task :default => [:test,:features] 45 | -------------------------------------------------------------------------------- /locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | app: 3 | desc: is a command line tool that allows you to manage and synchronize your localization resources with Crowdin project 4 | long_desc: | 5 | This tool requires configuration file to be created. 6 | See http://crowdin.com/page/cli-tool#configuration-file for more details. 7 | flags: 8 | config: 9 | desc: Project-specific configuration file 10 | identity: 11 | desc: User-specific configuration file with API credentials 12 | branch: 13 | desc: Defines branch name. 14 | switches: 15 | verbose: 16 | desc: Be verbose 17 | commands: 18 | upload: 19 | desc: Allows you to upload source files and existing translations to Crowdin project. 20 | long_desc: | 21 | This command is used to upload source files and translations to Crowdin. 22 | This command is used in combination with sub-commands `sources` and `translations` 23 | commands: 24 | sources: 25 | desc: safely upload source files to Crowdin 26 | long_desc: | 27 | Upload source files to Crowdin project. 28 | If there are new localization files locally they will be added to Crowdin project. 29 | Otherwise files will be updated (if no --no-auto-update option specified). 30 | switches: 31 | auto_update: 32 | desc: | 33 | Defines whether to update source files in Crowdin project. 34 | --no-auto-update is useful when you just want to upload new files without updating existing ones. 35 | translations: 36 | desc: upload existing translations to Crowdin project 37 | long_desc: | 38 | Upload existing translations to Crowdin. 39 | See below available options that can be used in combination with this command. 40 | If no options specified uploaded translations will be not approved, 41 | imported to Crowdin project even if they are duplicated and imported even they are equal to souce string. 42 | (In many localization formats they can be considered as not actually translations). 43 | flags: 44 | language: 45 | desc: | 46 | Defines what language upload translations to. 47 | By default translations will be uploaded for all Crowdin project target languages 48 | switches: 49 | import_duplicates: 50 | desc: Defines whether to add translation if there is the same translation already existing in Crowdin project 51 | import_eq_suggestions: 52 | desc: Defines whether to add translation if it is equal to source string in Crowdin project 53 | auto_approve_imported: 54 | desc: Automatically approve uploaded translations 55 | download: 56 | desc: Download latest translations from Crowdin and put them to the right places in your project 57 | long_desc: | 58 | Download latest translations from Crowdin and put them to the right places in your project 59 | flags: 60 | language: 61 | desc: | 62 | If the option is defined the translations will be downloaded for single specified language. 63 | Otherwise (by default) translations are downloaded for all languages 64 | switches: 65 | ignore_match: 66 | desc: | 67 | Exit with a zero even if no files matched 68 | include_unchanged: 69 | desc: | 70 | Download all translation files whether or not they have changes 71 | list: 72 | desc: Show a list of files (the current project) 73 | long_desc: | 74 | List information about the files. List only those files that match the wild-card pattern 75 | commands: 76 | project: 77 | desc: List information about the files that already exists in current project 78 | branches: 79 | desc: List remote-tracking branches 80 | sources: 81 | desc: List information about the sources files in current project that match the wild-card pattern 82 | translations: 83 | desc: List information about the translations files in current project that match the wild-card pattern 84 | switches: 85 | tree: 86 | desc: list contents of directories in a tree-like format 87 | include_branches: 88 | desc: include branches 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crowdin-CLI Ruby is now deprecated 2 | 3 | > [crowdin-cli-2](https://github.com/crowdin/crowdin-cli-2) is a ground-up rewrite in Java with a new flow, incredible speed, but the same core idea. 4 | > This repository remains available for existing applications built on what we now call Crowdin-CLI Ruby. 5 | 6 | ## Crowdin-CLI Ruby 7 | 8 | [Crowdin Integration Utility Homepage](http://crowdin.com/page/cli-tool) 9 | | [Support](http://crowdin.com/contacts) 10 | | [crowdin.com Homepage](http://crowdin.com) 11 | | [crowdin-api RubyDoc](http://rubydoc.info/github/crowdin/crowdin-api/) 12 | 13 | A Command-Line Interface to sync files between local computer/server and [Crowdin](crowdin.com). 14 | 15 | It is cross-platform and can be run in a terminal (Linux, MacOS X) or in cmd.exe (Windows). 16 | 17 | ![ScreenShot](https://raw.github.com/crowdin/crowdin-cli/master/screenshot.png) 18 | 19 | > **WARNING**: This is a development version: It contains the latest changes, but may also have several known issues, including crashes and data loss situations. In fact, it may not work at all. 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ``` 26 | gem 'crowdin-cli' 27 | ``` 28 | 29 | And then execute: 30 | ``` 31 | $ bundle 32 | ``` 33 | 34 | Or install it manually as: 35 | ``` 36 | $ gem install crowdin-cli 37 | ``` 38 | 39 | ## Configuration 40 | 41 | When the tool is installed, you would have to configure your project. Basically, `crowdin-cli` go through project directory, and looks for `crowdin.yaml` file that contains project information. 42 | 43 | Create `crowdin.yaml` YAML file in your root project directory with the following structure: 44 | 45 | ``` 46 | project_identifier: test 47 | api_key: KeepTheAPIkeySecret 48 | base_url: https://api.crowdin.com 49 | base_path: /path/to/your/project 50 | 51 | files: 52 | - 53 | source: /locale/en/LC_MESSAGES/messages.po 54 | translation: /locale/%two_letters_code%/LC_MESSAGES/%original_file_name% 55 | ``` 56 | 57 | * `api_key` - Crowdin Project API key 58 | * `project_identifier` - Crowdin project name 59 | * `base_url` - (default: https://api.crowdin.com) 60 | * `base_path` - defines what directory have to be scanned (default: current directory) 61 | * `files` 62 | * `source` - defines only files that should be uploaded as sources 63 | * `translation` - defines where translations should be placed after downloading (also the path have to be checked to detect and upload existing translations) 64 | 65 | Use the following placeholders to put appropriate variables into the resulting file name: 66 | * `%language%` - Language name (i.e. Ukrainian) 67 | * `%two_letters_code%` - Language code ISO 639-1 (i.e. uk) 68 | * `%three_letters_code%` - Language code ISO 639-2/T (i.e. ukr) 69 | * `%locale%` - Locale (like uk-UA) 70 | * `%locale_with_underscore%` - Locale (i.e. uk_UA) 71 | * `%original_file_name%` - Original file name 72 | * `%android_code%` - Android Locale identifier used to name "values-" directories 73 | * `%original_path%` - Take parent folders names in Crowdin project to build file path in resulted bundle 74 | * `%file_extension%` - Original file extension 75 | * `%file_name%` - File name without extension 76 | * `%osx_code%` - OS X Locale identifier used to name ".lproj" directories 77 | * `%osx_locale%` - OS X Locale identifier 78 | 79 | Example for Android projects: 80 | ``` 81 | /values-%android_code%/%original_file_name% 82 | ``` 83 | Example for Gettext projects: 84 | ``` 85 | /locale/%two_letters_code%/LC_MESSAGES/%original_file_name% 86 | ``` 87 | 88 | You can also add and upload all directories matching the pattern, including all nested files and localizable files. 89 | 90 | Configuration example provided above has 'source' and 'translation' attributes containing standard wildcards (also known as globbing patterns) to make it easier to work with multiple files. 91 | 92 | Here are the patterns you can use: 93 | 94 | * `*` (asterisk) 95 | 96 | Match zero or more characters in file name. A glob consisting of only the asterisk and no other characters will match all files in the directory. If you specified a `*.json` it will include all files like `messages.json`, `about_us.json` and anything that ends with `.json`.c* will match all files beginning with c; `*c` will match all files ending with c; and `*c*` will match all files that have c in them (including at the beginning or end). Equivalent to `/ .* /x` in regexp. 97 | 98 | * `**` (doubled asterisk) 99 | 100 | Match all the directories recursively. Note that you can use `**` in `source` and in `translation` pattern. When using `**` in `translation` pattern it will always contain sub-path from `source` for certain file. The mask `**` can be used only once in the pattern and must be surrounded by backslashes `/`. 101 | 102 | * `?` (question mark) 103 | 104 | Matches any one character. 105 | 106 | * `[set]` 107 | 108 | Matches any one character in set. Behaves exactly like character sets in `Regexp`, including set negation (`[^a-z]`). 109 | 110 | * `\` (backslash) 111 | 112 | Escapes the next metacharacter. 113 | 114 | Say, you can have source: `/en/**/*.po` to upload all `*.po` files to Crowdin recursively. `translation` pattern will be `/translations/%two_letters_code%/**/%original_file_name%'`. 115 | 116 | See sample configuration below: 117 | ``` 118 | --- 119 | project_identifier: test 120 | api_key: KeepTheAPIkeySecret 121 | base_url: https://api.crowdin.com 122 | base_path: /path/to/your/project 123 | 124 | files: 125 | - 126 | source: /locale/en/**/*.po 127 | translation: /locale/%two_letters_code%/**/%original_file_name% 128 | ``` 129 | 130 | ### API Credentials from environment variables 131 | 132 | You could load the API Credentials from an environment variable, e.g. 133 | 134 | ``` 135 | api_key_env: CROWDIN_API_KEY 136 | project_identifier_env: CROWDIN_PROJECT_ID 137 | base_path_env: CROWDIN_BASE_PATH 138 | 139 | ``` 140 | 141 | If mixed, `api_key` and `project_identifier` have priority: 142 | 143 | ``` 144 | api_key_env: CROWDIN_API_KEY # Low priority 145 | project_identifier_env: CROWDIN_PROJECT # Low priority 146 | base_path_env: CROWDIN_BASE_PATH # Low priority 147 | api_key: xxx # High priority 148 | project_identifier: yyy # High priority 149 | base_path: zzz # High priority 150 | ``` 151 | 152 | ### Split project configuration and user credentials 153 | 154 | The `crowdin.yaml` file contains project-specific configuration and user credentials(`api_key`, `project_identifier`, `base_path`). 155 | This means that you can't commit this file in the code repository, because the API key would leak to other users. `crowdin-cli` allow 2 configuration files: 156 | 157 | * a project-specific, residing in the project directory (required) 158 | * a user-specific, probably residing in `$HOME/.crowdin.yaml` (optional) 159 | 160 | **NOTE**: user credentials in user-specific configuration file is higher priority than project-specific. 161 | 162 | ### Languages mapping 163 | 164 | Often software projects have custom names for locale directories. `crowdin-cli` allows you to map your own languages to understandable by Crowdin. 165 | 166 | Let's say your locale directories named 'en', 'uk', 'fr', 'de'. All of them can be represented by `%two_letters_code%` placeholder. Still, you have one directory named 'zh_CH'. In order to make it work with `crowdin-cli` without changes in your project you can add `languages_mapping` section to your files set. See sample configuration below: 167 | 168 | ``` 169 | --- 170 | project_identifier: test 171 | api_key: KeepTheAPIkeySecret 172 | base_url: https://api.crowdin.com 173 | base_path: /path/to/your/project 174 | 175 | files: 176 | - 177 | source: /locale/en/**/*.po 178 | translation: /locale/%two_letters_code%/**/%original_file_name% 179 | languages_mapping: 180 | two_letters_code: 181 | # crowdin_language_code: local_name 182 | ru: ros 183 | uk: ukr 184 | ``` 185 | Mapping format is the following: `crowdin_language_code : code_use_use`. 186 | 187 | Check [complete list of Crowdin language codes](http://crowdin.com/page/api/language-codes) that can be used for mapping. 188 | 189 | You can also override language codes for other placeholders like `%android_code%`, `%locale%` etc... 190 | 191 | ### Ignoring directories 192 | 193 | From time to time there are files and directories you don't want translate on Crowdin. 194 | Local per-file rules can be added to the config file in your project. 195 | 196 | ``` 197 | files: 198 | - 199 | source: /locale/en/**/*.po 200 | translation: /locale/%two_letters_code%/**/%original_file_name% 201 | ignore: 202 | - /locale/en/templates 203 | - /locale/en/**/test-*.po 204 | - /locale/en/**/[^abc]*.po 205 | 206 | ``` 207 | 208 | ### Preserving directories hierarchy 209 | 210 | By default CLI tool tries to optimize your Crowdin project hierarchy and do not repeats complete path of local files online. 211 | In case you need to keep directories structure same at Crowdin and locally you can add `preserve_hierarchy: true` option in main section of the configuration file. 212 | 213 | Configuration sample is below: 214 | 215 | ``` 216 | --- 217 | project_identifier: test 218 | api_key: KeepTheAPIkeySecret 219 | base_url: https://api.crowdin.com 220 | base_path: /path/to/your/project 221 | preserve_hierarchy: true 222 | ``` 223 | 224 | ### Uploading files to specified path with specified type 225 | 226 | This add support for 2 optional parameters in the yaml file section: `dest` and `type`. 227 | This is useful typically for some projects, where the uploaded name must be different so Crowdin can detect the type correctly. 228 | `dest` allows you to specify a file name on Crowdin. 229 | **NOTE**: `dest` only works for single files. Don't try to use it with patterns (multiple files). 230 | 231 | Configuration sample is below: 232 | 233 | ``` 234 | files 235 | - 236 | source: '/conf/messages' 237 | dest: '/messages.properties' 238 | translation: '/conf/messages.%two_letters_code%' 239 | type: 'properties' 240 | 241 | 242 | ``` 243 | 244 | ### Escape quotes for `.properties` file format 245 | 246 | Defines whether single quote should be escaped by another single quote or backslash in exported translations. 247 | You can add `escape_quote: ` per-file option. 248 | Acceptable values are: 0, 1, 2, 3. Default is 3. 249 | 250 | * 0 — Do not escape single quote; 251 | * 1 — Escape single quote by another single quote; 252 | * 2 — Escape single quote by backslash; 253 | * 3 — Escape single quote by another single quote only in strings containing variables ( {0} ) 254 | 255 | ``` 256 | --- 257 | project_identifier: test 258 | api_key: KeepTheAPIkeySecret 259 | base_url: https://api.crowdin.com 260 | base_path: /path/to/your/project 261 | 262 | files: 263 | - 264 | source: '/en/strings.properties' 265 | translation: '/%two_letters_code%/%original_file_name%' 266 | escape_quotes: 1 267 | ``` 268 | 269 | ### Uploading CSV files via API 270 | 271 | ``` 272 | --- 273 | project_identifier: test 274 | api_key: KeepTheAPIkeySecret 275 | base_url: https://api.crowdin.com 276 | base_path: /path/to/your/project 277 | 278 | files: 279 | - 280 | source: '/*.csv' 281 | translation: '%two_letters_code%/%original_file_name%' 282 | # Defines whether first line should be imported or it contains columns headers 283 | first_line_contains_header: true 284 | # Used only when uploading CSV file to define data columns mapping. 285 | scheme: "identifier,source_phrase,translation,context,max_length" 286 | ``` 287 | 288 | #### Multicolumn CSV 289 | 290 | In case CSV file contains translations to all target languages you can use per-file option `multilingual_spreadsheet`. 291 | 292 | CSV file example: 293 | ``` 294 | identifier,source_phrase,context,Ukrainian,Russian,French 295 | ident1,Source 1,Context 1,,, 296 | ident2,Source 2,Context 2,,, 297 | ident3,Source 3,Context 3,,, 298 | ``` 299 | 300 | Configuration file example: 301 | ``` 302 | files: 303 | - 304 | source: multicolumn.csv 305 | translation: multicolumn.csv 306 | first_line_contains_header: true 307 | scheme: "identifier,source_phrase,context,uk,ru,fr" 308 | multilingual_spreadsheet: true 309 | ``` 310 | 311 | ### Versions Management 312 | 313 | In version `0.5.0` we added support for versions management feature in Crowdin. Read more in our [blog](http://blog.crowdin.com/post/130133108120/new-feature-versions-management). 314 | 315 | This is how Crowdin CLI command looks like if you upload source texts from the branch: 316 | ``` 317 | crowdin-cli upload sources -b {branch_name} 318 | ``` 319 | 320 | Upload translations texts from the branch: 321 | ``` 322 | crowdin-cli upload translations -b {branch_name} 323 | ``` 324 | 325 | Download translations from the branch: 326 | ``` 327 | crowdin-cli download -b {branch_name} 328 | ``` 329 | 330 | ### Using a common base path for multiple branches 331 | 332 | By default CLI tool uses the base path without taking the branch into account when using the new versions management feature. 333 | In case you need to specify a common base path that contains the branches in subfolders named after branch names you can add `base_path_contains_branch_subfolders: true` option in main section of the configuration file. 334 | 335 | Configuration file example: 336 | ``` 337 | --- 338 | project_identifier: test 339 | api_key: KeepTheAPIkeySecret 340 | base_url: https://api.crowdin.com 341 | base_path: /path/to/your/project 342 | base_path_contains_branch_subfolders: true 343 | ``` 344 | 345 | ## Configurations Examples 346 | 347 | ### GetText Project 348 | 349 | ``` 350 | --- 351 | project_identifier: test 352 | api_key: KeepTheAPIkeySecret 353 | base_url: https://api.crowdin.com 354 | base_path: /path/to/your/project 355 | 356 | files: 357 | - 358 | source: '/locale/en/**/*.po' 359 | translation: '/locale/%two_letters_code%/LC_MESSAGES/%original_file_name%' 360 | languages_mapping: 361 | two_letters_code: 362 | 'zh-CN': 'zh_CH' 363 | 'fr-QC': 'fr' 364 | ``` 365 | 366 | ### Android Project 367 | 368 | ``` 369 | --- 370 | project_identifier: test 371 | api_key: KeepTheAPIkeySecret 372 | base_url: https://api.crowdin.com 373 | base_path: /path/to/your/project 374 | 375 | files: 376 | - 377 | source: '/res/values/*.xml' 378 | translation: '/res/values-%android_code%/%original_file_name%' 379 | languages_mapping: 380 | android_code: 381 | # we need this mapping since Crowdin expects directories 382 | # to be named like "values-uk-rUA" 383 | # acording to specification instead of just "uk" 384 | de: de 385 | ru: ru 386 | ``` 387 | 388 | ## Usage 389 | 390 | When the configuration file is created, you are ready to start using `crowdin-cli` to manage your localization resources and automate files synchronization. 391 | 392 | We listed most typical commands that crowdin-cli is used for: 393 | 394 | Upload your source files to Crowdin: 395 | ``` 396 | $ crowdin-cli upload sources 397 | ``` 398 | 399 | Upload existing translations to Crowdin project (translations will be synchronized): 400 | ``` 401 | $ crowdin-cli upload translations 402 | ``` 403 | 404 | Download latest translations from Crowdin: 405 | ``` 406 | $ crowdin-cli download 407 | ``` 408 | 409 | List information about the files that already exists in current project: 410 | ``` 411 | $ crowdin-cli list project 412 | ``` 413 | 414 | List information about the sources files in current project that match the wild-card pattern: 415 | ``` 416 | $ crowdin-cli list sources 417 | ``` 418 | 419 | List information about the translations files in current project that match the wild-card pattern: 420 | ``` 421 | $ crowdin-cli list translations 422 | ``` 423 | 424 | By default, `list` command print a list of all the files 425 | Also, `list` accept `-tree` optional argument to list contents in a tree-like format. 426 | 427 | 428 | Get help on `upload` command: 429 | ``` 430 | $ crowdin-cli help upload 431 | ``` 432 | 433 | Get help on `upload sources` command: 434 | ``` 435 | $ crowdin-cli help upload sources 436 | ``` 437 | 438 | Use help provided with an application to get more information about available commands and options: 439 | 440 | 441 | ## Supported Rubies 442 | 443 | Tested with the following Ruby versions: 444 | 445 | - MRI 2.2.1 446 | - JRuby 9.0.0.0 447 | 448 | ## Creating a JAR file 449 | 450 | Installation/SystemRequirements: 451 | 452 | - Linux 453 | - Java 454 | - rvm/rbenv 455 | 456 | Install latest version of JRuby `>=9.0.0.0` and Warbler gem `>=2.0.0`: 457 | 458 | ``` 459 | $ rvm install jruby 460 | $ gem install warbler 461 | ``` 462 | 463 | Create a new file called `Gemfile` in new project directory, an specify `crowdin-cli` version: 464 | 465 | ```ruby 466 | source 'https://rubygems.org' 467 | gem 'crowdin-api', '=0.4.1' 468 | gem 'crowdin-cli', '=0.5.4' 469 | ``` 470 | 471 | Create a new file called `bin/crowdin-cli`: 472 | 473 | ```ruby 474 | #!/usr/bin/env ruby_noexec_wrapper 475 | 476 | # The application 'crowdin-cli' is installed as part of a gem, and 477 | # this file is here to facilitate running it. 478 | # 479 | 480 | require 'rubygems' 481 | 482 | version = ">= 0" 483 | 484 | if ARGV.first =~ /^_(.*)_$/ and Gem::Version.correct? $1 then 485 | version = $1 486 | ARGV.shift 487 | end 488 | 489 | gem 'crowdin-cli', version 490 | load Gem.bin_path('crowdin-cli', 'crowdin-cli', version) 491 | ``` 492 | 493 | Install dependencies: 494 | 495 | ``` 496 | $ bundle 497 | ``` 498 | 499 | Compile/package with Warbler: 500 | 501 | ``` 502 | $ warble jar 503 | ``` 504 | 505 | and rename `warbler.jar` to whatever you want. 506 | 507 | Run jar in any computer with installed Java: 508 | 509 | ``` 510 | java -jar .jar 511 | ``` 512 | 513 | #### Installing as a Java Application 514 | 515 | We bundled crowdin-cli as a Java application to let you start using crowdin-cli easier. 516 | This method does not require installation. To get started: 517 | 518 | 1. [Download crowdin-cli.jar](https://downloads.crowdin.com/cli/v1/crowdin-cli.jar "Download crowdin-cli bundled as Java application") and save to your hard drive 519 | 2. Check that you have Java 7 installed 520 | 3. Add the crowdin-cli.jar file to your project directory 521 | 522 | ## Contributing 523 | 524 | 1. Fork it 525 | 2. Create your feature branch (`git checkout -b my-new-feature`) 526 | 3. Commit your changes (`git commit -am 'Added some feature'`) 527 | 4. Push to the branch (`git push origin my-new-feature`) 528 | 5. Create new Pull Request 529 | 530 | ## License and Author 531 | 532 | Author: Anton Maminov (anton.maminov@gmail.com) 533 | 534 | Copyright: 2012-2018 [crowdin.com](http://crowdin.com/) 535 | 536 | This project is licensed under the MIT license, a copy of which can be found in the LICENSE file. 537 | -------------------------------------------------------------------------------- /bin/crowdin-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: utf-8 3 | 4 | require 'pp' 5 | require 'find' 6 | require 'crowdin-cli' 7 | 8 | # For development purposes only. Comment in production 9 | # require 'byebug' 10 | 11 | # GLI_DEBUG=true bundle exec bin/crowdin-cli 12 | 13 | # Setup i18n 14 | # tell the I18n library where to find your translations 15 | I18n.load_path += Dir[Pathname(__FILE__).dirname.expand_path + '../locales/*.yml'] 16 | I18n.enforce_available_locales = false 17 | I18n.locale = :en 18 | 19 | # Return +hierarchy+ of directories and files in Crowdin project 20 | # 21 | # +files+ - basically, it's project files details from API method `project_info` 22 | # 23 | def get_remote_files_hierarchy(files, root = '/', hierarchy: { dirs: [], files: [] }, include_branches: true) 24 | files.each do |node| 25 | case node['node_type'] 26 | when 'branch' 27 | if include_branches 28 | get_remote_files_hierarchy(node['files'], root + node['name'] + '/', hierarchy: hierarchy) 29 | end 30 | when 'directory' 31 | hierarchy[:dirs] << "#{root}#{node['name']}" 32 | get_remote_files_hierarchy(node['files'], root + node['name'] + '/', hierarchy: hierarchy) 33 | when 'file' 34 | hierarchy[:files] << "#{root}#{node['name']}" 35 | end 36 | end 37 | 38 | return hierarchy 39 | end 40 | 41 | # Return branches in Crowdin project 42 | # 43 | # +files+ - basically, it's project files details from API method `project_info` 44 | # 45 | def get_branches(files) 46 | files.select { |node| node["node_type"] == "branch" }.map { |branch| branch["name"] } 47 | end 48 | 49 | # Return +hierarchy+ of local directories and files 50 | # 51 | # @params [Array] files a list of files in a local directory. 52 | # 53 | def get_local_files_hierarchy(files, hierarchy = { dirs: [], files: [] }) 54 | hierarchy[:files] = files 55 | 56 | dirs = files.inject([]) do |res, a| 57 | res << a.split('/').drop(1).inject([]) do |res, s| 58 | res << res.last.to_s + '/' + s 59 | end 60 | end 61 | dirs.map(&:pop) # delete last element from each array 62 | hierarchy[:dirs] = dirs.flatten.uniq 63 | 64 | return hierarchy 65 | end 66 | 67 | # @param [String] path relative path to file in Crowdin project 68 | # @param [String] export_pattern basically, is a file['translation'] from crowdin.yaml 69 | # @param [Hash] lang language information 70 | # @option lang [String] :name 71 | # @option lang [String] :crowdin_code 72 | # @option lang [String] :iso_639_1 73 | # @option lang [String] :iso_639_3 74 | # @option lang [String] :locale 75 | # 76 | def export_pattern_to_path(path, export_pattern, lang, languages_mapping = nil) 77 | original_path = File.dirname(path)[1..-1] 78 | original_file_name = File.basename(path) 79 | file_extension = File.extname(path)[1..-1] 80 | file_name = File.basename(path, File.extname(path)) 81 | 82 | pattern = { 83 | '%original_file_name%' => original_file_name, 84 | '%original_path%' => original_path, 85 | '%file_extension%' => file_extension, 86 | '%file_name%' => file_name, 87 | '%language%' => lang['name'], 88 | '%two_letters_code%' => lang['iso_639_1'], 89 | '%three_letters_code%' => lang['iso_639_3'], 90 | '%locale%' => lang['locale'], 91 | '%locale_with_underscore%' => lang['locale'].gsub('-', '_'), 92 | '%android_code%' => lang['android_code'], 93 | '%osx_code%' => lang['osx_code'], 94 | '%osx_locale%' => lang['osx_locale'], 95 | } 96 | 97 | placeholders = pattern.inject([]){ |memo, h| memo << h.first[/%(.*)%/, 1] } 98 | 99 | unless languages_mapping.nil? 100 | pattern = Hash[pattern.map { |placeholder, str| [ 101 | placeholder, 102 | (languages_mapping[placeholder[/%(.*)%/, 1]][lang['crowdin_code']] rescue nil) || str] 103 | }] 104 | end 105 | 106 | export_pattern.gsub(/%(#{placeholders.join('|')})%/, pattern) 107 | end 108 | 109 | # @param [String] path relative path to file in Crowdin project 110 | # @param [String] source basically, is a file['source'] from crowdin.yaml 111 | # @param [String] translation basically, is a file['translation'] from crowdin.yaml 112 | # 113 | def construct_export_pattern(path, source, translation) 114 | pattern_regexp = translate_pattern_to_regexp(source) 115 | if pattern_regexp.names.include?('double_asterisk') && path.match(pattern_regexp) 116 | double_asterisk = path.match(pattern_regexp)['double_asterisk'] 117 | translation = translation.sub('**', double_asterisk) 118 | end 119 | 120 | export_pattern = translation.split('/').reject(&:empty?).join('/') 121 | export_pattern.insert(0, '/') if translation.start_with?('/') 122 | 123 | return export_pattern 124 | end 125 | 126 | # This is a partial translation of the algorithm defined in fnmatch.py 127 | # https://github.com/python-git/python/blob/master/Lib/fnmatch.py 128 | # Provides a partial implementation of translate a glob +pat+ to a regular expression 129 | # 130 | # Patterns are Unix shell style: 131 | # * matches everything 132 | # ? matches any single character 133 | # [seq] matches any character in seq 134 | # [^seq] matches any char not in seq 135 | # 136 | # NOTE: 137 | # `**` surrounded by backslashes `/` in the +pat+ 138 | # `**` used only once in the +pat+ 139 | # 140 | def translate_pattern_to_regexp(pat) 141 | i = 0 142 | n = pat.size 143 | res = '' 144 | while i < n 145 | c = pat[i] 146 | i = i + 1 147 | if c == '*' 148 | j = i 149 | if j < n && pat[j] == '*' 150 | res[-1] = '(\/)?(?.*)?' 151 | i = j + 1 152 | else 153 | res = res + '.*' 154 | end 155 | elsif c == '?' 156 | res = res + '.' 157 | elsif c == '[' 158 | j = i 159 | # The following two statements check if the sequence we stumbled 160 | # upon is '[]' or '[^]' because those are not valid character 161 | # classes. 162 | if j < n && pat[j] == '^' 163 | j = j + 1 164 | end 165 | if j < n && pat[j] == ']' 166 | j = j + 1 167 | end 168 | # Look for the closing ']' right off the bat. If one is not found, 169 | # escape the opening '[' and continue. If it is found, process 170 | # he contents of '[...]'. 171 | while j < n && pat[j] != ']' 172 | j = j + 1 173 | end 174 | if j >= n 175 | res = res + '\\[' 176 | else 177 | stuff = pat[i...j].gsub('\\', '\\\\') 178 | i = j + 1 179 | #if stuff[0] == '!' 180 | # stuff = '^' + stuff[1..-1] 181 | #elsif stuff[0] == '^' 182 | # stuff = '\\' + stuff 183 | #end 184 | res = "#{res}[#{stuff}]" 185 | end 186 | else 187 | res = res + Regexp.escape(c) 188 | end 189 | end 190 | 191 | return Regexp.new(res + '$') 192 | end 193 | 194 | def get_invalid_placeholders(export_pattern) 195 | valid_placeholders = [ 196 | '%language%', 197 | '%two_letters_code%', 198 | '%three_letters_code%', 199 | '%locale%', 200 | '%locale_with_underscore%', 201 | '%android_code%', 202 | '%osx_code%', 203 | '%osx_locale%', 204 | '%original_file_name%', 205 | '%original_path%', 206 | '%file_extension%', 207 | '%file_name%', 208 | ] 209 | 210 | all_placeholders = export_pattern.scan(/%[a-z0-9_]*?%/) 211 | invalid_placeholders = all_placeholders - valid_placeholders 212 | end 213 | 214 | # Return a string representing that part of the directory tree that is common to all the files 215 | # 216 | # @params [Array] paths set of strings representing directory paths 217 | # 218 | def find_common_directory_path(paths) 219 | case paths.length 220 | when 0 221 | return '/' 222 | when 1 223 | return paths.first.split('/').slice(0...-1).join('/') 224 | else 225 | arr = paths.sort 226 | first = arr.first.split('/') 227 | last = arr.last.split('/') 228 | i = 0 229 | i += 1 while first[i] == last[i] && i <= first.length 230 | first.slice(0, i).join('/') 231 | end 232 | end 233 | 234 | # Extract compressed files +files_list+ in a ZIP archive +zipfile_name+ to +dest_path+ 235 | # 236 | # +files_list+ is a Hash of key-value pairs. Where key is a possible archive filename based on current project configuration 237 | # and value is the expanded filename 238 | # 239 | def unzip_file_with_translations(zipfile_name, dest_path, files_list, ignore_match, ignored_paths = []) 240 | # overwrite files if they already exist inside of the extracted path 241 | Zip.on_exists_proc = true 242 | 243 | # files that exists in archive and doesn't match current project configuration 244 | unmatched_files = [] 245 | 246 | Zip::File.open(zipfile_name) do |zip_file| 247 | zip_file.restore_permissions = false 248 | zip_file.select(&:file?).each do |zip_entry| 249 | next if zip_entry.name.start_with?(*ignored_paths) 250 | 251 | filename = zip_entry.name 252 | if @branch_name && @base_path_contains_branch_subfolders 253 | # strip branch from filename 254 | filename = zip_entry.name.sub(File.join(@branch_name, '/'), '') 255 | end 256 | 257 | file = files_list[filename] 258 | if file 259 | fpath = File.join(dest_path, file) 260 | FileUtils.mkdir_p(File.dirname(fpath)) 261 | puts "Extracting: `#{file}'" 262 | zip_file.extract(zip_entry, fpath) 263 | else 264 | unmatched_files << zip_entry 265 | end 266 | end 267 | end 268 | 269 | unless unmatched_files.empty? || ignore_match 270 | puts "Warning: Downloaded translations do not match current project configuration. The following files will be omitted:" 271 | unmatched_files.each { |file| puts " - `#{file}'" } 272 | end 273 | end 274 | 275 | # Build a Hash tree from Array of +filenames* 276 | # 277 | def build_hash_tree(filenames) 278 | files_tree = filenames.inject({}) { |h, i| t = h; i.split("/").each { |n| t[n] ||= {}; t = t[n] }; h } 279 | end 280 | 281 | # Box-drawing character - https://en.wikipedia.org/wiki/Box-drawing_character 282 | # ├ └ ─ │ 283 | # 284 | def display_tree(files_tree, level = -2, branches = []) 285 | level += 1 286 | 287 | files_tree.each_with_index do |(key, val), index| 288 | if val.empty? # this is a file 289 | result = branches.take(level).inject('') { |s, i| s << (i == 1 ? '│' : '').ljust(4) } 290 | 291 | if index == files_tree.length - 1 292 | # this is a last element 293 | result << '└' + '──' + ' ' + key 294 | else 295 | result << '├' + '──' + ' ' + key 296 | end 297 | 298 | puts result 299 | else # this is directory 300 | if key == '' # root directory 301 | result = '.' 302 | else 303 | result = branches.take(level).inject('') { |s, i| s << (i == 1 ? '│' : '').ljust(4) } 304 | if index == files_tree.length - 1 305 | # this is a last element 306 | result << '└' + '──' + ' ' + key 307 | 308 | branches[level] = 0 309 | else 310 | result << '├' + '──' + ' ' + key 311 | 312 | branches[level] = 1 313 | end 314 | end 315 | puts result 316 | 317 | # recursion \(^_^)/ 318 | display_tree(val, level, branches) 319 | end 320 | end 321 | end 322 | 323 | include GLI::App 324 | 325 | version Crowdin::CLI::VERSION 326 | 327 | subcommand_option_handling :normal 328 | 329 | program_desc I18n.t('app.desc') 330 | program_long_desc I18n.t('app.long_desc') 331 | sort_help :manually # help commands are ordered in the order declared 332 | wrap_help_text :to_terminal 333 | 334 | desc I18n.t('app.switches.verbose.desc') 335 | switch [:v, :verbose], negatable: false 336 | 337 | desc I18n.t('app.flags.config.desc') 338 | default_value File.join(Dir.pwd, 'crowdin.yaml') 339 | arg_name '' 340 | flag [:c, :config] 341 | 342 | desc I18n.t('app.flags.identity.desc') 343 | default_value File.join(Dir.home, '.crowdin.yaml') 344 | arg_name '' 345 | flag [:identity] 346 | 347 | desc I18n.t('app.commands.upload.desc') 348 | long_desc I18n.t('app.commands.upload.long_desc') 349 | command :upload do |c| 350 | 351 | c.desc I18n.t('app.commands.upload.commands.sources.desc') 352 | c.long_desc I18n.t('app.commands.upload.commands.sources.long_desc') 353 | c.command :sources do |c| 354 | 355 | c.desc I18n.t('app.commands.upload.commands.sources.switches.auto_update.desc') 356 | c.default_value true 357 | c.switch ['auto-update'] 358 | 359 | c.desc I18n.t('app.flags.branch.desc') 360 | c.arg_name 'branch_name' 361 | c.flag [:b, :branch] 362 | 363 | c.action do |global_options, options, args| 364 | source_language = @project_info['details']['source_language']['code'] 365 | 366 | # Crowdin supported languages list 367 | supported_languages = @crowdin.supported_languages 368 | source_language = supported_languages.find { |lang| lang['crowdin_code'] == source_language } 369 | 370 | if @branch_name 371 | branch = @project_info['files'].find { |h| h['node_type'] == 'branch' && h['name'] == @branch_name } 372 | 373 | if branch 374 | branch_files = [] << branch 375 | else 376 | print "Creating a new branch `#{@branch_name}'" 377 | 378 | @crowdin.add_directory(@branch_name, is_branch: '1') 379 | 380 | puts "\rCreating a new branch `#{@branch_name}' - OK" 381 | branch_files = [] 382 | end 383 | 384 | remote_project_tree = get_remote_files_hierarchy(branch_files) 385 | 386 | # Remove branch directory from a directory path string 387 | remote_project_tree[:dirs].map! { |p| p.split('/')[2..-1].unshift('').join('/') } 388 | remote_project_tree[:files].map! { |p| p.split('/')[2..-1].unshift('').join('/') } 389 | else 390 | # INFO it also includes all branches 391 | remote_project_tree = get_remote_files_hierarchy(@project_info['files']) 392 | end 393 | 394 | local_files = [] 395 | dest_files = [] 396 | 397 | @config['files'].each do |file| 398 | get_invalid_placeholders(file['translation']).each do |placeholder| 399 | puts "Warning: #{placeholder} is not valid variable supported by Crowdin. See http://crowdin.com/page/cli-tool#configuration-file for more details." 400 | end 401 | 402 | ignores = file['ignore'] || [] 403 | 404 | if File.exist?(File.join(@base_path, file['source'])) 405 | dest = file['dest'] || file['source'] 406 | type = file['type'] 407 | 408 | dest_files << dest 409 | 410 | local_file = { dest: dest, source: File.join(@base_path, file['source']), export_pattern: file['translation'], type: type } 411 | 412 | @allowed_options.each do |option| 413 | local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) 414 | end 415 | 416 | local_files << local_file 417 | else 418 | Find.find(@base_path) do |source_path| 419 | dest = source_path.sub(/\A#{Regexp.escape(@base_path)}/, '') # relative path in Crowdin 420 | type = file['type'] 421 | 422 | if File.directory?(source_path) 423 | if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 424 | Find.prune # Don't look any further into this directory 425 | else 426 | next 427 | end 428 | elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) 429 | next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 430 | 431 | dest_files << dest 432 | 433 | export_pattern = construct_export_pattern(dest, file['source'], file['translation']) 434 | 435 | local_file = { dest: dest, source: source_path, export_pattern: export_pattern, type: type } 436 | 437 | @allowed_options.each do |option| 438 | local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) 439 | end 440 | 441 | local_files << local_file 442 | end 443 | end # Find 444 | 445 | end # if File.exist? 446 | end # @config['files'] 447 | 448 | if dest_files.empty? 449 | exit_now! <<-EOS.strip_heredoc 450 | No source files to upload. 451 | Check your configuration file to ensure that they contain valid directives. 452 | See http://crowdin.com/page/cli-tool#configuration-file for more details. 453 | EOS 454 | end 455 | 456 | common_dir = @preserve_hierarchy ? '' : find_common_directory_path(dest_files) 457 | 458 | local_project_tree = get_local_files_hierarchy(local_files.collect { |h| h[:dest].sub(common_dir, '') }) 459 | 460 | local_files.each { |file| file[:dest].sub!(common_dir, '') } 461 | 462 | # Create directory tree 463 | # 464 | create_dirs = local_project_tree[:dirs] - remote_project_tree[:dirs] 465 | create_dirs.each do |dir| 466 | # FIXME if directory path starts with / Crowdin returns an error: 467 | # 17: Specified directory was not found 468 | dir.slice!(0) if dir.start_with?('/') 469 | 470 | print "Creating directory `#{dir}'" 471 | 472 | @crowdin.add_directory(dir, branch: @branch_name) 473 | 474 | puts "\rCreating directory `#{dir}' - OK" 475 | end 476 | 477 | if options['auto-update'] 478 | # Update existing files in Crowdin project 479 | # 480 | # array containing elements common to the two arrays 481 | update_files = local_project_tree[:files] & remote_project_tree[:files] 482 | files_for_upload = local_files.select { |file| update_files.include?(file[:dest]) } 483 | 484 | thread_count = 12 # tweak this number for maximum performance. 485 | resps = [] 486 | mutex = Mutex.new 487 | 488 | thread_count.times.map { 489 | Thread.new(files_for_upload, resps) do |files_for_upload, resps| 490 | while file = mutex.synchronize { files_for_upload.pop } 491 | file[:dest].slice!(0) if file[:dest].start_with?('/') 492 | 493 | params = {} 494 | params[:branch] = @branch_name if @branch_name 495 | 496 | @allowed_options.each do |option| 497 | params[option.to_sym] = file.delete(option.to_sym) 498 | end 499 | 500 | resp = @crowdin.update_file([] << file, params) 501 | 502 | case resp['files'].first[1] 503 | when 'skipped' 504 | puts "Updating source file `#{file[:dest]}' - Skipped" 505 | when 'updated' 506 | puts "Updating source file `#{file[:dest]}' - OK" 507 | end 508 | mutex.synchronize { resps << resp } 509 | end 510 | end 511 | }.each(&:join) 512 | end 513 | 514 | # Add new files to Crowdin project 515 | # 516 | add_files = local_project_tree[:files] - remote_project_tree[:files] 517 | files_for_add = local_files.select { |file| add_files.include?(file[:dest]) } 518 | 519 | thread_count = 12 520 | resps = [] 521 | mutex = Mutex.new 522 | 523 | thread_count.times.map { 524 | Thread.new(files_for_add, resps) do |files_for_add, resps| 525 | while file = mutex.synchronize { files_for_add.pop } 526 | # If file path starts with / Crowdin returns error 527 | # 17: Specified directory was not found 528 | # make sure that file[:dest] not start with '/' 529 | file[:dest].slice!(0) if file[:dest].start_with?('/') 530 | 531 | params = {} 532 | params[:type] = file.delete(:type) if file[:type] 533 | params[:branch] = @branch_name if @branch_name 534 | 535 | @allowed_options.each do |option| 536 | params[option.to_sym] = file.delete(option.to_sym) 537 | end 538 | 539 | resp = @crowdin.add_file([] << file, params) 540 | 541 | puts "Uploading source file `#{file[:dest]}' - OK" 542 | 543 | mutex.synchronize { resps << resp } 544 | end 545 | end 546 | }.each(&:join) 547 | end # action 548 | end # upload sources 549 | 550 | c.desc I18n.t('app.commands.upload.commands.translations.desc') 551 | c.long_desc I18n.t('app.commands.upload.commands.translations.long_desc') 552 | c.command :translations do |c| 553 | 554 | c.desc I18n.t('app.commands.upload.commands.translations.flags.language.desc') 555 | c.default_value 'all' 556 | c.arg_name 'crowdin_language_code' 557 | c.flag [:l, :language] 558 | 559 | c.desc I18n.t('app.flags.branch.desc') 560 | c.arg_name 'branch_name' 561 | c.flag [:b, :branch] 562 | 563 | c.desc I18n.t('app.commands.upload.commands.translations.switches.import_duplicates.desc') 564 | c.switch ['import-duplicates'] 565 | 566 | c.desc I18n.t('app.commands.upload.commands.translations.switches.import_eq_suggestions.desc') 567 | c.switch ['import-eq-suggestions'] 568 | 569 | c.desc I18n.t('app.commands.upload.commands.translations.switches.auto_approve_imported.desc') 570 | c.switch ['auto-approve-imported'] 571 | 572 | c.action do |global_options, options, args| 573 | params = {} 574 | params[:import_duplicates] = options['import-duplicates'] ? 1 : 0 575 | params[:import_eq_suggestions] = options['import-eq-suggestions'] ? 1 : 0 576 | params[:auto_approve_imported] = options['auto-approve-imported'] ? 1 : 0 577 | 578 | language = options[:language] 579 | 580 | if @branch_name 581 | branch = @project_info['files'].find { |h| h['node_type'] == 'branch' && h['name'] == @branch_name } 582 | 583 | if branch 584 | params[:branch] = @branch_name 585 | branch_files = [] << branch 586 | else 587 | exit_now!("branch '#{@branch_name}' doesn't exist in the project") 588 | end 589 | 590 | remote_project_tree = get_remote_files_hierarchy(branch_files) 591 | 592 | # Remove branch directory from a directory path string 593 | remote_project_tree[:dirs].map! { |p| p.split('/')[2..-1].unshift('').join('/') } 594 | remote_project_tree[:files].map! { |p| p.split('/')[2..-1].unshift('').join('/') } 595 | else 596 | remote_project_tree = get_remote_files_hierarchy(@project_info['files']) 597 | end 598 | 599 | project_languages = @project_info['languages'].collect { |h| h['code'] } 600 | 601 | if language == 'all' 602 | # do nothing 603 | else 604 | if project_languages.include?(language) 605 | project_languages = [] << language 606 | else 607 | exit_now!("language '#{language}' doesn't exist in the project") 608 | end 609 | end 610 | 611 | supported_languages = @crowdin.supported_languages 612 | 613 | source_language = @project_info['details']['source_language']['code'] 614 | source_language = supported_languages.find { |lang| lang['crowdin_code'] == source_language } 615 | 616 | translation_languages = supported_languages.select { |lang| project_languages.include?(lang['crowdin_code']) } 617 | 618 | translated_files = [] 619 | dest_files = [] 620 | 621 | @config['files'].each do |file| 622 | get_invalid_placeholders(file['translation']).each do |placeholder| 623 | puts "Warning: #{placeholder} is not valid variable supported by Crowdin. See http://crowdin.com/page/cli-tool#configuration-file for more details." 624 | end 625 | 626 | ignores = file['ignore'] || [] 627 | 628 | languages_mapping = file['languages_mapping'] 629 | 630 | # CSV files only (default: false) 631 | multilingual_spreadsheet = file['multilingual_spreadsheet'] || false 632 | 633 | if multilingual_spreadsheet 634 | file_translation_languages = [] << source_language 635 | else 636 | file_translation_languages = translation_languages 637 | end 638 | 639 | if File.exist?(File.join(@base_path, file['source'])) 640 | dest = file['dest'] || file['source'] 641 | dest.sub!(/\A#{Regexp.escape(@base_path)}/, '') 642 | dest_files << dest 643 | 644 | file_translation_languages.each do |lang| 645 | source = export_pattern_to_path(dest, file['translation'], lang, languages_mapping) 646 | translated_files << { crowdin_code: lang['crowdin_code'], source: File.join(@base_path, source), dest: dest } 647 | end 648 | else 649 | Find.find(@base_path) do |source_path| 650 | dest = source_path.sub(/\A#{Regexp.escape(@base_path)}/, '') # relative path in Crowdin 651 | 652 | if File.directory?(source_path) 653 | if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 654 | Find.prune # Don't look any further into this directory 655 | else 656 | next 657 | end 658 | elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) 659 | next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 660 | 661 | dest_files << dest 662 | 663 | export_pattern = construct_export_pattern(dest, file['source'], file['translation']) 664 | 665 | file_translation_languages.each do |lang| 666 | source = export_pattern_to_path(dest, export_pattern, lang, languages_mapping) 667 | translated_files << { crowdin_code: lang['crowdin_code'], source: File.join(@base_path, source), dest: dest } 668 | end 669 | end 670 | end # Find 671 | end # if 672 | end # @config['files'] 673 | 674 | if dest_files.empty? 675 | exit_now! <<-EOS.strip_heredoc 676 | No translation files to upload. 677 | Check your configuration file to ensure that they contain valid directives. 678 | See https://crowdin.com/page/cli-tool#configuration-file for more details. 679 | EOS 680 | end 681 | 682 | common_dir = @preserve_hierarchy ? '' : find_common_directory_path(dest_files) 683 | 684 | thread_count = 12 685 | resps = [] 686 | mutex = Mutex.new 687 | 688 | thread_count.times.map { 689 | Thread.new(translated_files, resps) do |translated_files, resps| 690 | while file = mutex.synchronize { translated_files.pop } 691 | file[:dest] = file[:dest].sub(common_dir, '') 692 | source_file = file[:source].sub(/\A#{Regexp.escape(@base_path)}/, '') 693 | language = file.delete(:crowdin_code) 694 | 695 | if remote_project_tree[:files].include?(file[:dest]) 696 | if File.exist?(file[:source]) 697 | # If file path starts with / Crowdin returns error 698 | # 17: Specified directory was not found 699 | # make sure that file[:dest] not start with '/' 700 | file[:dest].slice!(0) if file[:dest].start_with?('/') 701 | 702 | resp = @crowdin.upload_translation([] << file, language, params) 703 | 704 | case resp['files'].first[1] 705 | when 'skipped' 706 | puts "Uploading translation file `#{source_file}' - Skipped" 707 | when 'uploaded' 708 | puts "Uploading translation file `#{source_file}' - OK" 709 | when 'not_allowed' 710 | puts "Uploading translation file `#{source_file}' - is not possible" 711 | end 712 | else 713 | puts "Warning: Local file `#{file[:source]}' does not exist" 714 | end 715 | else 716 | # if source file does not exist, don't upload translations 717 | puts "Warning: Skip `#{source_file}'. Translation can not be uploaded for a non-existent source file `#{file[:dest]}'. Please upload sources first." 718 | end 719 | mutex.synchronize { resps << resp } 720 | end 721 | end 722 | }.each(&:join) 723 | end # action 724 | end # upload translations 725 | 726 | end 727 | 728 | desc I18n.t('app.commands.download.desc') 729 | #arg_name 'Describe arguments to download here' 730 | command :download do |c| 731 | 732 | c.desc I18n.t('app.commands.download.flags.language.desc') 733 | c.long_desc I18n.t('app.commands.download.flags.language.long_desc') 734 | c.arg_name 'language_code' 735 | c.flag [:l, :language], default_value: 'all' 736 | 737 | c.desc I18n.t('app.flags.branch.desc') 738 | c.arg_name 'branch_name' 739 | c.flag [:b, :branch] 740 | 741 | c.desc I18n.t('app.commands.download.switches.ignore_match.desc') 742 | c.switch ['ignore-match'], negatable: false 743 | 744 | c.desc I18n.t('app.commands.download.switches.include_unchanged.desc') 745 | c.switch ['include-unchanged'], negatable: false 746 | 747 | c.action do |global_options, options, args| 748 | branches = get_branches(@project_info['files']) 749 | 750 | if @branch_name && !branches.include?(@branch_name) 751 | exit_now! <<~HEREDOC 752 | path `#{@branch_name}' did not match any branch known to Crowdin 753 | HEREDOC 754 | end 755 | 756 | language = options[:language] 757 | 758 | supported_languages = @crowdin.supported_languages 759 | 760 | project_languages = @project_info['languages'].collect { |h| h['code'] } 761 | 762 | if language == 'all' 763 | if @jipt_language 764 | if supported_languages.find { |lang| lang['crowdin_code'] == @jipt_language } 765 | project_languages << @jipt_language # crowdin_language_code 766 | else 767 | exit_now!("invalid jipt language `#{@jipt_language}`") 768 | end 769 | end 770 | else 771 | if project_languages.include?(language) 772 | project_languages = [] << language 773 | else 774 | exit_now!("language '#{language}' doesn't exist in a project") 775 | end 776 | end 777 | 778 | params = {} 779 | params[:branch] = @branch_name if @branch_name 780 | 781 | unless options['include-unchanged'] 782 | # use export API method before to download the most recent translations 783 | print 'Building ZIP archive with the latest translations ' 784 | export_translations = @crowdin.export_translations(params) 785 | if export_translations['success'] 786 | if export_translations['success']['status'] == 'built' 787 | puts "- OK" 788 | elsif export_translations['success']['status'] == 'skipped' 789 | puts "- Skipped" 790 | puts "Warning: Export was skipped. Please note that this method can be invoked only once per 30 minutes." 791 | end 792 | end 793 | end 794 | 795 | source_language = @project_info['details']['source_language']['code'] 796 | source_language = supported_languages.find { |lang| lang['crowdin_code'] == source_language } 797 | 798 | translation_languages = supported_languages.select { |lang| project_languages.include?(lang['crowdin_code']) } 799 | 800 | # keys is all possible files in .ZIP archive 801 | # values is resulted local files 802 | downloadable_files_hash = {} 803 | 804 | @config['files'].each do |file| 805 | languages_mapping = file['languages_mapping'] # Hash or NilClass 806 | 807 | ignores = file['ignore'] || [] 808 | 809 | # CSV files only (default: false) 810 | multilingual_spreadsheet = file['multilingual_spreadsheet'] || false 811 | 812 | if multilingual_spreadsheet 813 | file_translation_languages = [] << source_language 814 | else 815 | file_translation_languages = translation_languages 816 | end 817 | 818 | if File.exist?(File.join(@base_path, file['source'])) 819 | dest = file['source'].sub(/\A#{Regexp.escape(@base_path)}/, '') 820 | 821 | file_translation_languages.each do |lang| 822 | zipped_file = export_pattern_to_path(dest, file['translation'], lang) 823 | zipped_file.sub!(/^\//, '') 824 | local_file = export_pattern_to_path(dest, file['translation'], lang, languages_mapping) 825 | downloadable_files_hash[zipped_file] = local_file 826 | end 827 | 828 | else 829 | Find.find(@base_path) do |source_path| 830 | dest = source_path.sub(/\A#{Regexp.escape(@base_path)}/, '') # relative path in Crowdin 831 | 832 | if File.directory?(source_path) 833 | if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 834 | Find.prune # Don't look any further into this directory 835 | else 836 | next 837 | end 838 | elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) 839 | next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 840 | 841 | export_pattern = construct_export_pattern(dest, file['source'], file['translation']) 842 | 843 | file_translation_languages.each do |lang| 844 | zipped_file = export_pattern_to_path(dest, export_pattern, lang) 845 | zipped_file.sub!(/^\//, '') 846 | local_file = export_pattern_to_path(dest, export_pattern, lang, languages_mapping) 847 | downloadable_files_hash[zipped_file] = local_file 848 | end 849 | 850 | end 851 | end # Find 852 | end # if 853 | end # @config['files'] 854 | 855 | tempfile = Tempfile.new(language) 856 | zipfile_name = tempfile.path 857 | 858 | params = {} 859 | params[:output] = zipfile_name 860 | params[:branch] = @branch_name if @branch_name 861 | 862 | begin 863 | @crowdin.download_translation(language, params) 864 | 865 | unzip_file_with_translations(zipfile_name, @base_path, downloadable_files_hash, options['ignore-match'], branches) 866 | ensure 867 | tempfile.close 868 | tempfile.unlink # delete the tempfile 869 | end 870 | end # action 871 | end # download 872 | 873 | desc I18n.t('app.commands.list.desc') 874 | long_desc I18n.t('app.commands.list.long_desc') 875 | command :list do |ls_cmd| 876 | 877 | ls_cmd.desc I18n.t('app.commands.list.commands.project.desc') 878 | ls_cmd.command :project do |prj_cmd| 879 | prj_cmd.desc I18n.t('app.commands.list.switches.tree.desc') 880 | prj_cmd.switch ['tree'], negatable: false 881 | 882 | prj_cmd.desc I18n.t('app.flags.branch.desc') 883 | prj_cmd.arg_name 'branch_name' 884 | prj_cmd.flag [:b, :branch] 885 | 886 | prj_cmd.action do |global_options, options, args| 887 | branches = get_branches(@project_info['files']) 888 | if @branch_name 889 | branch = @project_info['files'].find { |h| h['node_type'] == 'branch' && h['name'] == @branch_name } 890 | 891 | unless branch 892 | exit_now! <<~HEREDOC 893 | path `#{@branch_name}' did not match any branch known to Crowdin 894 | HEREDOC 895 | end 896 | 897 | branch_files = [] << branch 898 | 899 | remote_project_tree = get_remote_files_hierarchy(branch_files) 900 | else 901 | remote_project_tree = get_remote_files_hierarchy(@project_info['files'], include_branches: false) 902 | end 903 | 904 | if options[:tree] 905 | tree = build_hash_tree(remote_project_tree[:files]) 906 | display_tree(tree) 907 | else 908 | puts remote_project_tree[:files] 909 | end 910 | end 911 | end 912 | 913 | ls_cmd.desc I18n.t('app.commands.list.commands.branches.desc') 914 | ls_cmd.command :branches do |branches_cmd| 915 | branches_cmd.action do |global_options, options, args| 916 | branches = get_branches(@project_info['files']) 917 | branches.each do |branch| 918 | puts "- #{branch}" 919 | end 920 | end 921 | end 922 | 923 | 924 | ls_cmd.desc I18n.t('app.commands.list.commands.sources.desc') 925 | ls_cmd.command :sources do |src_cmd| 926 | src_cmd.desc I18n.t('app.commands.list.switches.tree.desc') 927 | src_cmd.switch ['tree'], negatable: false 928 | 929 | src_cmd.action do |global_options, options, args| 930 | local_files = [] 931 | dest_files = [] 932 | 933 | @config['files'].each do |file| 934 | get_invalid_placeholders(file['translation']).each do |placeholder| 935 | puts "Warning: #{placeholder} is not valid variable supported by Crowdin. See http://crowdin.com/page/cli-tool#configuration-file for more details." 936 | end 937 | 938 | ignores = file['ignore'] || [] 939 | 940 | if File.exist?(File.join(@base_path, file['source'])) 941 | dest = file['source'] 942 | dest_files << dest 943 | 944 | local_file = { dest: dest, source: File.join(@base_path, file['source']), export_pattern: file['translation'] } 945 | 946 | @allowed_options.each do |option| 947 | local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) 948 | end 949 | 950 | local_files << local_file 951 | else 952 | Find.find(@base_path) do |source_path| 953 | dest = source_path.sub(/\A#{Regexp.escape(@base_path)}/, '') # relative path in Crowdin 954 | 955 | if File.directory?(source_path) 956 | if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 957 | Find.prune # Don't look any further into this directory 958 | else 959 | next 960 | end 961 | elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) 962 | next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 963 | 964 | dest_files << dest 965 | 966 | export_pattern = construct_export_pattern(dest, file['source'], file['translation']) 967 | 968 | local_file = { dest: dest, source: source_path, export_pattern: export_pattern } 969 | 970 | @allowed_options.each do |option| 971 | local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) 972 | end 973 | 974 | local_files << local_file 975 | end 976 | end # Find 977 | 978 | end # if File.exist? 979 | end # @config['files'] 980 | 981 | common_dir = @preserve_hierarchy ? '' : find_common_directory_path(dest_files) 982 | 983 | local_project_tree = get_local_files_hierarchy(local_files.collect { |h| h[:dest].sub(common_dir, '') }) 984 | 985 | if options[:tree] 986 | tree = build_hash_tree(local_project_tree[:files]) 987 | display_tree(tree) 988 | else 989 | puts local_project_tree[:files] 990 | end 991 | end 992 | end # list sources 993 | 994 | ls_cmd.desc I18n.t('app.commands.list.commands.translations.desc') 995 | ls_cmd.command :translations do |trans_cmd| 996 | trans_cmd.desc I18n.t('app.commands.list.switches.tree.desc') 997 | trans_cmd.switch ['tree'], negatable: false 998 | 999 | trans_cmd.action do |global_options, options, args| 1000 | project_languages = @project_info['languages'].collect { |h| h['code'] } 1001 | 1002 | supported_languages = @crowdin.supported_languages 1003 | translation_languages = supported_languages.select { |lang| project_languages.include?(lang['crowdin_code']) } 1004 | 1005 | translation_files = [] 1006 | @config['files'].each do |file| 1007 | languages_mapping = file['languages_mapping'] # Hash or NilClass 1008 | 1009 | ignores = file['ignore'] || [] 1010 | 1011 | if File.exist?(File.join(@base_path, file['source'])) 1012 | dest = file['source'].sub(/\A#{Regexp.escape(@base_path)}/, '') 1013 | 1014 | translation_languages.each do |lang| 1015 | local_file = export_pattern_to_path(dest, file['translation'], lang, languages_mapping) 1016 | translation_files << local_file 1017 | end 1018 | 1019 | else 1020 | Find.find(@base_path) do |source_path| 1021 | dest = source_path.sub(/\A#{Regexp.escape(@base_path)}/, '') # relative path in Crowdin 1022 | 1023 | if File.directory?(source_path) 1024 | if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 1025 | Find.prune # Don't look any further into this directory 1026 | else 1027 | next 1028 | end 1029 | elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) 1030 | next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } 1031 | 1032 | export_pattern = construct_export_pattern(dest, file['source'], file['translation']) 1033 | 1034 | translation_languages.each do |lang| 1035 | local_file = export_pattern_to_path(dest, export_pattern, lang, languages_mapping) 1036 | translation_files << local_file 1037 | end 1038 | 1039 | end 1040 | end # Find 1041 | end # if 1042 | end # @config['files'] 1043 | 1044 | if options[:tree] 1045 | tree = build_hash_tree(translation_files) 1046 | display_tree(tree) 1047 | else 1048 | puts translation_files 1049 | end 1050 | 1051 | end 1052 | end # list translations 1053 | 1054 | #ls_cmd.default_command :project 1055 | end # list 1056 | 1057 | pre do |globals, command, options, args| 1058 | # Pre logic here 1059 | # Return true to proceed; false to abourt and not call the 1060 | # chosen command 1061 | # Use skips_pre before a command to skip this block 1062 | # on that command only 1063 | 1064 | # TODO: check for validity options 1065 | @allowed_options = [ 1066 | # *.properties files only 1067 | 'escape_quotes', 1068 | # CSV files only 1069 | 'scheme', 1070 | 'first_line_contains_header', 1071 | 'update_option', 1072 | # XML files only 1073 | 'translate_content', 1074 | 'translate_attributes', 1075 | 'content_segmentation', 1076 | 'translatable_elements', 1077 | ] 1078 | 1079 | unless File.exist?(globals[:config]) 1080 | exit_now! <<~HEREDOC 1081 | Can't find configuration file (default `crowdin.yaml'). 1082 | Type `crowdin-cli help` to know how to specify custom configuration file 1083 | 1084 | See http://crowdin.com/page/cli-tool#configuration-file for more details 1085 | HEREDOC 1086 | end 1087 | 1088 | # load project-specific configuration 1089 | # 1090 | begin 1091 | # undocumented feature 1092 | # you can use ERB in your config file, e.g. 1093 | # api_key: <%= # ruby code ... %> 1094 | # 1095 | @config = YAML.load(ERB.new(File.read(globals[:config])).result) || {} 1096 | rescue Psych::SyntaxError => err 1097 | exit_now! <<~HEREDOC 1098 | Could not parse YAML: #{err.message} 1099 | 1100 | We were unable to successfully parse the crowdin.yaml file that you provided - most likely it is not well-formatted YAML. 1101 | Please check whether your crowdin.yaml is valid YAML - you can use the http://yamllint.com/ validator to do this - and make any necessary changes to fix it. 1102 | HEREDOC 1103 | end 1104 | 1105 | # try to load the API credentials from an environment variable, e.g. 1106 | # 1107 | # project_identifier_env: 'CROWDIN_PROJECT_ID' 1108 | # api_key_env: 'CROWDIN_API_KEY' 1109 | # base_path_env: 'CROWDIN_BASE_PATH' 1110 | # 1111 | ['api_key', 'project_identifier', 'base_path'].each do |key| 1112 | if @config["#{key}_env"] 1113 | unless @config[key] # project credentials have a higher priority if they are specified in the config 1114 | @config[key] = ENV[@config["#{key}_env"]] 1115 | end 1116 | end 1117 | end 1118 | 1119 | # user credentials in the user-specific config file have a higher priority than project-specific 1120 | # 1121 | if File.exist?(globals[:identity]) 1122 | identity = YAML.load(ERB.new(File.read(globals[:identity])).result) || {} 1123 | ['api_key', 'project_identifier', 'base_path'].each do |key| 1124 | @config[key] = identity[key] if identity[key] 1125 | end 1126 | end 1127 | 1128 | ['api_key', 'project_identifier'].each do |key| 1129 | unless @config[key] 1130 | exit_now! <<~HEREDOC 1131 | Configuration file misses required option `#{key}` 1132 | 1133 | See http://crowdin.com/page/cli-tool#configuration-file for more details 1134 | HEREDOC 1135 | end 1136 | end 1137 | 1138 | unless @config['files'] 1139 | exit_now! <<~HEREDOC 1140 | Configuration file misses required section `files` 1141 | 1142 | See https://crowdin.com/page/cli-tool#configuration-file for more details 1143 | HEREDOC 1144 | end 1145 | 1146 | @config['files'].each do |file| 1147 | unless file['source'] 1148 | exit_now! <<~HEREDOC 1149 | Files section misses required parameter `source` 1150 | 1151 | See https://crowdin.com/page/cli-tool#configuration-file for more details 1152 | HEREDOC 1153 | end 1154 | 1155 | unless file['translation'] 1156 | exit_now! <<~HEREDOC 1157 | Files section misses required parameter `translation` 1158 | 1159 | See https://crowdin.com/page/cli-tool#configuration-file for more details 1160 | HEREDOC 1161 | end 1162 | 1163 | file['source'] = '/' + file['source'] unless file['source'].start_with?('/') 1164 | #file['translation'] = '/' + file['translation'] unless file['translation'].start_with?('/') 1165 | 1166 | if file['source'].include?('**') 1167 | if file['source'].scan('**').size > 1 1168 | exit_now! <<~HEREDOC 1169 | Source pattern `#{file['source']}` is not valid. The mask `**` can be used only once in the source pattern. 1170 | HEREDOC 1171 | elsif file['source'].scan('**').size == 1 && !file['source'].match(/\/\*\*\//) 1172 | exit_now! <<~HEREDOC 1173 | Source pattern `#{file['source']}` is not valid. The mask `**` must be surrounded by slashes `/` in the source pattern. 1174 | HEREDOC 1175 | end 1176 | else 1177 | if file['translation'].include?('**') 1178 | exit_now! <<~HEREDOC 1179 | Translation pattern `#{file['translation']}` is not valid. The mask `**` can't be used. 1180 | When using `**` in 'translation' pattern it will always contain sub-path from 'source' for certain file. 1181 | HEREDOC 1182 | end 1183 | end 1184 | 1185 | end #@config['files'] 1186 | 1187 | if @config['base_path'] 1188 | @base_path = @config['base_path'] 1189 | else 1190 | @base_path = Dir.pwd 1191 | puts <<~HEREDOC 1192 | Warning: Configuration file misses parameter `base_path` that defines your project root directory. Using `#{@base_path}` as a root directory. 1193 | HEREDOC 1194 | end 1195 | 1196 | @base_path_contains_branch_subfolders = false 1197 | if @config['base_path_contains_branch_subfolders'] 1198 | @base_path_contains_branch_subfolders = case @config['base_path_contains_branch_subfolders'] 1199 | when true 1200 | true 1201 | when false 1202 | false 1203 | else 1204 | exit_now! <<~HEREDOC 1205 | Parameter `base_path_contains_branch_subfolders` allows values of true or false. 1206 | HEREDOC 1207 | end 1208 | end 1209 | 1210 | @branch_name = options[:branch] || nil 1211 | if @branch_name && @base_path_contains_branch_subfolders 1212 | @base_path = File.join(@base_path, @branch_name) 1213 | end 1214 | 1215 | unless Dir.exist?(@base_path) 1216 | exit_now! <<~HEREDOC 1217 | No such directory `#{@base_path}`. Please make sure that the `base_path` is properly set. 1218 | HEREDOC 1219 | end 1220 | 1221 | @preserve_hierarchy = false 1222 | if @config['preserve_hierarchy'] 1223 | @preserve_hierarchy = case @config['preserve_hierarchy'] 1224 | when true 1225 | true 1226 | when false 1227 | false 1228 | else 1229 | exit_now! <<~HEREDOC 1230 | Parameter `preserve_hierarchy` allows values of true or false. 1231 | HEREDOC 1232 | end 1233 | end 1234 | 1235 | @jipt_language = nil 1236 | if @config['jipt_language'] 1237 | @jipt_language = @config['jipt_language'] 1238 | end 1239 | 1240 | Crowdin::API.log = Logger.new($stderr) if globals[:verbose] 1241 | 1242 | base_url = @config['base_url'] || 'https://api.crowdin.com' 1243 | @crowdin = Crowdin::API.new(api_key: @config['api_key'], project_id: @config['project_identifier'], base_url: base_url) 1244 | 1245 | begin 1246 | @project_info = @crowdin.project_info 1247 | rescue Crowdin::API::Errors::Error => err 1248 | raise err 1249 | rescue 1250 | exit_now!("Seems Crowdin server API URL is not valid. Please check the `base_url` parameter in the configuration file.") 1251 | end 1252 | 1253 | #puts "Executing #{command.name}" if globals[:verbose] 1254 | true 1255 | end 1256 | 1257 | post do |globals, command, options, args| 1258 | # Post logic here 1259 | # Use skips_post before a command to skip this 1260 | # block on that command only 1261 | # puts "Executed #{command.name}" if globals[:verbose] 1262 | end 1263 | 1264 | on_error do |exception| 1265 | # Error logic here 1266 | # return false to skip default error handling 1267 | true 1268 | end 1269 | 1270 | exit run(ARGV) 1271 | --------------------------------------------------------------------------------