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.
- checksums.yaml +7 -0
- data/.github/workflows/tests.yml +22 -0
- data/.gitignore +57 -0
- data/.rspec +2 -0
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +60 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/Rakefile +10 -0
- data/exe/tt +67 -0
- data/lib/toptranslation_cli.rb +34 -0
- data/lib/toptranslation_cli/check.rb +101 -0
- data/lib/toptranslation_cli/configuration.rb +52 -0
- data/lib/toptranslation_cli/file_finder.rb +57 -0
- data/lib/toptranslation_cli/info.rb +28 -0
- data/lib/toptranslation_cli/initializer.rb +125 -0
- data/lib/toptranslation_cli/placeholder_path.rb +40 -0
- data/lib/toptranslation_cli/pull.rb +101 -0
- data/lib/toptranslation_cli/push.rb +139 -0
- data/lib/toptranslation_cli/status.rb +62 -0
- data/lib/toptranslation_cli/threaded.rb +25 -0
- data/lib/toptranslation_cli/version.rb +5 -0
- data/toptranslation_cli.gemspec +37 -0
- metadata +221 -0
@@ -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
|