toptranslation_cli 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|