aspera-cli 4.13.0 → 4.15.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +81 -7
  4. data/CONTRIBUTING.md +22 -6
  5. data/README.md +2038 -1080
  6. data/bin/ascli +18 -9
  7. data/bin/asession +12 -14
  8. data/examples/dascli +1 -1
  9. data/examples/proxy.pac +1 -1
  10. data/examples/rubyc +24 -0
  11. data/lib/aspera/aoc.rb +219 -159
  12. data/lib/aspera/ascmd.rb +25 -14
  13. data/lib/aspera/cli/basic_auth_plugin.rb +12 -9
  14. data/lib/aspera/cli/error.rb +17 -0
  15. data/lib/aspera/cli/extended_value.rb +47 -12
  16. data/lib/aspera/cli/formatter.rb +260 -179
  17. data/lib/aspera/cli/hints.rb +80 -0
  18. data/lib/aspera/cli/main.rb +104 -156
  19. data/lib/aspera/cli/manager.rb +259 -209
  20. data/lib/aspera/cli/plugin.rb +123 -63
  21. data/lib/aspera/cli/plugins/alee.rb +2 -3
  22. data/lib/aspera/cli/plugins/aoc.rb +341 -261
  23. data/lib/aspera/cli/plugins/ats.rb +22 -21
  24. data/lib/aspera/cli/plugins/bss.rb +5 -5
  25. data/lib/aspera/cli/plugins/config.rb +578 -627
  26. data/lib/aspera/cli/plugins/console.rb +44 -6
  27. data/lib/aspera/cli/plugins/cos.rb +15 -17
  28. data/lib/aspera/cli/plugins/faspex.rb +114 -100
  29. data/lib/aspera/cli/plugins/faspex5.rb +411 -264
  30. data/lib/aspera/cli/plugins/node.rb +354 -259
  31. data/lib/aspera/cli/plugins/orchestrator.rb +61 -29
  32. data/lib/aspera/cli/plugins/preview.rb +82 -90
  33. data/lib/aspera/cli/plugins/server.rb +79 -32
  34. data/lib/aspera/cli/plugins/shares.rb +55 -42
  35. data/lib/aspera/cli/sync_actions.rb +68 -0
  36. data/lib/aspera/cli/transfer_agent.rb +66 -73
  37. data/lib/aspera/cli/transfer_progress.rb +74 -0
  38. data/lib/aspera/cli/version.rb +1 -1
  39. data/lib/aspera/colors.rb +12 -8
  40. data/lib/aspera/command_line_builder.rb +14 -11
  41. data/lib/aspera/cos_node.rb +3 -2
  42. data/lib/aspera/data/6 +0 -0
  43. data/lib/aspera/environment.rb +24 -9
  44. data/lib/aspera/fasp/agent_aspera.rb +126 -0
  45. data/lib/aspera/fasp/agent_base.rb +31 -77
  46. data/lib/aspera/fasp/agent_connect.rb +25 -21
  47. data/lib/aspera/fasp/agent_direct.rb +89 -103
  48. data/lib/aspera/fasp/agent_httpgw.rb +231 -149
  49. data/lib/aspera/fasp/agent_node.rb +41 -34
  50. data/lib/aspera/fasp/agent_trsdk.rb +75 -32
  51. data/lib/aspera/fasp/error_info.rb +4 -2
  52. data/lib/aspera/fasp/faux_file.rb +52 -0
  53. data/lib/aspera/fasp/installation.rb +53 -195
  54. data/lib/aspera/fasp/management.rb +244 -0
  55. data/lib/aspera/fasp/parameters.rb +71 -37
  56. data/lib/aspera/fasp/parameters.yaml +76 -8
  57. data/lib/aspera/fasp/products.rb +162 -0
  58. data/lib/aspera/fasp/resume_policy.rb +3 -3
  59. data/lib/aspera/fasp/transfer_spec.rb +7 -6
  60. data/lib/aspera/fasp/uri.rb +26 -24
  61. data/lib/aspera/faspex_gw.rb +2 -2
  62. data/lib/aspera/faspex_postproc.rb +2 -2
  63. data/lib/aspera/hash_ext.rb +14 -4
  64. data/lib/aspera/json_rpc.rb +49 -0
  65. data/lib/aspera/keychain/macos_security.rb +13 -13
  66. data/lib/aspera/line_logger.rb +23 -0
  67. data/lib/aspera/log.rb +58 -16
  68. data/lib/aspera/node.rb +157 -92
  69. data/lib/aspera/oauth.rb +37 -19
  70. data/lib/aspera/open_application.rb +4 -4
  71. data/lib/aspera/persistency_action_once.rb +1 -1
  72. data/lib/aspera/persistency_folder.rb +2 -2
  73. data/lib/aspera/preview/file_types.rb +4 -2
  74. data/lib/aspera/preview/generator.rb +22 -35
  75. data/lib/aspera/preview/options.rb +2 -0
  76. data/lib/aspera/preview/terminal.rb +73 -16
  77. data/lib/aspera/preview/utils.rb +21 -28
  78. data/lib/aspera/proxy_auto_config.js +2 -2
  79. data/lib/aspera/rest.rb +136 -68
  80. data/lib/aspera/rest_call_error.rb +1 -1
  81. data/lib/aspera/rest_error_analyzer.rb +15 -14
  82. data/lib/aspera/rest_errors_aspera.rb +37 -34
  83. data/lib/aspera/secret_hider.rb +18 -15
  84. data/lib/aspera/ssh.rb +5 -2
  85. data/lib/aspera/sync.rb +127 -119
  86. data/lib/aspera/temp_file_manager.rb +10 -3
  87. data/lib/aspera/web_auth.rb +10 -7
  88. data/lib/aspera/web_server_simple.rb +9 -4
  89. data.tar.gz.sig +0 -0
  90. metadata +34 -17
  91. metadata.gz.sig +0 -0
  92. data/docs/test_env.conf +0 -186
  93. data/lib/aspera/cli/listener/line_dump.rb +0 -19
  94. data/lib/aspera/cli/listener/logger.rb +0 -22
  95. data/lib/aspera/cli/listener/progress.rb +0 -50
  96. data/lib/aspera/cli/listener/progress_multi.rb +0 -84
  97. data/lib/aspera/cli/plugins/sync.rb +0 -44
  98. data/lib/aspera/data/7 +0 -0
  99. data/lib/aspera/fasp/listener.rb +0 -13
data/lib/aspera/oauth.rb CHANGED
@@ -24,18 +24,23 @@ module Aspera
24
24
  # OAuth methods supported by default
25
25
  STD_AUTH_TYPES = %i[web jwt].freeze
26
26
 
27
- # remove 5 minutes to account for time offset between client and server (TODO: configurable?)
28
- JWT_ACCEPTED_OFFSET_SEC = 300
29
- # one hour validity (TODO: configurable?)
30
- JWT_EXPIRY_OFFSET_SEC = 3600
31
- # tokens older than 30 minutes will be discarded from cache
32
- TOKEN_CACHE_EXPIRY_SEC = 1800
33
- # tokens valid for less than this duration will be regenerated
34
- TOKEN_EXPIRATION_GUARD_SEC = 120
27
+ @@globals = { # rubocop:disable Style/ClassVars
28
+ # remove 5 minutes to account for time offset between client and server (TODO: configurable?)
29
+ jwt_accepted_offset_sec: 300,
30
+ # one hour validity (TODO: configurable?)
31
+ jwt_expiry_offset_sec: 3600,
32
+ # tokens older than 30 minutes will be discarded from cache
33
+ token_cache_expiry_sec: 1800,
34
+ # tokens valid for less than this duration will be regenerated
35
+ token_expiration_guard_sec: 120
36
+ }
37
+
35
38
  # a prefix for persistency of tokens (simplify garbage collect)
36
39
  PERSIST_CATEGORY_TOKEN = 'token'
40
+ # prefix for bearer token when in header
41
+ BEARER_PREFIX = 'Bearer '
37
42
 
38
- private_constant :JWT_ACCEPTED_OFFSET_SEC, :JWT_EXPIRY_OFFSET_SEC, :TOKEN_CACHE_EXPIRY_SEC, :PERSIST_CATEGORY_TOKEN, :TOKEN_EXPIRATION_GUARD_SEC
43
+ private_constant :PERSIST_CATEGORY_TOKEN, :BEARER_PREFIX
39
44
 
40
45
  # persistency manager
41
46
  @persist = nil
@@ -45,10 +50,23 @@ module Aspera
45
50
  @id_handlers = {}
46
51
 
47
52
  class << self
53
+ def bearer_build(token)
54
+ return BEARER_PREFIX + token
55
+ end
56
+
57
+ def bearer_extract(token)
58
+ raise 'not a bearer token, wrong prefix' unless bearer?(token)
59
+ return token[BEARER_PREFIX.length..-1]
60
+ end
61
+
62
+ def bearer?(token)
63
+ return token.start_with?(BEARER_PREFIX)
64
+ end
65
+
48
66
  def persist_mgr=(manager)
49
67
  @persist = manager
50
68
  # cleanup expired tokens
51
- @persist.garbage_collect(PERSIST_CATEGORY_TOKEN, TOKEN_CACHE_EXPIRY_SEC)
69
+ @persist.garbage_collect(PERSIST_CATEGORY_TOKEN, @@globals[:token_cache_expiry_sec])
52
70
  end
53
71
 
54
72
  def persist_mgr
@@ -153,9 +171,9 @@ module Aspera
153
171
  Log.log.info{"seconds=#{seconds_since_epoch}"}
154
172
  raise 'missing JWT payload' unless oauth.specific_parameters[:payload].is_a?(Hash)
155
173
  jwt_payload = {
156
- exp: seconds_since_epoch + JWT_EXPIRY_OFFSET_SEC, # expiration time
157
- nbf: seconds_since_epoch - JWT_ACCEPTED_OFFSET_SEC, # not before
158
- iat: seconds_since_epoch - JWT_ACCEPTED_OFFSET_SEC + 1, # issued at (we tell a little in the past so that server always accepts)
174
+ exp: seconds_since_epoch + @@globals[:jwt_expiry_offset_sec], # expiration time
175
+ nbf: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec], # not before
176
+ iat: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec] + 1, # issued at (we tell a little in the past so that server always accepts)
159
177
  jti: SecureRandom.uuid # JWT id
160
178
  }.merge(oauth.specific_parameters[:payload])
161
179
  Log.log.debug{"JWT jwt_payload=[#{jwt_payload}]"}
@@ -192,7 +210,7 @@ module Aspera
192
210
  # replace default values
193
211
  @generic_parameters = DEFAULT_CREATE_PARAMS.deep_merge(a_params)
194
212
  # legacy
195
- @generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype)
213
+ @generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype) # cspell: disable-line
196
214
  # check that type is known
197
215
  self.class.token_creator(@generic_parameters[:grant_method])
198
216
  # specific parameters for the creation type
@@ -213,8 +231,8 @@ module Aspera
213
231
  @generic_parameters.delete(:base_url)
214
232
  @generic_parameters.delete(:auth)
215
233
  @generic_parameters.delete(@generic_parameters[:grant_method])
216
- Log.dump(:generic_parameters, @generic_parameters)
217
- Log.dump(:specific_parameters, @specific_parameters)
234
+ Log.log.debug{Log.dump(:generic_parameters, @generic_parameters)}
235
+ Log.log.debug{Log.dump(:specific_parameters, @specific_parameters)}
218
236
  end
219
237
 
220
238
  public
@@ -259,14 +277,14 @@ module Aspera
259
277
  # `direct` agent is equipped with refresh code
260
278
  if !use_refresh_token && !token_data.nil?
261
279
  decoded_token = self.class.decode_token(token_data[@generic_parameters[:token_field]])
262
- Log.dump('decoded_token', decoded_token) unless decoded_token.nil?
280
+ Log.log.debug{Log.dump('decoded_token', decoded_token)} unless decoded_token.nil?
263
281
  if decoded_token.is_a?(Hash)
264
282
  expires_at_sec =
265
283
  if decoded_token['expires_at'].is_a?(String) then DateTime.parse(decoded_token['expires_at']).to_time
266
284
  elsif decoded_token['exp'].is_a?(Integer) then Time.at(decoded_token['exp'])
267
285
  end
268
286
  # force refresh if we see a token too close from expiration
269
- use_refresh_token = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) < TOKEN_EXPIRATION_GUARD_SEC
287
+ use_refresh_token = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) < @@globals[:token_expiration_guard_sec]
270
288
  Log.log.debug{"Expiration: #{expires_at_sec} / #{use_refresh_token}"}
271
289
  end
272
290
  end
@@ -306,7 +324,7 @@ module Aspera
306
324
  end # if ! in_cache
307
325
  raise "API error: No such field in answer: #{@generic_parameters[:token_field]}" unless token_data.key?(@generic_parameters[:token_field])
308
326
  # ok we shall have a token here
309
- return 'Bearer ' + token_data[@generic_parameters[:token_field]]
327
+ return self.class.bearer_build(token_data[@generic_parameters[:token_field]])
310
328
  end
311
329
  end # OAuth
312
330
  end # Aspera
@@ -27,7 +27,7 @@ module Aspera
27
27
  def uri_graphical(uri)
28
28
  case Aspera::Environment.os
29
29
  when Aspera::Environment::OS_X then return system('open', uri.to_s)
30
- when Aspera::Environment::OS_WINDOWS then return system('start', 'explorer', '"' + uri.to_s + '"')
30
+ when Aspera::Environment::OS_WINDOWS then return system('start', 'explorer', %Q{"#{uri}"})
31
31
  when Aspera::Environment::OS_LINUX then return system('xdg-open', uri.to_s)
32
32
  else
33
33
  raise "no graphical open method for #{Aspera::Environment.os}"
@@ -38,7 +38,7 @@ module Aspera
38
38
  if ENV.key?('EDITOR')
39
39
  system(ENV['EDITOR'], file_path.to_s)
40
40
  elsif Aspera::Environment.os.eql?(Aspera::Environment::OS_WINDOWS)
41
- system('notepad.exe', '"' + file_path.to_s + '"')
41
+ system('notepad.exe', %Q{"#{file_path}"})
42
42
  else
43
43
  uri_graphical(file_path.to_s)
44
44
  end
@@ -59,9 +59,9 @@ module Aspera
59
59
  when :text
60
60
  case the_url.to_s
61
61
  when /^http/
62
- puts "USER ACTION: please enter this url in a browser:\n" + the_url.to_s.red + "\n"
62
+ puts "USER ACTION: please enter this url in a browser:\n#{the_url.to_s.red}\n"
63
63
  else
64
- puts "USER ACTION: open this:\n" + the_url.to_s.red + "\n"
64
+ puts "USER ACTION: open this:\n#{the_url.to_s.red}\n"
65
65
  end
66
66
  else
67
67
  raise StandardError, "unsupported url open method: #{@url_method}"
@@ -8,7 +8,7 @@ module Aspera
8
8
  class PersistencyActionOnce
9
9
  # @param :manager Mandatory Database
10
10
  # @param :data Mandatory object to persist, must be same object from begin to end (assume array by default)
11
- # @param :id Mandatory identifiers
11
+ # @param :id Mandatory identifiers
12
12
  # @param :delete Optional delete persistency condition
13
13
  # @param :parse Optional parse method (default to JSON)
14
14
  # @param :format Optional dump method (default to JSON)
@@ -37,7 +37,7 @@ module Aspera
37
37
  raise 'value: only String supported' unless value.is_a?(String)
38
38
  persist_filepath = id_to_filepath(object_id)
39
39
  Log.log.debug{"persistency saving: #{persist_filepath}"}
40
- File.delete(persist_filepath) if File.exist?(persist_filepath)
40
+ FileUtils.rm_f(persist_filepath)
41
41
  File.write(persist_filepath, value)
42
42
  Environment.restrict_file_access(persist_filepath)
43
43
  @cache[object_id] = value
@@ -46,7 +46,7 @@ module Aspera
46
46
  def delete(object_id)
47
47
  persist_filepath = id_to_filepath(object_id)
48
48
  Log.log.debug{"persistency deleting: #{persist_filepath}"}
49
- File.delete(persist_filepath) if File.exist?(persist_filepath)
49
+ FileUtils.rm_f(persist_filepath)
50
50
  @cache.delete(object_id)
51
51
  end
52
52
 
@@ -281,7 +281,7 @@ module Aspera
281
281
 
282
282
  # use mime magic to find mime type based on file content (magic numbers)
283
283
  def mime_from_file(filepath)
284
- # moved here, as mimemagic can cause installation issues
284
+ # moved here, as `mimemagic` can cause installation issues
285
285
  require 'mimemagic'
286
286
  require 'mimemagic/version'
287
287
  require 'mimemagic/overlay' if MimeMagic::VERSION.start_with?('0.3.')
@@ -297,9 +297,10 @@ module Aspera
297
297
  return detected_mime
298
298
  end
299
299
 
300
- # return file type, one of enum CONVERSION_TYPES
301
300
  # @param filepath [String] full path to file
302
301
  # @param mimetype [String] provided by node api
302
+ # @return file type, one of enum CONVERSION_TYPES
303
+ # @raise [RuntimeError] if no conversion type found
303
304
  def conversion_type(filepath, mimetype)
304
305
  Log.log.debug{"conversion_type(#{filepath},m=#{mimetype},t=#{@use_mimemagic})"}
305
306
  # 1- get type from provided mime type, using local mapping
@@ -322,6 +323,7 @@ module Aspera
322
323
  # 3- else, from extensions, using local mapping
323
324
  extension = File.extname(filepath.downcase)[1..-1]
324
325
  conversion_type = SUPPORTED_EXTENSIONS[extension] if conversion_type.nil?
326
+ raise "no conversion type found for extension #{extension}" if conversion_type.nil?
325
327
  Log.log.debug{"conversion_type(#{extension}): #{conversion_type.class.name} [#{conversion_type}]"}
326
328
  return conversion_type
327
329
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ffmpeg options:
4
4
  # spellchecker:ignore pauseframes libx264 trunc bufsize muxer apng libmp3lame maxrate posterize movflags faststart
5
- # spellchecker:ignore palettegen paletteuse pointsize bordercolor repage lanczos
5
+ # spellchecker:ignore palettegen paletteuse pointsize bordercolor repage lanczos unoconv optipng reencode conv transframes
6
6
 
7
7
  require 'open3'
8
8
  require 'aspera/preview/options'
@@ -19,6 +19,7 @@ module Aspera
19
19
  FFMPEG_OPTIONS_LIST = %w[in out].freeze
20
20
 
21
21
  # CLI needs to know conversion type to know if need skip it
22
+ # one of CONVERSION_TYPES
22
23
  attr_reader :conversion_type
23
24
 
24
25
  # @param src source file path
@@ -32,55 +33,40 @@ module Aspera
32
33
  # -> preview_format is one of Generator::PREVIEW_FORMATS
33
34
  # the conversion video->mp4 is implemented in methods: convert_video_to_mp4_using_<video_conversion>
34
35
  # -> conversion method is one of Generator::VIDEO_CONVERSION_METHODS
35
- def initialize(options, src, dst, main_temp_dir, api_mime_type)
36
- @options = options
36
+ def initialize(src, dst, options, main_temp_dir, api_mime_type)
37
37
  @source_file_path = src
38
38
  @destination_file_path = dst
39
+ @options = options
39
40
  @temp_folder = File.join(main_temp_dir, @source_file_path.split('/').last.gsub(/\s/, '_').gsub(/\W/, ''))
40
41
  # extract preview format from extension of target file
41
- @preview_format_symb = File.extname(@destination_file_path).gsub(/^\./, '').to_sym
42
- @conversion_type = FileTypes.instance.conversion_type(@source_file_path, api_mime_type)
43
- end
44
-
45
- # name of processing method in this object
46
- # combination of: conversion type and output format (and video_conversion for video)
47
- def processing_method_symb
48
- name = "convert_#{@conversion_type}_to_#{@preview_format_symb}"
49
- if @conversion_type.eql?(:video)
50
- case @preview_format_symb
42
+ @preview_format_sym = File.extname(@destination_file_path).gsub(/^\./, '').to_sym
43
+ conversion_type = FileTypes.instance.conversion_type(@source_file_path, api_mime_type)
44
+ @processing_method = "convert_#{conversion_type}_to_#{@preview_format_sym}"
45
+ if conversion_type.eql?(:video)
46
+ case @preview_format_sym
51
47
  when :mp4
52
- name = "#{name}_using_#{@options.video_conversion}"
48
+ @processing_method = "#{@processing_method}_using_#{@options.video_conversion}"
53
49
  when :png
54
- name = "#{name}_using_#{@options.video_png_conv}"
50
+ @processing_method = "#{@processing_method}_using_#{@options.video_png_conv}"
55
51
  end
56
52
  end
57
- Log.log.debug{"method: #{name}"}
58
- return name.to_sym
59
- end
60
-
61
- # @return true if conversion is supported.
62
- # for instance, plaintext to mp4 is not supported
63
- def supported?
64
- return false if @conversion_type.nil?
65
- return respond_to?(processing_method_symb, true)
53
+ @processing_method = @processing_method.to_sym
54
+ Log.log.debug{"method: #{@processing_method}"}
55
+ raise "no processing know for #{conversion_type} -> #{@preview_format_sym}" unless respond_to?(@processing_method, true)
66
56
  end
67
57
 
68
58
  # create preview as specified in constructor
69
59
  def generate
70
- raise 'could not detect type of file' if @conversion_type.nil?
71
- method_symb = processing_method_symb
72
- Log.log.info{"#{@source_file_path}->#{@destination_file_path} (#{method_symb})"}
60
+ Log.log.info{"#{@source_file_path}->#{@destination_file_path} (#{@processing_method})"}
73
61
  begin
74
- send(method_symb)
62
+ send(@processing_method)
75
63
  # check that generated size does not exceed maximum
76
64
  result_size = File.size(@destination_file_path)
77
- if result_size > @options.max_size
78
- Log.log.warn{"preview size exceeds maximum allowed #{result_size} > #{@options.max_size}"}
79
- end
65
+ Log.log.warn{"preview size exceeds maximum allowed #{result_size} > #{@options.max_size}"} if result_size > @options.max_size
80
66
  rescue StandardError => e
81
67
  Log.log.error{"Ignoring: #{e.message}"}
82
68
  Log.log.debug(e.backtrace.join("\n").red)
83
- FileUtils.cp(File.expand_path(@preview_format_symb.eql?(:mp4) ? 'video_error.png' : 'image_error.png', File.dirname(__FILE__)), @destination_file_path)
69
+ FileUtils.cp(File.expand_path(@preview_format_sym.eql?(:mp4) ? 'video_error.png' : 'image_error.png', File.dirname(__FILE__)), @destination_file_path)
84
70
  ensure
85
71
  FileUtils.rm_rf(@temp_folder)
86
72
  end
@@ -134,8 +120,8 @@ module Aspera
134
120
  # generate n clips starting at offset
135
121
  def convert_video_to_mp4_using_clips
136
122
  p_duration = Utils.video_get_duration(@source_file_path)
137
- filelist = File.join(this_tmpdir, 'clip_files.txt')
138
- File.open(filelist, 'w+') do |f|
123
+ file_list_file = File.join(this_tmpdir, 'clip_files.txt')
124
+ File.open(file_list_file, 'w+') do |f|
139
125
  1.upto(@options.clips_count.to_i) do |i|
140
126
  offset_seconds = get_offset(p_duration, @options.video_start_sec.to_i, @options.clips_count.to_i, i)
141
127
  tmp_file_name = format('clip%04d.mp4', i)
@@ -153,10 +139,11 @@ module Aspera
153
139
  end
154
140
  # concat clips
155
141
  Utils.ffmpeg(
156
- in_f: filelist,
142
+ in_f: file_list_file,
157
143
  in_p: ['-f', 'concat'],
158
144
  out_f: @destination_file_path,
159
145
  out_p: ['-codec', 'copy'])
146
+ File.delete(file_list_file)
160
147
  end
161
148
 
162
149
  # do a simple re-encoding
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:ignore reencode conv transframes pauseframes
4
+
3
5
  module Aspera
4
6
  module Preview
5
7
  # generator options. Used as parameter to preview generator object.
@@ -1,34 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:words Magick MAGICKCORE ITERM mintty winsize termcap
4
+
3
5
  require 'rmagick' # https://rmagick.github.io/index.html
4
6
  require 'rainbow'
5
7
  require 'io/console'
6
8
  module Aspera
7
9
  module Preview
8
- # function conversion_type returns one of the types: CONVERSION_TYPES
10
+ # Display a picture in the terminal, either using coloured characters or iTerm2
9
11
  class Terminal
12
+ # Rainbow only supports 8-bit colors
13
+ # quantum depth is 8 or 16, see: `convert xc: -format "%q" info:`
14
+ SHIFT_FOR_8_BIT = Magick::MAGICKCORE_QUANTUM_DEPTH - 8
15
+ # env vars to detect terminal type
16
+ TERM_ENV_VARS = %w[TERM_PROGRAM LC_TERMINAL].freeze
17
+ # terminal names that support iTerm2 image display
18
+ ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
19
+ # TODO: retrieve terminal font ratio using some termcap ?
20
+ # ratio = font height / font width
21
+ DEFAULT_FONT_RATIO = 32.0 / 14.0
22
+ private_constant :SHIFT_FOR_8_BIT, :TERM_ENV_VARS, :ITERM_NAMES, :DEFAULT_FONT_RATIO
10
23
  class << self
11
- def build(blob, reserved_lines: 0)
12
- # TODO: retrieve terminal ratio using
13
- font_ratio = 1.7
14
- (term_rows, term_columns) = IO.console.winsize
15
- term_rows -= reserved_lines
24
+ # @return [String] the image as text, or the iTerm2 escape sequence
25
+ # @param blob [String] the image as a binary string
26
+ # @param reserve [Integer] number of lines to reserve for other text than the image
27
+ # @param text [Boolean] true to display the image as text, false to use iTerm2
28
+ # @param double [Boolean] true to use colors on half lines, false to use colors on full lines
29
+ # @param font_ratio [Float] ratio = font height / font width
30
+ def build(blob, reserve: 3, text: false, double: true, font_ratio: DEFAULT_FONT_RATIO)
31
+ return iterm_display_image(blob) if iterm_supported? && !text
16
32
  image = Magick::ImageList.new.from_blob(blob)
17
- chosen_factor = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
18
- image = image.scale((image.columns * chosen_factor * font_ratio).to_i, (image.rows * chosen_factor).to_i)
19
- text_pixels = []
33
+ (term_rows, term_columns) = IO.console.winsize
34
+ term_rows -= reserve
35
+ # compute scaling to fit terminal
36
+ fit_term_ratio = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
37
+ height_ratio = double ? 2.0 : 1.0
38
+ image = image.scale((image.columns * fit_term_ratio * font_ratio).to_i, (image.rows * fit_term_ratio * height_ratio).to_i)
39
+ # get all pixel colors, adjusted for Rainbow
40
+ pixel_colors = []
20
41
  image.each_pixel do |pixel, col, row|
21
- text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
22
- pixel_rgb = [pixel.red, pixel.green, pixel.blue].map do |color|
23
- # quantum depth is 8 or 16: convert xc: -format "%q" info:
24
- # Rainbow only supports 8-bit colors
25
- color >> (Magick::MAGICKCORE_QUANTUM_DEPTH - 8)
42
+ pixel_rgb = [pixel.red, pixel.green, pixel.blue]
43
+ pixel_rgb = pixel_rgb.map { |color| color >> SHIFT_FOR_8_BIT } unless SHIFT_FOR_8_BIT.eql?(0)
44
+ # init 2-dim array
45
+ pixel_colors[row] ||= []
46
+ pixel_colors[row][col] = pixel_rgb
47
+ end
48
+ # now generate text
49
+ text_pixels = []
50
+ pixel_colors.each_with_index do |row_data, row|
51
+ next if double && (row.odd? || row.eql?(pixel_colors.length - 1))
52
+ row_data.each_with_index do |pixel_rgb, col|
53
+ text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
54
+ if double
55
+ text_pixels.push(Rainbow('▄').background(pixel_rgb).foreground(pixel_colors[row + 1][col]))
56
+ else
57
+ text_pixels.push(Rainbow(' ').background(pixel_rgb))
58
+ end
26
59
  end
27
- text_pixels.push(Rainbow(' ').background(pixel_rgb))
28
60
  end
29
61
  return text_pixels.join
30
62
  end
31
- end # class << self
63
+
64
+ # display image in iTerm2
65
+ # https://iterm2.com/documentation-images.html
66
+ def iterm_display_image(blob)
67
+ # image = Magick::ImageList.new.from_blob(blob)
68
+ # parameters for iTerm2 image display
69
+ arguments = {
70
+ inline: 1,
71
+ preserveAspectRatio: 1,
72
+ size: blob.length
73
+ # width: image.columns,
74
+ # height: image.rows
75
+ }.map { |k, v| "#{k}=#{v}" }.join(';')
76
+ # \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
77
+ # escape sequence for iTerm2 image display
78
+ return "\e]1337;File=#{arguments}:#{Base64.encode64(blob)}\a"
79
+ end
80
+
81
+ # @return [Boolean] true if the terminal supports iTerm2 image display
82
+ def iterm_supported?
83
+ TERM_ENV_VARS.each do |env_var|
84
+ return true if ITERM_NAMES.any? { |term| ENV[env_var]&.include?(term) }
85
+ end
86
+ false
87
+ end
88
+ end # class << self
32
89
  end # class Terminal
33
90
  end # module Preview
34
91
  end # module Aspera
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:ignore ffprobe optipng unoconv
3
4
  require 'English'
4
5
  require 'tmpdir'
5
6
  require 'fileutils'
@@ -11,18 +12,17 @@ module Aspera
11
12
  class Utils
12
13
  # from bash manual: meta-character need to be escaped
13
14
  BASH_SPECIAL_CHARACTERS = "|&;()<> \t#\n"
14
- # shell exit code when command is not found
15
- BASH_EXIT_NOT_FOUND = 127
16
15
  # external binaries used
17
16
  EXTERNAL_TOOLS = %i[ffmpeg ffprobe convert composite optipng unoconv].freeze
18
17
  TEMP_FORMAT = 'img%04d.jpg'
19
- private_constant :BASH_SPECIAL_CHARACTERS, :BASH_EXIT_NOT_FOUND, :EXTERNAL_TOOLS, :TEMP_FORMAT
18
+ private_constant :BASH_SPECIAL_CHARACTERS, :EXTERNAL_TOOLS, :TEMP_FORMAT
20
19
 
21
20
  class << self
22
- # returns string with single quotes suitable for bash if there is any bash metacharacter
21
+ # returns string with single quotes suitable for bash if there is any bash meta-character
23
22
  def shell_quote(argument)
24
23
  return argument unless argument.chars.any?{|c|BASH_SPECIAL_CHARACTERS.include?(c)}
25
- return "'" + argument.gsub(/'/){|_s| "'\"'\"'"} + "'"
24
+ # surround with single quotes, and escape single quotes
25
+ return %Q{'#{argument.gsub("'"){|_s| %q{'"'"'}}}'}
26
26
  end
27
27
 
28
28
  # check that external tools can be executed
@@ -30,38 +30,31 @@ module Aspera
30
30
  tools_to_check = EXTERNAL_TOOLS.dup
31
31
  tools_to_check.delete(:unoconv) if skip_types.include?(:office)
32
32
  # Check for binaries
33
- tools_to_check.each do |command_symb|
34
- external_command(command_symb, ['-h'])
33
+ tools_to_check.each do |command_sym|
34
+ external_command(command_sym, ['-h'], log_error: false)
35
+ rescue Errno::ENOENT => e
36
+ raise "missing #{command_sym} binary: #{e}"
37
+ rescue
38
+ nil
35
39
  end
36
40
  end
37
41
 
38
42
  # execute external command
39
43
  # one could use "system", but we would need to redirect stdout/err
40
44
  # @return true if su
41
- def external_command(command_symb, command_args)
42
- raise "unexpected command #{command_symb}" unless EXTERNAL_TOOLS.include?(command_symb)
45
+ def external_command(command_sym, command_args, log_error: true)
46
+ raise "unexpected command #{command_sym}" unless EXTERNAL_TOOLS.include?(command_sym)
43
47
  # build command line, and quote special characters
44
- command = command_args.clone.unshift(command_symb).map{|i| shell_quote(i.to_s)}.join(' ')
45
- Log.log.debug{"cmd=#{command}".blue}
46
- # capture3: only in ruby2+
47
- if Open3.respond_to?(:capture3)
48
- stdout, stderr, exit_status = Open3.capture3(command)
49
- else
50
- stderr = '<merged with stdout>'
51
- stdout = %x(#{command} 2>&1)
52
- exit_status = $CHILD_STATUS
53
- end
54
- if BASH_EXIT_NOT_FOUND.eql?(exit_status)
55
- raise "Error: #{command_symb} is not in the PATH"
56
- end
57
- unless exit_status.success?
58
- Log.log.error{"commandline: #{command}"}
59
- Log.log.error{"Error code: #{exit_status}"}
48
+ command_line = command_args.clone.unshift(command_sym).map{|i| shell_quote(i.to_s)}.join(' ')
49
+ Log.log.debug{"cmd=#{command_line}".blue}
50
+ stdout, stderr, status = Open3.capture3(command_line)
51
+ if log_error && !status.success?
52
+ Log.log.error{"status: #{status}"}
60
53
  Log.log.error{"stdout: #{stdout}"}
61
54
  Log.log.error{"stderr: #{stderr}"}
62
- raise "#{command_symb} error #{exit_status}"
55
+ raise "#{command_sym} error #{status}"
63
56
  end
64
- return {status: exit_status, stdout: stdout}
57
+ return {status: status, stdout: stdout}
65
58
  end
66
59
 
67
60
  def ffmpeg(a)
@@ -82,7 +75,7 @@ module Aspera
82
75
  result = external_command(:ffprobe, [
83
76
  '-loglevel', 'error',
84
77
  '-show_entries', 'format=duration',
85
- '-print_format', 'default=noprint_wrappers=1:nokey=1',
78
+ '-print_format', 'default=noprint_wrappers=1:nokey=1', # cspell:disable-line
86
79
  input_file])
87
80
  return result[:stdout].to_f
88
81
  end
@@ -70,7 +70,7 @@ function shExpMatch(str, shell_expr) {
70
70
  }
71
71
  function weekdayRange(wd1, wd2, gmt) {
72
72
  var today = new Date();
73
- var days = 'SUNMONTUEWEDTHUFRISAT';
73
+ var days = 'SUNMONTUEWEDTHUFRISAT'; // cspell: disable-line
74
74
  wd1 = wd1.toUpperCase();
75
75
  if (wd2 == undefined)
76
76
  wd2 = wd1;
@@ -138,7 +138,7 @@ function dateRange() {
138
138
  else
139
139
  return false;
140
140
  } else if (typeof arg == 'string') {
141
- var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC';
141
+ var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC'; // cspell: disable-line
142
142
  arg = arg.toUpperCase();
143
143
  arg = months.indexOf(arg);
144
144
  if (arg == -1)