aspera-cli 4.14.0 → 4.15.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +54 -3
  4. data/CONTRIBUTING.md +7 -7
  5. data/README.md +1457 -880
  6. data/bin/ascli +18 -9
  7. data/bin/asession +12 -14
  8. data/examples/proxy.pac +1 -1
  9. data/lib/aspera/aoc.rb +198 -127
  10. data/lib/aspera/ascmd.rb +24 -14
  11. data/lib/aspera/cli/basic_auth_plugin.rb +9 -6
  12. data/lib/aspera/cli/error.rb +17 -0
  13. data/lib/aspera/cli/extended_value.rb +47 -12
  14. data/lib/aspera/cli/formatter.rb +260 -171
  15. data/lib/aspera/cli/hints.rb +80 -0
  16. data/lib/aspera/cli/main.rb +101 -147
  17. data/lib/aspera/cli/manager.rb +160 -124
  18. data/lib/aspera/cli/plugin.rb +70 -59
  19. data/lib/aspera/cli/plugins/alee.rb +0 -1
  20. data/lib/aspera/cli/plugins/aoc.rb +239 -273
  21. data/lib/aspera/cli/plugins/ats.rb +8 -5
  22. data/lib/aspera/cli/plugins/bss.rb +2 -2
  23. data/lib/aspera/cli/plugins/config.rb +516 -375
  24. data/lib/aspera/cli/plugins/console.rb +40 -0
  25. data/lib/aspera/cli/plugins/cos.rb +4 -5
  26. data/lib/aspera/cli/plugins/faspex.rb +99 -84
  27. data/lib/aspera/cli/plugins/faspex5.rb +179 -148
  28. data/lib/aspera/cli/plugins/node.rb +219 -153
  29. data/lib/aspera/cli/plugins/orchestrator.rb +52 -17
  30. data/lib/aspera/cli/plugins/preview.rb +46 -32
  31. data/lib/aspera/cli/plugins/server.rb +57 -17
  32. data/lib/aspera/cli/plugins/shares.rb +34 -12
  33. data/lib/aspera/cli/sync_actions.rb +68 -0
  34. data/lib/aspera/cli/transfer_agent.rb +45 -55
  35. data/lib/aspera/cli/transfer_progress.rb +74 -0
  36. data/lib/aspera/cli/version.rb +1 -1
  37. data/lib/aspera/colors.rb +3 -1
  38. data/lib/aspera/command_line_builder.rb +14 -11
  39. data/lib/aspera/cos_node.rb +3 -2
  40. data/lib/aspera/environment.rb +17 -6
  41. data/lib/aspera/fasp/agent_aspera.rb +126 -0
  42. data/lib/aspera/fasp/agent_base.rb +31 -77
  43. data/lib/aspera/fasp/agent_connect.rb +21 -22
  44. data/lib/aspera/fasp/agent_direct.rb +88 -102
  45. data/lib/aspera/fasp/agent_httpgw.rb +196 -192
  46. data/lib/aspera/fasp/agent_node.rb +41 -34
  47. data/lib/aspera/fasp/agent_trsdk.rb +75 -34
  48. data/lib/aspera/fasp/error_info.rb +2 -2
  49. data/lib/aspera/fasp/faux_file.rb +52 -0
  50. data/lib/aspera/fasp/installation.rb +43 -184
  51. data/lib/aspera/fasp/management.rb +244 -0
  52. data/lib/aspera/fasp/parameters.rb +59 -26
  53. data/lib/aspera/fasp/parameters.yaml +75 -8
  54. data/lib/aspera/fasp/products.rb +162 -0
  55. data/lib/aspera/fasp/transfer_spec.rb +1 -1
  56. data/lib/aspera/fasp/uri.rb +4 -4
  57. data/lib/aspera/faspex_gw.rb +2 -2
  58. data/lib/aspera/faspex_postproc.rb +2 -2
  59. data/lib/aspera/hash_ext.rb +2 -2
  60. data/lib/aspera/json_rpc.rb +49 -0
  61. data/lib/aspera/line_logger.rb +23 -0
  62. data/lib/aspera/log.rb +57 -16
  63. data/lib/aspera/node.rb +97 -14
  64. data/lib/aspera/oauth.rb +36 -18
  65. data/lib/aspera/open_application.rb +4 -4
  66. data/lib/aspera/persistency_folder.rb +2 -2
  67. data/lib/aspera/preview/file_types.rb +4 -2
  68. data/lib/aspera/preview/generator.rb +22 -35
  69. data/lib/aspera/preview/options.rb +2 -0
  70. data/lib/aspera/preview/terminal.rb +24 -13
  71. data/lib/aspera/preview/utils.rb +19 -26
  72. data/lib/aspera/rest.rb +103 -72
  73. data/lib/aspera/rest_call_error.rb +1 -1
  74. data/lib/aspera/rest_error_analyzer.rb +15 -14
  75. data/lib/aspera/rest_errors_aspera.rb +37 -34
  76. data/lib/aspera/secret_hider.rb +14 -16
  77. data/lib/aspera/ssh.rb +4 -1
  78. data/lib/aspera/sync.rb +128 -122
  79. data/lib/aspera/temp_file_manager.rb +10 -3
  80. data/lib/aspera/web_auth.rb +10 -7
  81. data/lib/aspera/web_server_simple.rb +9 -4
  82. data.tar.gz.sig +0 -0
  83. metadata +33 -15
  84. metadata.gz.sig +0 -0
  85. data/lib/aspera/cli/listener/line_dump.rb +0 -19
  86. data/lib/aspera/cli/listener/logger.rb +0 -22
  87. data/lib/aspera/cli/listener/progress.rb +0 -50
  88. data/lib/aspera/cli/listener/progress_multi.rb +0 -84
  89. data/lib/aspera/cli/plugins/sync.rb +0 -44
  90. 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}]"}
@@ -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}"
@@ -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.
@@ -7,25 +7,34 @@ require 'rainbow'
7
7
  require 'io/console'
8
8
  module Aspera
9
9
  module Preview
10
- # Generates a string that can display an image in a terminal
10
+ # Display a picture in the terminal, either using coloured characters or iTerm2
11
11
  class Terminal
12
- # quantum depth is 8 or 16: convert xc: -format "%q" info:
13
12
  # Rainbow only supports 8-bit colors
13
+ # quantum depth is 8 or 16, see: `convert xc: -format "%q" info:`
14
14
  SHIFT_FOR_8_BIT = Magick::MAGICKCORE_QUANTUM_DEPTH - 8
15
- ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
15
+ # env vars to detect terminal type
16
16
  TERM_ENV_VARS = %w[TERM_PROGRAM LC_TERMINAL].freeze
17
- private_constant :SHIFT_FOR_8_BIT, :ITERM_NAMES, :TERM_ENV_VARS
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
18
23
  class << self
19
- def build(blob, reserved_lines: 0, double_precision: true)
20
- return iterm_display_image(blob) if iterm_supported?
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
21
32
  image = Magick::ImageList.new.from_blob(blob)
22
33
  (term_rows, term_columns) = IO.console.winsize
23
- term_rows -= reserved_lines
34
+ term_rows -= reserve
24
35
  # compute scaling to fit terminal
25
36
  fit_term_ratio = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
26
- # TODO: retrieve terminal font ratio using some termcap ?
27
- font_ratio = 1.7
28
- height_ratio = double_precision ? 2.0 : 1.0
37
+ height_ratio = double ? 2.0 : 1.0
29
38
  image = image.scale((image.columns * fit_term_ratio * font_ratio).to_i, (image.rows * fit_term_ratio * height_ratio).to_i)
30
39
  # get all pixel colors, adjusted for Rainbow
31
40
  pixel_colors = []
@@ -39,10 +48,10 @@ module Aspera
39
48
  # now generate text
40
49
  text_pixels = []
41
50
  pixel_colors.each_with_index do |row_data, row|
42
- next if double_precision && row.odd?
51
+ next if double && (row.odd? || row.eql?(pixel_colors.length - 1))
43
52
  row_data.each_with_index do |pixel_rgb, col|
44
53
  text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
45
- if double_precision
54
+ if double
46
55
  text_pixels.push(Rainbow('▄').background(pixel_rgb).foreground(pixel_colors[row + 1][col]))
47
56
  else
48
57
  text_pixels.push(Rainbow(' ').background(pixel_rgb))
@@ -53,8 +62,10 @@ module Aspera
53
62
  end
54
63
 
55
64
  # display image in iTerm2
65
+ # https://iterm2.com/documentation-images.html
56
66
  def iterm_display_image(blob)
57
67
  # image = Magick::ImageList.new.from_blob(blob)
68
+ # parameters for iTerm2 image display
58
69
  arguments = {
59
70
  inline: 1,
60
71
  preserveAspectRatio: 1,
@@ -63,7 +74,7 @@ module Aspera
63
74
  # height: image.rows
64
75
  }.map { |k, v| "#{k}=#{v}" }.join(';')
65
76
  # \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
66
- # https://iterm2.com/documentation-images.html
77
+ # escape sequence for iTerm2 image display
67
78
  return "\e]1337;File=#{arguments}:#{Base64.encode64(blob)}\a"
68
79
  end
69
80
 
@@ -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
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{"command line: #{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)