aspera-cli 4.14.0 → 4.15.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.
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)