#!/usr/bin/env ruby # encoding: utf-8 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.enforce_available_locales = false 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)[1..-1] original_file_name = File.basename(path) file_extension = File.extname(path)[1..-1] file_name = File.basename(path, File.extname(path)) pattern = { '%original_file_name%' => original_file_name, '%original_path%' => original_path, '%file_extension%' => file_extension, '%file_name%' => file_name, '%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']), '%osx_code%' => osx_language_code(lang['crowdin_code']) + '.lproj', } 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('/') export_pattern.insert(0, '/') if translation.start_with?('/') return export_pattern end # This is a partial translation of the algorithm defined in fnmatch.py # https://github.com/python-git/python/blob/master/Lib/fnmatch.py # Provides a partial implementation of translate a glob +pat+ to a regular expression # # Patterns are Unix shell style: # * matches everything # ? matches any single character # [seq] matches any character in seq # [^seq] matches any char not in seq # # NOTE: # `**` surrounded by backslashes `/` in the +pat+ # `**` used only once in the +pat+ # def translate_pattern_to_regexp(pat) i = 0 n = pat.size res = '' while i < n c = pat[i] i = i + 1 if c == '*' j = i if j < n and pat[j] == '*' res[-1] = '(\/)?(?.*)?' i = j + 1 else res = res + '.*' end elsif c == '?' res = res + '.' elsif c == '[' j = i # The following two statements check if the sequence we stumbled # upon is '[]' or '[^]' because those are not valid character # classes. if j < n and pat[j] == '^' j = j + 1 end if j < n and pat[j] == ']' j = j + 1 end # Look for the closing ']' right off the bat. If one is not found, # escape the opening '[' and continue. If it is found, process # he contents of '[...]'. while j < n and pat[j] != ']' j = j + 1 end if j >= n res = res + '\\[' else stuff = pat[i...j].gsub('\\', '\\\\') i = j + 1 #if stuff[0] == '!' # stuff = '^' + stuff[1..-1] #elsif stuff[0] == '^' # stuff = '\\' + stuff #end res = "#{res}[#{stuff}]" end else res = 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 osx_language_code(language_code) language_code = case language_code when 'zh-TW' then 'zh-Hant' when 'zh-CN' then 'zh-Hans' else language_code end return language_code.sub('-', '_') end def get_invalid_placeholders(export_pattern) valid_placeholders = [ '%language%', '%two_letters_code%', '%three_letters_code%', '%locale%', '%locale_with_underscore%', '%android_code%', '%osx_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) case paths.length when 0 return '/' when 1 return paths.first.split('/').slice(0...-1).join('/') else 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 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.on_exists_proc = true # files that exists in archive and doesn't match current project configuration unmatched_files = [] Zip::File.open(zipfile_name) do |zipfile| zipfile.select{ |zip_entry| zip_entry.file? }.each do |f| # `f' - relative path in archive 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 do 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 # Build a Hash tree from Array of +filenames* # def build_hash_tree(filenames) files_tree = filenames.inject({}) { |h, i| t = h; i.split("/").each { |n| t[n] ||= {}; t = t[n] }; h } end # Box-drawing character - https://en.wikipedia.org/wiki/Box-drawing_character # ├ └ ─ │ # def display_tree(files_tree, level = -2, branches = []) tab = ' ' * 4 level += 1 files_tree.each_with_index do |(key, val), index| if val.empty? # this is a file result = branches.take(level).inject('') { |s, i| s << (i == 1 ? '│ ': ' ') } if index == files_tree.length - 1 # this is a last element result << '└' + '── ' + key else result << '├' + '── ' + key end puts result else # this is directory if key == '' # root directory result = '.' else result = branches.take(level).inject('') { |s, i| s << (i == 1 ? '│ ' : ' ') } if index == files_tree.length - 1 # this is a last element result << '└' + '── ' + key branches[level] = 0 else result << '├' + '── ' + key branches[level] = 1 end end puts result # recursion \(^_^)/ display_tree(val, level, branches) end 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 [:v, :verbose], :negatable => false desc I18n.t('app.flags.config.desc') default_value File.join(Dir.pwd, 'crowdin.yaml') arg_name '' flag [:c, :config] desc I18n.t('app.flags.identity.desc') default_value File.join(Dir.home, '.crowdin.yaml') arg_name '' flag [:identity] 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.default_value true 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.com/page/cli-tool#configuration-file for more details." end ignores = file['ignore'] || [] if File.exist?(File.join(@base_path, file['source'])) dest = file['dest'] || file['source'] type = file['type'] dest_files << dest local_file = { dest: dest, source: File.join(@base_path, file['source']), export_pattern: file['translation'], type: type } @allowed_options.each do |option| local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) end local_files << local_file else Find.find(@base_path) do |source_path| dest = source_path.sub(/\A#{@base_path}/, '') # relative path in Crowdin if File.directory?(source_path) if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } dest_files << dest export_pattern = construct_export_pattern(dest, file['source'], file['translation']) local_file = { dest: dest, source: source_path, export_pattern: export_pattern } @allowed_options.each do |option| local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) end 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] supported_languages = @crowdin.supported_languages project_info = @crowdin.project_info project_languages = project_info['languages'].collect{ |h| h['code'] } if language == 'all' if @jipt_language if supported_languages.find { |lang| lang['crowdin_code'] == @jipt_language } project_languages << @jipt_language # crowdin_language_code else exit_now!("invalid jipt language `#{@jipt_language}`") end end else 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 print 'Build ZIP archive with the latest translations ' export_translations = @crowdin.export_translations if export_translations['success'] if export_translations['success']['status'] == 'built' puts "- OK" elsif export_translations['success']['status'] == 'skipped' puts "- Skipped" puts "Warning: Export was skipped. Please note that this method can be invoked only once per 30 minutes." end end source_language = project_info['details']['source_language']['code'] source_language = supported_languages.find { |lang| lang['crowdin_code'] == source_language } translation_languages = supported_languages.select { |lang| project_languages.include?(lang['crowdin_code']) } # 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 ignores = file['ignore'] || [] # CSV files only (default: false) multilingual_spreadsheet = file['multilingual_spreadsheet'] || false if multilingual_spreadsheet file_translation_languages = [] << source_language else file_translation_languages = translation_languages end if File.exists?(File.join(@base_path, file['source'])) dest = file['source'].sub(/\A#{@base_path}/, '') file_translation_languages.each do |lang| zipped_file = export_pattern_to_path(dest, file['translation'], lang) zipped_file.sub!(/^\//, '') 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(/\A#{@base_path}/, '') # relative path in Crowdin if File.directory?(source_path) if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } export_pattern = construct_export_pattern(dest, file['source'], file['translation']) file_translation_languages.each do |lang| zipped_file = export_pattern_to_path(dest, export_pattern, lang) zipped_file.sub!(/^\//, '') 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 # action end # download desc I18n.t('app.commands.list.desc') long_desc I18n.t('app.commands.list.long_desc') command :list do |ls_cmd| ls_cmd.desc I18n.t('app.commands.list.commands.project.desc') ls_cmd.command :project do |proj_cmd| proj_cmd.desc I18n.t('app.commands.list.switches.tree.desc') proj_cmd.switch ['tree'], :negatable => false proj_cmd.action do |global_options, options, args| project_info = @crowdin.project_info remote_project_tree = get_remote_files_hierarchy(project_info['files']) if options[:tree] tree = build_hash_tree(remote_project_tree[:files]) display_tree(tree) else puts remote_project_tree[:files] end end end ls_cmd.desc I18n.t('app.commands.list.commands.sources.desc') ls_cmd.command :sources do |src_cmd| src_cmd.desc I18n.t('app.commands.list.switches.tree.desc') src_cmd.switch ['tree'], :negatable => false src_cmd.action do |global_options, options, args| 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.com/page/cli-tool#configuration-file for more details." end ignores = file['ignore'] || [] if File.exist?(File.join(@base_path, file['source'])) dest = file['source'] dest_files << dest local_file = { dest: dest, source: File.join(@base_path, file['source']), export_pattern: file['translation'] } @allowed_options.each do |option| local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) end local_files << local_file else Find.find(@base_path) do |source_path| dest = source_path.sub(/\A#{@base_path}/, '') # relative path in Crowdin if File.directory?(source_path) if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } dest_files << dest export_pattern = construct_export_pattern(dest, file['source'], file['translation']) local_file = { dest: dest, source: source_path, export_pattern: export_pattern } @allowed_options.each do |option| local_file.merge!({ option.to_sym => file[option] }) if file.has_key?(option) end local_files << local_file end end # Find end # if File.exists? end # @config['files'] common_dir = @preserve_hierarchy ? '' : find_common_directory_path(dest_files) local_project_tree = get_local_files_hierarchy(local_files.collect { |h| h[:dest].sub(common_dir, '') }) if options[:tree] tree = build_hash_tree(local_project_tree[:files]) display_tree(tree) else puts local_project_tree[:files] end end end # list sources ls_cmd.desc I18n.t('app.commands.list.commands.translations.desc') ls_cmd.command :translations do |trans_cmd| trans_cmd.desc I18n.t('app.commands.list.switches.tree.desc') trans_cmd.switch ['tree'], :negatable => false trans_cmd.action do |global_options, options, args| project_info = @crowdin.project_info project_languages = project_info['languages'].collect{ |h| h['code'] } supported_languages = @crowdin.supported_languages translation_languages = supported_languages.select { |lang| project_languages.include?(lang['crowdin_code']) } translation_files = [] @config['files'].each do |file| languages_mapping = file['languages_mapping'] # Hash or NilClass ignores = file['ignore'] || [] if File.exists?(File.join(@base_path, file['source'])) dest = file['source'].sub(/\A#{@base_path}/, '') translation_languages.each do |lang| local_file = export_pattern_to_path(dest, file['translation'], lang, languages_mapping) translation_files << local_file end else Find.find(@base_path) do |source_path| dest = source_path.sub(/\A#{@base_path}/, '') # relative path in Crowdin if File.directory?(source_path) if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } Find.prune # Don't look any further into this directory else next end elsif File.fnmatch?(file['source'], dest, File::FNM_PATHNAME) next if ignores.any? { |pattern| File.fnmatch?(pattern, dest, File::FNM_PATHNAME) } export_pattern = construct_export_pattern(dest, file['source'], file['translation']) translation_languages.each do |lang| local_file = export_pattern_to_path(dest, export_pattern, lang, languages_mapping) translation_files << local_file end end end # Find end # if end # @config['files'] if options[:tree] tree = build_hash_tree(translation_files) display_tree(tree) else puts translation_files end end end # list translations #ls_cmd.default_command :project end # list 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 @allowed_options = [ # CSV files only 'scheme', 'first_line_contains_header', 'update_option', # XML files only 'translate_content', 'translate_attributes', 'content_segmentation', 'translatable_elements', ] unless File.exists?(globals[:config]) 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)