toptranslation_cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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