#!/usr/bin/env ruby require 'pp' require 'find' require 'crowdin-cli' # GLI_DEBUG=true bundle exec bin/crowdin-cli # Setup i18n # tell the I18n library where to find your translations I18n.load_path += Dir[Pathname(__FILE__).dirname.expand_path + '../locales/*.yml'] I18n.locale = :en # Return +hierarchy+ of directories and files in Crowdin project # # +files+ - basically, it's project files details from API method `project_info` # def get_remote_files_hierarchy(files, root = '/', hierarchy = { dirs: [], files: [] }) files.each do |node| case node['node_type'] when 'directory' hierarchy[:dirs] << "#{root}#{node['name']}" get_remote_files_hierarchy(node['files'], root + node['name'] + '/', hierarchy) when 'file' hierarchy[:files] << "#{root}#{node['name']}" end end return hierarchy end # Return +hierarchy+ of local directories and files # # @params [Array] files a list of files in a local directory. # def get_local_files_hierarchy(files, hierarchy = { dirs: [], files: [] }) hierarchy[:files] = files dirs = files.inject([]) do |res, a| res << a.split('/').drop(1).inject([]) do |res, s| res << res.last.to_s + '/' + s end end dirs.map(&:pop) # delete last element from each array hierarchy[:dirs] = dirs.flatten.uniq return hierarchy end # @param [String] path relative path to file in Crowdin project # @param [String] export_pattern basically, is a file['translation'] from crowdin.yaml # @param [Hash] lang language information # @option lang [String] :name # @option lang [String] :crowdin_code # @option lang [String] :iso_639_1 # @option lang [String] :iso_639_3 # @option lang [String] :locale # def export_pattern_to_path(path, export_pattern, lang, languages_mapping = nil) original_path = File.dirname(path) original_file_name = File.basename(path) file_extension = File.extname(path)[1..-1] file_name = File.basename(path, File.extname(path)) pattern = { '%language%' => lang['name'], '%two_letters_code%' => lang['iso_639_1'], '%three_letters_code%' => lang['iso_639_3'], '%locale%' => lang['locale'], '%locale_with_underscore%' => lang['locale'].gsub('-', '_'), '%android_code%' => android_locale_code(lang['locale']), '%original_file_name%' => original_file_name, '%original_path%' => original_path, '%file_extension%' => file_extension, '%file_name%' => file_name, } placeholders = pattern.inject([]){ |memo, h| memo << h.first[/%(.*)%/, 1] } unless languages_mapping.nil? pattern = Hash[pattern.map{ |placeholder, str| [ placeholder, (languages_mapping[placeholder[/%(.*)%/, 1]][lang['crowdin_code']] rescue nil) || str] }] end export_pattern.gsub(/%(#{placeholders.join('|')})%/, pattern) end # @param [String] path relative path to file in Crowdin project # @param [String] source basically, is a file['source'] from crowdin.yaml # @param [String] translation basically, is a file['translation'] from crowdin.yaml # def construct_export_pattern(path, source, translation) pattern_regexp = translate_pattern_to_regexp(source) if pattern_regexp.names.include?('double_asterisk') and path.match(pattern_regexp) double_asterisk = path.match(pattern_regexp)['double_asterisk'] translation = translation.sub('**', double_asterisk) end export_pattern = translation.split('/').reject(&:empty?).join('/').insert(0, '/') return export_pattern end # Provides a partial implementation of translate a glob +pattern+ to a regular expression # NOTE: # `**` surrounded by backslashes `/` in the +pattern+ # `**` used only once in the +pattern+ # def translate_pattern_to_regexp(pattern) i = 0 n = pattern.size res = '' while i < n c = pattern[i] i = i + 1 if c == '*' j = i if j < n and pattern[j] == '*' res[-1] = '(\/)?(?.*)?' i = j + 1 else res << '(.*)' end elsif c == '?' res << '.' else res << Regexp.escape(c) end end return Regexp.new(res) end def android_locale_code(locale_code) locale_code = case locale_code when 'he-IL' then 'iw-IL' when 'yi-DE' then 'ji-DE' when 'id-ID' then 'in-ID' else locale_code end return locale_code.sub('-', '-r') end def get_invalid_placeholders(export_pattern) valid_placeholders = [ '%language%', '%two_letters_code%', '%three_letters_code%', '%locale%', '%locale_with_underscore%', '%android_code%', '%original_file_name%', '%original_path%', '%file_extension%', '%file_name%', ] all_placeholders = export_pattern.scan(/%[a-z0-9_]*?%/) invalid_placeholders = all_placeholders - valid_placeholders end # Return a string representing that part of the directory tree that is common to all the files # # @params [Array] paths set of strings representing directory paths # def find_common_directory_path(paths) return paths.first.split('/').slice(0...-1).join('/') if paths.length <= 1 arr = paths.sort first = arr.first.split('/') last = arr.last.split('/') i = 0 i += 1 while first[i] == last[i] && i <= first.length first.slice(0, i).join('/') end # Extract compressed files +files_list+ in a ZIP archive +zipfile_name+ to +dest_path+ # # +files_list+ is a Hash of key-value pairs. Where key is a posible archive filename based on current project configuration # and value is the expanded filename # def unzip_file_with_translations(zipfile_name, dest_path, files_list) # overwrite files if they already exist inside of the extracted path Zip.options[:on_exists_proc] = true # files that exists in archive and doesn't match current project configuration unmatched_files = [] Zip::ZipFile.open(zipfile_name) do |zipfile| zipfile.select{ |zip_entry| zip_entry.file? }.each do |f| file = files_list['/' + f.name] if file fpath = File.join(dest_path, file) FileUtils.mkdir_p(File.dirname(fpath)) puts "Download: `#{file}'" zipfile.extract(f, fpath) else unmatched_files << f end end end unless unmatched_files.empty? puts "Warning: Downloaded translations does not match current project configuration. Some of the resulted files will be omitted." unmatched_files.each{ |file| puts " - `#{file}'" } puts "Crowdin has internal caching mechanisms that prevents us from overload. Please try to download translations later." end end ### include GLI::App version Crowdin::CLI::VERSION subcommand_option_handling :normal program_desc I18n.t('app.desc') program_long_desc I18n.t('app.long_desc') sort_help :manually # help commands are ordered in the order declared wrap_help_text :to_terminal desc I18n.t('app.switches.verbose.desc') switch [:verbose, :v], :negatable => false desc I18n.t('app.flags.config.desc') default_value File.join(Dir.pwd, 'crowdin.yaml') arg_name '' flag [:config,:c] desc I18n.t('app.commands.upload.desc') long_desc I18n.t('app.commands.upload.long_desc') command :upload do |c| c.desc I18n.t('app.commands.upload.commands.sources.desc') c.long_desc I18n.t('app.commands.upload.commands.sources.long_desc') c.command :sources do |c| c.desc I18n.t('app.commands.upload.commands.sources.switches.auto_update.desc') c.switch ['auto-update'] c.action do |global_options, options, args| project_info = @crowdin.project_info source_language = project_info['details']['source_language']['code'] # Crowdin supported languages list supported_languages = @crowdin.supported_languages source_language = supported_languages.find{ |lang| lang['crowdin_code'] == source_language } remote_project_tree = get_remote_files_hierarchy(project_info['files']) local_files = [] dest_files = [] @config['files'].each do |file| get_invalid_placeholders(file['translation']).each do |placeholder| puts "Warning: #{placeholder} is not valid variable supported by Crowdin. See http://crowdin.net/page/cli-tool#configuration-file for more details." end if File.exist?("#{@base_path}#{file['source']}") dest = file['source'] dest_files << dest local_file = { dest: dest, source: "#{@base_path}#{file['source']}", export_pattern: file['translation'] } # Used only when uploading CSV file to define data columns mapping. local_file.merge!({ sheme: file['scheme'] }) if file.has_key?('scheme') local_file.merge!({ first_line_contains_header: file['first_line_contains_header'] }) if file.has_key?('first_line_contains_header') local_files << local_file else Find.find(@base_path) do |source_path| dest = source_path.sub(@base_path, '') # relative path in Crowdin if File.directory?(source_path) ignores = file['ignore'] || [] if ignores.include?(dest) Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest) dest_files << dest export_pattern = construct_export_pattern(dest, file['source'], file['translation']) local_file = { dest: dest, source: source_path, export_pattern: export_pattern } local_file.merge!({ sheme: file['scheme'] }) if file.has_key?('scheme') local_file.merge!({ first_line_contains_header: file['first_line_contains_header'] }) if file.has_key?('first_line_contains_header') local_files << local_file end end # Find end # if File.exists? end # @config['files'] if dest_files.empty? exit_now! < 'all' c.action do |global_options ,options, args| language = options[:language] project_info = @crowdin.project_info remote_project_tree = get_remote_files_hierarchy(project_info['files']) project_languages = project_info['languages'].collect{ |h| h['code'] } if language != 'all' if project_languages.include?(language) project_languages = [] << language else exit_now!("language '#{language}' doesn't exist in a project") end end # use export API method before to download the most recent translations @crowdin.export_translations supported_languages = @crowdin.supported_languages translation_languages = supported_languages.select{ |lang| project_languages.include?(lang['crowdin_code']) } source_language = project_info['details']['source_language']['code'] source_language = supported_languages.find{ |lang| lang['crowdin_code'] == source_language } # keys is all possible files in .ZIP archive # values is resulted local files downloadable_files_hash = {} @config['files'].each do |file| languages_mapping = file['languages_mapping'] # Hash or NilClass if File.exists?("#{@base_path}#{file['source']}") dest = file['source'].sub("#{@base_path}", '') translation_languages.each do |lang| zipped_file = export_pattern_to_path(dest, file['translation'], lang) local_file = export_pattern_to_path(dest, file['translation'], lang, languages_mapping) downloadable_files_hash[zipped_file] = local_file end else Find.find(@base_path) do |source_path| dest = source_path.sub(@base_path, '') # relative path in Crowdin if File.directory?(source_path) ignores = file['ignore'] || [] if ignores.include?(dest) Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest) export_pattern = construct_export_pattern(dest, file['source'], file['translation']) translation_languages.each do |lang| zipped_file = export_pattern_to_path(dest, export_pattern, lang) local_file = export_pattern_to_path(dest, export_pattern, lang, languages_mapping) downloadable_files_hash[zipped_file] = local_file end end end # Find end # if end # @config['files'] ## tempfile = Tempfile.new(language) zipfile_name = tempfile.path begin @crowdin.download_translation(language, output: zipfile_name) unzip_file_with_translations(zipfile_name, @base_path, downloadable_files_hash) ensure tempfile.close tempfile.unlink # delete the tempfile end end end pre do |globals ,command, options, args| # Pre logic here # Return true to proceed; false to abourt and not call the # chosen command # Use skips_pre before a command to skip this block # on that command only if File.exists?(globals[:config]) @config = YAML.load_file(globals[:config]) ['api_key', 'project_identifier'].each do |key| unless @config[key] exit_now! < 1 exit_now! < err raise err rescue exit_now!("Seems Crowdin server API URL is not valid. Please check the `base_url` parameter in the configuration file.") end #puts "Executing #{command.name}" if globals[:verbose] true end post do |globals, command, options, args| # Post logic here # Use skips_post before a command to skip this # block on that command only #puts "Executed #{command.name}" if globals[:verbose] end on_error do |exception| # Error logic here # return false to skip default error handling true end exit run(ARGV)