toptranslation_cli 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToptranslationCli
4
+ class Check
5
+ class << self
6
+ def run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
7
+ puts "Toptranslation command line client, version #{VERSION} - Configuration check\n\n"
8
+ puts "Configuration file present:\t#{check_configuration_file}"
9
+ puts " * includes access_token:\t#{check_access_token}"
10
+ puts " * includes project_identifier:\t#{check_project_identifier}"
11
+ puts " * includes files:\t\t#{check_file_paths_present}\n\n"
12
+
13
+ puts 'Checking connection:'
14
+ puts " * API URL:\t\t\t#{ToptranslationCli.configuration.api_base_url}"
15
+ puts " * Files URL:\t\t\t#{ToptranslationCli.configuration.files_base_url}"
16
+ puts " * access_token:\t\t#{ToptranslationCli.configuration.access_token}"
17
+ puts " * project_identifier:\t\t#{ToptranslationCli.configuration.project_identifier}"
18
+ puts " * project found:\t\t#{check_for_project}\n\n"
19
+
20
+ check_matching_files
21
+ end
22
+
23
+ private
24
+
25
+ def check_configuration_file
26
+ if ToptranslationCli.configuration.exist?
27
+ pastel.green('ok')
28
+ else
29
+ pastel.red('configuration file missing')
30
+ end
31
+ end
32
+
33
+ def check_access_token
34
+ ToptranslationCli.configuration.load
35
+ if !ToptranslationCli.configuration.access_token.nil?
36
+ pastel.green('ok')
37
+ else
38
+ pastel.red('access token missing from configuration file')
39
+ end
40
+ end
41
+
42
+ def check_project_identifier
43
+ ToptranslationCli.configuration.load
44
+ if !ToptranslationCli.configuration.project_identifier.nil?
45
+ pastel.green('ok')
46
+ else
47
+ pastel.red('project_identifier missing from configuration file')
48
+ end
49
+ end
50
+
51
+ def find_remote_project(project_identifier)
52
+ ToptranslationCli.connection.projects.find(project_identifier)
53
+ rescue StandardError => e
54
+ puts pastel.red(e)
55
+ end
56
+
57
+ def check_for_project
58
+ project_identifier = ToptranslationCli.configuration.project_identifier
59
+ remote_project = find_remote_project(project_identifier)
60
+
61
+ if remote_project&.identifier == project_identifier
62
+ pastel.green('ok')
63
+ else
64
+ pastel.red('project not found')
65
+ end
66
+ end
67
+
68
+ def check_file_paths_present
69
+ ToptranslationCli.configuration.load
70
+ if ToptranslationCli.configuration.files.any?
71
+ pastel.green('ok')
72
+ else
73
+ pastel.red('file paths missing from configuration file')
74
+ end
75
+ end
76
+
77
+ def check_matching_files
78
+ puts 'Matching files:'
79
+
80
+ ToptranslationCli.configuration.load
81
+ ToptranslationCli.configuration.files.each do |path_definition|
82
+ puts " * #{path_definition}: #{matching_files_output(path_definition)}"
83
+ end
84
+ end
85
+
86
+ def matching_files_output(path_definition)
87
+ count = FileFinder.new(path_definition).files.count
88
+
89
+ if count != 0
90
+ pastel.green("#{count} matching files")
91
+ else
92
+ pastel.red('no matching files')
93
+ end
94
+ end
95
+
96
+ def pastel
97
+ @pastel ||= Pastel.new
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToptranslationCli
4
+ class Configuration
5
+ attr_accessor :project_identifier, :access_token, :files, :api_base_url, :files_base_url, :verbose
6
+
7
+ FILENAME = '.toptranslation.yml'
8
+
9
+ def load
10
+ @project_identifier = configuration['project_identifier']
11
+ @access_token = configuration['access_token']
12
+ @files = configuration['files'] || []
13
+ @files_base_url = configuration['files_base_url'] || 'https://files.toptranslation.com'
14
+ @api_base_url = configuration['api_base_url'] || 'https://api.toptranslation.com'
15
+ @verbose = !ENV['VERBOSE'].nil?
16
+ end
17
+
18
+ def save
19
+ File.open(FILENAME, 'w') do |file|
20
+ # Psych can't stringify keys so we dump it to json before dumping to yml
21
+ Psych.dump(JSON.parse(configuration_hash.to_json), file)
22
+ end
23
+ end
24
+
25
+ def use_examples
26
+ @project_identifier = '<PROJECT_IDENTIFIER>'
27
+ @access_token = '<YOUR_ACCESS_TOKEN>'
28
+ @files = ['config/locales/{locale_code}/**/*.yml']
29
+ end
30
+
31
+ def exist?
32
+ File.exist?(FILENAME)
33
+ end
34
+
35
+ private
36
+
37
+ def configuration
38
+ @configuration ||= Psych.safe_load(File.read(FILENAME, encoding: 'bom|utf-8'))
39
+ rescue StandardError => e
40
+ puts Pastel.new.red('Could not read configuration'), e
41
+ exit 1
42
+ end
43
+
44
+ def configuration_hash
45
+ {
46
+ project_identifier: @project_identifier,
47
+ access_token: @access_token,
48
+ files: @files
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToptranslationCli
4
+ class FileFinder
5
+ def initialize(path_definition)
6
+ @path_definition = path_definition
7
+ end
8
+
9
+ def files(locale_code = '**')
10
+ Dir.glob(@path_definition.gsub('{locale_code}', locale_code))
11
+ end
12
+
13
+ class << self
14
+ def local_files(project)
15
+ ToptranslationCli.configuration.files.each_with_object({}) do |path_definition, mem|
16
+ project&.locales&.map(&:code)&.each do |locale_code|
17
+ mem.merge!(local_files_for_path_definition(path_definition, locale_code))
18
+ end
19
+ end
20
+ end
21
+
22
+ def remote_files(project)
23
+ project_locales = project&.locales
24
+ project&.documents&.each_with_object({}) do |document, files|
25
+ project_locales&.each do |locale|
26
+ translation = document.translations.find { |t| t.locale.code == locale.code }
27
+ next unless translation
28
+
29
+ path = document.path.gsub('{locale_code}', locale.code)
30
+ files[path] = remote_file(document, locale, translation)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def add_remote_file(document, locale, translation)
38
+ {
39
+ sha1: translation.sha1,
40
+ identifier: document.identifier,
41
+ locale_code: locale.code
42
+ }
43
+ end
44
+
45
+ def local_files_for_path_definition(path_definition, locale_code)
46
+ new(path_definition)
47
+ .files(locale_code)
48
+ .map { |path| { path => checksum(path) } }
49
+ .reduce({}, &:merge)
50
+ end
51
+
52
+ def checksum(path)
53
+ Digest::SHA1.file(path).hexdigest
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToptranslationCli
4
+ class Info
5
+ def self.print_help
6
+ print_version
7
+
8
+ puts <<~INFO
9
+ Usage:\t\ttt [command]\n\n"
10
+ Commands:'
11
+ init\t\tCreates example configuration file #{Configuration::FILENAME}"
12
+ check\tChecks current configuration"
13
+ push\t\tUploads local documents"
14
+ pull\t\tPulls remote translations, overwrites local documents"
15
+ --version\tDisplays current version of application"
16
+ --help\tDisplays this help screen\n\n"
17
+ Twitter:\t@tt_developers\n\n"
18
+ Websites:\thttps://www.toptranslation.com"
19
+ \t\thttps://developer.toptranslation.com"
20
+ \t\thttps://github.com/Toptranslation/tt_cli"
21
+ INFO
22
+ end
23
+
24
+ def self.print_version
25
+ puts "Toptranslation command line client, version #{VERSION}"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'tty-prompt'
5
+ require 'tty-spinner'
6
+
7
+ module ToptranslationCli
8
+ class Initializer # rubocop:disable Metrics/ClassLength
9
+ class << self
10
+ def run
11
+ new.run
12
+ end
13
+ end
14
+
15
+ def initialize
16
+ @prompt = TTY::Prompt.new
17
+ @client = Toptranslation.new({})
18
+ @pastel = Pastel.new
19
+ format = "[#{@pastel.yellow(':spinner')}] :title"
20
+ @spinner = TTY::Spinner.new(format, success_mark: @pastel.green('+'), error_mark: @pastel.red('-'))
21
+ end
22
+
23
+ def run
24
+ create_config(ask_config)
25
+ @prompt.ok("Generated #{Configuration::FILENAME}")
26
+ end
27
+
28
+ private
29
+
30
+ def ask_config
31
+ {
32
+ token: sign_in(ask_auth_method),
33
+ project_id: ask_project,
34
+ file_selectors: ask_file_selectors
35
+ }
36
+ end
37
+
38
+ def projects?
39
+ @spinner.update(title: 'Fetching projects...')
40
+ @spinner.auto_spin
41
+ @client.projects.any?.tap do |any|
42
+ if any
43
+ @spinner.success(@pastel.green('done'))
44
+ else
45
+ @spinner.error(@pastel.red('could not find any projects'))
46
+ end
47
+ end
48
+ end
49
+
50
+ def ask_project
51
+ exit 1 unless projects?
52
+
53
+ @prompt.select('Project:') do |menu|
54
+ each_project_with_index.map do |project, index|
55
+ menu.default(index + 1) if File.basename(Dir.pwd).casecmp?(project.name)
56
+ menu.choice name: project.name, value: project.identifier
57
+ end
58
+ end
59
+ end
60
+
61
+ def ask_auth_method
62
+ @prompt.select('Authentication method:') do |menu|
63
+ menu.choice name: 'Email and password', value: :email
64
+ menu.choice name: 'Access token', value: :token
65
+ end
66
+ end
67
+
68
+ def ask_file_selectors
69
+ result = []
70
+ loop do
71
+ result << @prompt.ask('File selector:') do |q|
72
+ q.required true
73
+ q.default 'config/locales/{locale_code}/**/*.yml'
74
+ end
75
+ break unless @prompt.yes?('Add another file selector?', default: false)
76
+ end
77
+ result
78
+ end
79
+
80
+ def sign_in(auth_method)
81
+ @spinner.update(title: 'Signing in...')
82
+ if auth_method == :email
83
+ ask_email_and_password
84
+ else
85
+ ask_access_token
86
+ end
87
+ end
88
+
89
+ def ask_email_and_password
90
+ email = @prompt.ask('Email:', required: true)
91
+ password = @prompt.mask('Password:', required: true, echo: false)
92
+ token = @client.sign_in!(email: email, password: password)
93
+ @spinner.success(@pastel.green('done'))
94
+ token
95
+ rescue RestClient::Unauthorized
96
+ @spinner.error(@pastel.red('credentials are invalid'))
97
+ retry
98
+ end
99
+
100
+ def ask_access_token
101
+ token = @prompt.ask('Access token:', required: true)
102
+ @client.access_token = token
103
+ @spinner.auto_spin
104
+ @client.projects.to_a
105
+ @spinner.success(@pastel.green('done'))
106
+ token
107
+ rescue RestClient::Forbidden
108
+ @spinner.error(@pastel.red('invalid access token'))
109
+ @spinner.stop
110
+ retry
111
+ end
112
+
113
+ def create_config(answers)
114
+ config = ToptranslationCli::Configuration.new
115
+ config.project_identifier = answers[:project_id]
116
+ config.access_token = answers[:token]
117
+ config.files = answers[:file_selectors]
118
+ config.save
119
+ end
120
+
121
+ def each_project_with_index
122
+ @client.projects.sort_by(&:name).each_with_index
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PlaceholderPath
4
+ def initialize(path_definition)
5
+ @path_definition = path_definition
6
+ end
7
+
8
+ # Returns a filepath with {locale_code} placeholder e.g. for the parameters
9
+ # path: "/locales/de/admin/index.de.po"
10
+ # path_definition: "/locales/{locale_code}/**/*{locale_code}.po"
11
+ # locale_code: "de"
12
+ # it will return: "/locales/{locale_code}/admin/index.{locale_code}.po"
13
+ def for_path(path, locale_code)
14
+ regex = regex(locale_code)
15
+ path.match(regex).captures.join('{locale_code}')
16
+ end
17
+
18
+ private
19
+
20
+ # Replaces UNIX wildcards in a path definition and returns a regular
21
+ # expression of the path definition
22
+ # e.g. "/config/**/*{locale_code}.po" => /\/config\/.*de\.po/
23
+ #
24
+ # (1) - Replaces ** and * wildcards with .*
25
+ # (2) - Replaces duplicate wildcards like .*/.* with one .*
26
+ # (3) - splits path_definition at {locale_code}
27
+ # (4) - Puts each part of splits in parantesis and joins them with locale_code
28
+ def regex(locale_code)
29
+ string = @path_definition
30
+
31
+ string = string.gsub(/\./, '\.')
32
+ string = string.gsub(/((?<!\*)\*(?!\*))|(\*\*)/, '.*') # (1)
33
+ string = string.gsub('.*/.*', '.*') # (2)
34
+
35
+ splits = string.split('{locale_code}') # (3)
36
+ path = splits.map { |segment| "(#{segment})" }.join(locale_code) # (4)
37
+
38
+ Regexp.new(path)
39
+ end
40
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tty-spinner'
5
+ require 'pastel'
6
+ require 'tty-progressbar'
7
+
8
+ module ToptranslationCli
9
+ class Pull
10
+ using Threaded
11
+
12
+ def self.run
13
+ new.run
14
+ end
15
+
16
+ def initialize
17
+ ToptranslationCli.configuration.load
18
+
19
+ @pastel = Pastel.new
20
+ @spinner_settings = { success_mark: @pastel.green('+'), error_mark: @pastel.red('-') }
21
+ @spinner = TTY::Spinner.new("[#{@pastel.yellow(':spinner')}] :title", @spinner_settings)
22
+ end
23
+
24
+ def run
25
+ changed = changed_files(remote_files, local_files)
26
+ download_files(changed)
27
+ rescue RestClient::BadRequest
28
+ @spinner.error(@pastel.red('invalid access token')) if @spinner.spinning?
29
+ exit 1
30
+ rescue RestClient::NotFound
31
+ @spinner.error(@pastel.red('project not found')) if @spinner.spinning?
32
+ exit 1
33
+ end
34
+
35
+ private
36
+
37
+ def changed_files(remote_files, local_files)
38
+ @spinner.update(title: 'Checking for changed files...')
39
+ files = remote_files.reject do |file|
40
+ local_files[file[:path]] == file[:sha1]
41
+ end
42
+ @spinner.auto_spin
43
+ @spinner.success(@pastel.green("found #{files.count} changed file(s)"))
44
+ files
45
+ end
46
+
47
+ def download_files(files)
48
+ return if files.empty?
49
+
50
+ bar = TTY::ProgressBar.new('Downloading [:bar] :percent [:current/:total]', total: files.count)
51
+ bar.render
52
+
53
+ files.each_in_threads(8) do |file|
54
+ file[:document].download(file[:locale].code, path: file[:path])
55
+ bar.synchronize { bar.log(file[:path]) }
56
+ bar.advance
57
+ end
58
+ end
59
+
60
+ def local_files
61
+ @spinner.update(title: 'Checking local files...')
62
+ @spinner.auto_spin
63
+ files = FileFinder.local_files(project)
64
+ @spinner.success(@pastel.green("found #{files.count} file(s)"))
65
+ files
66
+ end
67
+
68
+ def path(document, locale)
69
+ document.path.gsub('{locale_code}', locale.code)
70
+ end
71
+
72
+ def project_locales
73
+ @project_locales ||= project.locales
74
+ end
75
+
76
+ def project
77
+ @project ||= ToptranslationCli.connection.projects.find(ToptranslationCli.configuration.project_identifier)
78
+ end
79
+
80
+ def remote_files
81
+ @spinner.update(title: 'Checking remote files...')
82
+ @spinner.auto_spin
83
+ files = project&.documents&.flat_map do |document|
84
+ document.translations.map do |translation|
85
+ file_to_download(document, translation)
86
+ end
87
+ end
88
+ @spinner.success(@pastel.green("found #{files.count} file(s)"))
89
+ files
90
+ end
91
+
92
+ def file_to_download(document, translation)
93
+ {
94
+ path: path(document, translation.locale),
95
+ document: document,
96
+ sha1: translation.sha1,
97
+ locale: translation.locale
98
+ }
99
+ end
100
+ end
101
+ end