aspera-cli 4.25.6 → 4.26.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 (40) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +74 -47
  4. data/CONTRIBUTING.md +1 -1
  5. data/lib/aspera/api/aoc.rb +118 -78
  6. data/lib/aspera/api/node.rb +101 -49
  7. data/lib/aspera/ascp/installation.rb +94 -30
  8. data/lib/aspera/cli/extended_value.rb +1 -0
  9. data/lib/aspera/cli/formatter.rb +47 -40
  10. data/lib/aspera/cli/manager.rb +30 -4
  11. data/lib/aspera/cli/plugins/aoc.rb +214 -136
  12. data/lib/aspera/cli/plugins/ats.rb +3 -3
  13. data/lib/aspera/cli/plugins/base.rb +17 -42
  14. data/lib/aspera/cli/plugins/config.rb +5 -3
  15. data/lib/aspera/cli/plugins/console.rb +3 -3
  16. data/lib/aspera/cli/plugins/faspex.rb +5 -5
  17. data/lib/aspera/cli/plugins/faspex5.rb +20 -18
  18. data/lib/aspera/cli/plugins/node.rb +66 -70
  19. data/lib/aspera/cli/plugins/oauth.rb +5 -12
  20. data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
  21. data/lib/aspera/cli/plugins/preview.rb +116 -80
  22. data/lib/aspera/cli/plugins/server.rb +2 -10
  23. data/lib/aspera/cli/plugins/shares.rb +7 -7
  24. data/lib/aspera/cli/version.rb +1 -1
  25. data/lib/aspera/dot_container.rb +7 -3
  26. data/lib/aspera/environment.rb +3 -2
  27. data/lib/aspera/log.rb +1 -1
  28. data/lib/aspera/preview/file_types.rb +1 -1
  29. data/lib/aspera/preview/generator.rb +146 -91
  30. data/lib/aspera/preview/options.rb +4 -1
  31. data/lib/aspera/preview/terminal.rb +50 -20
  32. data/lib/aspera/preview/utils.rb +76 -34
  33. data/lib/aspera/products/transferd.rb +1 -1
  34. data/lib/aspera/rest.rb +1 -0
  35. data/lib/aspera/rest_list.rb +23 -16
  36. data/lib/aspera/secret_hider.rb +3 -1
  37. data/lib/aspera/uri_reader.rb +17 -2
  38. data.tar.gz.sig +0 -0
  39. metadata +5 -5
  40. metadata.gz.sig +0 -0
@@ -14,6 +14,7 @@ require 'aspera/api/node'
14
14
  require 'aspera/hash_ext'
15
15
  require 'aspera/timer_limiter'
16
16
  require 'aspera/id_generator'
17
+ require 'aspera/uri_reader'
17
18
  require 'aspera/log'
18
19
  require 'aspera/assert'
19
20
  require 'securerandom'
@@ -22,31 +23,30 @@ module Aspera
22
23
  module Cli
23
24
  module Plugins
24
25
  class Preview < BasicAuth
25
- # special tag to identify transfers related to generator
26
+ # Reserved transfer tag used to identify preview-generation transfers.
26
27
  PREV_GEN_TAG = 'preview_generator'
27
- # defined by node API: suffix for folder containing previews
28
+ # Node API suffix for the per-file preview folder.
28
29
  PREVIEW_FOLDER_SUFFIX = '.asp-preview'
29
- # basename of preview files
30
+ # Default basename for generated preview files.
30
31
  PREVIEW_BASENAME = 'preview'
31
- # subfolder in system tmp folder
32
+ # Prefix for the temporary working directory created under the system temp folder.
32
33
  TMP_DIR_PREFIX = 'prev_tmp'
33
- # same value as in aspera.conf
34
+ # Default preview root folder name, aligned with `aspera.conf`.
34
35
  DEFAULT_PREVIEWS_FOLDER = 'previews'
35
- # mark that this is used by a particular access key
36
+ # Marker file recording which access key owns a cached preview area.
36
37
  AK_MARKER_FILE = '.aspera_access_key'
37
- # URL prefix for local storage
38
- PVCL_LOCAL_STORAGE = 'file:///'
39
38
  LOG_LIMITER_SEC = 30.0
39
+ REMOTE_ACCESS = 'aspera:'
40
40
  private_constant :PREV_GEN_TAG,
41
41
  :PREVIEW_FOLDER_SUFFIX,
42
42
  :PREVIEW_BASENAME,
43
43
  :TMP_DIR_PREFIX,
44
44
  :DEFAULT_PREVIEWS_FOLDER,
45
- :PVCL_LOCAL_STORAGE,
46
45
  :AK_MARKER_FILE,
47
- :LOG_LIMITER_SEC
46
+ :LOG_LIMITER_SEC,
47
+ :REMOTE_ACCESS
48
48
 
49
- attr_accessor :option_skip_types, :option_previews_folder, :option_folder_reset_cache, :option_skip_folders, :option_overwrite, :option_file_access
49
+ attr_accessor :option_skip_types, :option_previews_folder, :option_folder_reset_cache, :option_skip_folders, :option_overwrite
50
50
 
51
51
  def initialize(**_)
52
52
  super
@@ -55,17 +55,19 @@ module Aspera
55
55
  @option_previews_folder = nil
56
56
  @option_overwrite = nil
57
57
  @option_folder_reset_cache = nil
58
- # options for generation
58
+ # Generator configuration populated from CLI options.
59
59
  @gen_options = Aspera::Preview::Options.new
60
- # used to trigger periodic processing
60
+ # Used to rate-limit periodic progress logging and checkpoint persistence.
61
61
  @periodic = TimerLimiter.new(LOG_LIMITER_SEC)
62
- # Proc
62
+ # Optional callback used to filter entries before generation.
63
63
  @filter_block = nil
64
- # link CLI options to gen_info attributes
64
+ @access_remote = true
65
+ # Bind CLI options directly to generator option attributes.
65
66
  options.declare(
66
67
  :skip_format, 'Skip this preview format',
67
68
  allowed: Aspera::Preview::Generator::PREVIEW_FORMATS
68
69
  )
70
+ # TODO: use the same option as in `node` plugin
69
71
  options.declare(
70
72
  :folder_reset_cache, 'Force detection of generated preview by refresh cache',
71
73
  allowed: %i[no header read],
@@ -81,12 +83,12 @@ module Aspera
81
83
  options.declare(:mimemagic, 'Use Mime type detection of gem mimemagic', allowed: Allowed::TYPES_BOOLEAN, default: false)
82
84
  options.declare(:overwrite, 'When to overwrite result file', handler: {o: self, m: :option_overwrite}, allowed: %i[always never mtime], default: :mtime)
83
85
  options.declare(
84
- :file_access, 'How to read and write files in repository',
85
- allowed: %i[local remote],
86
- handler: {o: self, m: :option_file_access},
87
- default: :local
86
+ :root_url,
87
+ "How to read and write files on storage (<empty>, #{REMOTE_ACCESS}, or #{UriReader.file_url('<folder>')})",
88
+ allowed: Allowed::TYPES_STRING,
89
+ default: ''
88
90
  )
89
- # add other options for generator (and set default values)
91
+ # Declare generator-specific options and apply their default values.
90
92
  Aspera::Preview::Options::DESCRIPTIONS.each do |opt|
91
93
  values = if opt.key?(:values)
92
94
  opt[:values]
@@ -97,7 +99,8 @@ module Aspera
97
99
  end
98
100
 
99
101
  options.parse_options!
100
- # by default generate all supported formats (clone, as altered by options)
102
+ Api::Node.api_options[:cache] = !@option_folder_reset_cache.eql?(:header)
103
+ # Start from the full supported format list, then remove any skipped format.
101
104
  @preview_formats_to_generate = Aspera::Preview::Generator::PREVIEW_FORMATS.clone
102
105
  skip = options.get_option(:skip_format)
103
106
  @preview_formats_to_generate.delete(skip) if skip
@@ -106,22 +109,16 @@ module Aspera
106
109
  Log.log.debug{"tmpdir: #{@tmp_folder}"}
107
110
  end
108
111
 
109
- # /files/id/files is normally cached in Redis, but we can discard the cache
110
- # but /files/id is not cached
111
- def get_folder_entries(file_id, request_args = nil)
112
- headers = {'Accept' => Mime::JSON}
113
- headers['X-Aspera-Cache-Control'] = 'no-cache' if @option_folder_reset_cache.eql?(:header)
114
- return @api_node.read("files/#{file_id}/files", request_args, headers: headers)
115
- end
116
-
117
- # old version based on folders
118
- # @param iteration_persistency can be nil
112
+ # Process legacy transfer events and trigger preview generation for completed downloads.
113
+ #
114
+ # @param iteration_persistency [PersistencyActionOnce, nil] stores the last processed event id
115
+ # @return [void]
119
116
  def process_trevents(iteration_persistency)
120
117
  events_filter = {
121
118
  'access_key' => @access_key_self['id'],
122
119
  'type' => 'download.ended'
123
120
  }
124
- # optionally add iteration token from persistency
121
+ # Resume from the last persisted event id when available.
125
122
  events_filter['iteration_token'] = iteration_persistency.data.first unless iteration_persistency.nil?
126
123
  begin
127
124
  events = @api_node.read('events', events_filter)
@@ -146,10 +143,10 @@ module Aspera
146
143
  scan_folder_files(folder_entry) unless folder_entry.nil?
147
144
  end
148
145
  end
149
- # log/persist periodically or last one
146
+ # Periodically log progress and persist the latest processed event.
150
147
  next unless @periodic.trigger? || event.equal?(events.last)
151
148
  Log.log.debug{"Processed event #{event['id']}"}
152
- # save checkpoint to avoid losing processing in case of error
149
+ # Save a checkpoint to avoid replaying the full batch after a failure.
153
150
  if !iteration_persistency.nil?
154
151
  iteration_persistency.data[0] = event['id'].to_s
155
152
  iteration_persistency.save
@@ -157,19 +154,22 @@ module Aspera
157
154
  end
158
155
  end
159
156
 
160
- # requests recent events on node api and process newly modified folders
157
+ # Process recent Node API file events since the last persisted checkpoint.
158
+ #
159
+ # @param iteration_persistency [PersistencyActionOnce, nil] stores the last processed event id
160
+ # @return [void]
161
161
  def process_events(iteration_persistency)
162
- # get new file creation by access key (TODO: what if file already existed?)
162
+ # Restrict the event stream to file-related changes for the current access key.
163
163
  events_filter = {
164
164
  'access_key' => @access_key_self['id'],
165
165
  'type' => 'file.*'
166
166
  }
167
- # optionally add iteration token from persistency
167
+ # Resume from the last persisted event id when available.
168
168
  events_filter['iteration_token'] = iteration_persistency.data.first unless iteration_persistency.nil?
169
169
  events = @api_node.read('events', events_filter)
170
170
  return if events.empty?
171
171
  events.each do |event|
172
- # process only files
172
+ # Ignore non-file events such as folder notifications.
173
173
  if event.dig('data', 'type').eql?('file')
174
174
  file_entry = @api_node.read("files/#{event['data']['id']}") rescue nil
175
175
  if !file_entry.nil? &&
@@ -179,10 +179,10 @@ module Aspera
179
179
  generate_preview(file_entry) if event['types'].include?('file.deleted')
180
180
  end
181
181
  end
182
- # log/persist periodically or last one
182
+ # Periodically log progress and persist the latest processed event.
183
183
  next unless @periodic.trigger? || event.equal?(events.last)
184
184
  Log.log.debug{"Processing event #{event['id']}"}
185
- # save checkpoint to avoid losing processing in case of error
185
+ # Save a checkpoint to avoid replaying the full batch after a failure.
186
186
  if !iteration_persistency.nil?
187
187
  iteration_persistency.data[0] = event['id'].to_s
188
188
  iteration_persistency.save
@@ -190,21 +190,34 @@ module Aspera
190
190
  end
191
191
  end
192
192
 
193
+ # Transfer a file to or from the configured Node storage using a tagged transfer spec.
194
+ #
195
+ # @param direction [String] transfer direction, typically from [`Transfer::Spec`](lib/aspera/transfer/spec.rb)
196
+ # @param folder_id [String] Node API identifier of the reference folder
197
+ # @param source_filename [String] relative source path inside the transfer root
198
+ # @param destination [String, nil] local destination root for receive operations
199
+ # @return [Object] transfer result returned by [`Main.result_transfer`](lib/aspera/cli/main.rb)
193
200
  def do_transfer(direction, folder_id, source_filename, destination = '/')
194
201
  Aspera.assert(!(destination.nil? && direction.eql?(Transfer::Spec::DIRECTION_RECEIVE)))
195
202
  t_spec = @api_node.transfer_spec_gen4(folder_id, direction, {
196
203
  'paths' => [{'source' => source_filename}],
197
204
  'tags' => {Transfer::Spec::TAG_RESERVED => {PREV_GEN_TAG => true}}
198
205
  })
199
- # force destination, need to set this in transfer agent else it gets overwritten, do not do: t_spec['destination_root']=destination
206
+ # Force the destination on the transfer agent object.
207
+ # Setting `t_spec['destination_root']` directly would later be overwritten.
200
208
  transfer.user_transfer_spec['destination_root'] = destination
201
209
  Main.result_transfer(transfer.start(t_spec))
202
210
  end
203
211
 
212
+ # Populate generation metadata for a source file available on the local filesystem.
213
+ #
214
+ # @param gen_infos [Array<Hash>] preview generation descriptors to enrich
215
+ # @param entry [Hash] file entry containing at least the relative path
216
+ # @return [String] local directory where previews for this entry are stored
204
217
  def get_infos_local(gen_infos, entry)
205
218
  local_original_filepath = File.join(@local_storage_root, entry['path'])
206
219
  original_mtime = File.mtime(local_original_filepath)
207
- # out
220
+ # Output directory for previews generated from the local source file.
208
221
  local_entry_preview_dir = File.join(@local_preview_folder, entry_preview_folder_name(entry))
209
222
  gen_infos.each do |gen_info|
210
223
  gen_info[:src] = local_original_filepath
@@ -215,41 +228,57 @@ module Aspera
215
228
  return local_entry_preview_dir
216
229
  end
217
230
 
231
+ # Populate generation metadata for a source file stored remotely on Node.
232
+ #
233
+ # @param gen_infos [Array<Hash>] preview generation descriptors to enrich
234
+ # @param entry [Hash] remote file entry returned by Node API
235
+ # @return [String] temporary local directory where previews are generated
218
236
  def get_infos_remote(gen_infos, entry)
219
- # store source directly here
237
+ # Download the source file into the temporary workspace before generating previews.
220
238
  local_original_filepath = File.join(@tmp_folder, entry['name'])
221
239
  # require 'date'
222
240
  # original_mtime=DateTime.parse(entry['modified_time'])
223
- # out: where previews are generated
241
+ # Local directory where previews are generated before being uploaded back.
224
242
  local_entry_preview_dir = File.join(@tmp_folder, entry_preview_folder_name(entry))
225
243
  file_info = @api_node.read("files/#{entry['id']}")
226
- # TODO: this does not work because previews is hidden in api (gen4)
227
- # this_preview_folder_entries=get_folder_entries(@previews_folder_entry['id'],{name: @entry_preview_folder_name})
228
- # TODO: use gen3 api to list files and get date
244
+ # TODO: This does not work with Gen4 because preview folders are hidden by the API.
245
+ # this_preview_folder_entries=@api_node.read_folder_content(@previews_folder_entry['id'],{name: @entry_preview_folder_name})
246
+ # TODO: Query Gen3 APIs to list preview files and retrieve timestamps.
229
247
  gen_infos.each do |gen_info|
230
248
  gen_info[:src] = local_original_filepath
231
249
  gen_info[:dst] = File.join(local_entry_preview_dir, gen_info[:base_dest])
232
- # TODO: use this_preview_folder_entries (but it's hidden)
250
+ # TODO: Reuse `this_preview_folder_entries` once preview folders become visible.
233
251
  gen_info[:preview_exist] = file_info.key?('preview')
234
- # TODO: get change time and compare, useful ?
252
+ # TODO: Compare source and preview modification times when remote timestamps are available.
235
253
  gen_info[:preview_newer_than_original] = gen_info[:preview_exist]
236
254
  end
237
255
  return local_entry_preview_dir
238
256
  end
239
257
 
240
- # defined by node api
258
+ # Build the preview folder name for a file entry using the Node API convention.
259
+ #
260
+ # @param entry [Hash] file entry containing an `id`
261
+ # @return [String] preview folder name for the entry
241
262
  def entry_preview_folder_name(entry)
242
263
  "#{entry['id']}#{PREVIEW_FOLDER_SUFFIX}"
243
264
  end
244
265
 
245
- # Generate a file name based on basename and format (extension)
266
+ # Build a preview filename from a basename and target format.
267
+ #
268
+ # @param preview_format [String, Symbol] preview format used as filename extension
269
+ # @param base_name [String, nil] basename to use before the extension
270
+ # @return [String] preview filename
246
271
  def preview_filename(preview_format, base_name = nil)
247
272
  base_name ||= PREVIEW_BASENAME
248
273
  return "#{base_name}.#{preview_format}"
249
274
  end
250
275
 
251
- # generate preview files for one folder entry (file) if necessary
252
- # entry must contain "parent_file_id" if remote.
276
+ # Generate all required previews for a single file entry when regeneration is needed.
277
+ #
278
+ # Remote entries must include `parent_file_id`.
279
+ #
280
+ # @param entry [Hash] local or remote file entry to preview
281
+ # @return [void]
253
282
  def generate_preview(entry)
254
283
  # prepare generic information
255
284
  gen_infos = @preview_formats_to_generate.map do |preview_format|
@@ -280,7 +309,7 @@ module Aspera
280
309
  end
281
310
  begin
282
311
  # need generator for further checks
283
- gen_info[:generator] = Aspera::Preview::Generator.new(gen_info[:src], gen_info[:dst], @gen_options, @tmp_folder, entry['content_type'])
312
+ gen_info[:generator] = Aspera::Preview::Generator.new(gen_info[:src], gen_info[:dst], @gen_options, @tmp_folder, mime: entry['content_type'])
284
313
  rescue
285
314
  # no conversion supported
286
315
  next false
@@ -300,7 +329,12 @@ module Aspera
300
329
  end
301
330
  Log.log.debug{"source: #{entry['id']}: #{entry['path']}"}
302
331
  gen_infos.each do |gen_info|
303
- gen_info[:generator].generate rescue nil
332
+ gen_info[:generator].generate
333
+ rescue => e
334
+ Log.log.error{"Ignoring: #{e.class} #{e.message}"}
335
+ Log.log.debug(e.backtrace.join("\n").red)
336
+ # in case of any error, place a standard error image
337
+ FileUtils.cp(gen_info[:generator].error_asset, @destination_file_path)
304
338
  end
305
339
  if @access_remote
306
340
  # upload
@@ -316,8 +350,9 @@ module Aspera
316
350
  Log.log.debug(e.backtrace.join("\n").red)
317
351
  end
318
352
 
319
- # scan all files in provided folder entry
320
- # @param top_path subpath to start folder scan inside
353
+ # Scan all files in provided folder entry
354
+ # @param top_entry [Hash] the top entry to scan
355
+ # @param top_path [String, nil] subpath to start folder scan inside
321
356
  def scan_folder_files(top_entry, top_path = nil)
322
357
  unless top_path.nil?
323
358
  # canonical path: start with / and ends with /
@@ -353,7 +388,7 @@ module Aspera
353
388
  else
354
389
  Log.log.debug{"#{entry['path']} folder".green}
355
390
  # get folder content
356
- folder_entries = get_folder_entries(entry['id'])
391
+ folder_entries = @api_node.read_folder_content(entry['id'])
357
392
  # process all items in current folder
358
393
  folder_entries.each do |folder_entry|
359
394
  # add path for older versions of ES
@@ -376,37 +411,35 @@ module Aspera
376
411
  def execute_action
377
412
  command = options.get_next_command(ACTIONS)
378
413
  unless %i[check test show].include?(command)
379
- # this will use node api
414
+ # This will use node api
380
415
  @api_node = Api::Node.new(**basic_auth_params)
381
416
  @transfer_server_address = URI.parse(@api_node.base_url).host
382
- # get current access key
417
+ # Get current access key information
383
418
  @access_key_self = @api_node.read('access_keys/self')
384
419
  # TODO: check events is activated here:
385
420
  # note that docroot is good to look at as well
386
421
  node_info = @api_node.read('info')
387
422
  Log.log.debug{"root: #{node_info['docroot']}"}
388
- @access_remote = @option_file_access.eql?(:remote)
423
+ # Default storage url to local file if not provided
424
+ option_root_url = options.get_option(:root_url, mandatory: true)
425
+ option_root_url = UriReader.file_url(@access_key_self['storage']['path']) if option_root_url.empty? && @access_key_self['storage']['type'].eql?('local')
426
+ @access_remote = !UriReader.file?(option_root_url)
389
427
  Log.log.debug{"remote: #{@access_remote}"}
390
- Log.log.debug{"access key info: #{@access_key_self}"}
391
- # TODO: can the previews folder parameter be read from node api ?
428
+ # TODO: can the `previews` folder parameter be read from Node API ?
392
429
  @option_skip_folders.push("/#{@option_previews_folder}")
393
430
  if @access_remote
394
431
  # NOTE: the filter "name", it's why we take the first one
395
- @previews_folder_entry = get_folder_entries(@access_key_self['root_file_id'], {name: @option_previews_folder}).first
432
+ @previews_folder_entry = @api_node.read_folder_content(@access_key_self['root_file_id'], {name: @option_previews_folder}).first
396
433
  raise Cli::Error, "Folder #{@option_previews_folder} does not exist on node. " \
397
434
  'Please create it in the storage root, or specify an alternate name.' if @previews_folder_entry.nil?
398
435
  else
399
- Aspera.assert(@access_key_self['storage']['type'].eql?('local')){'only local storage allowed in this mode'}
400
- @local_storage_root = @access_key_self['storage']['path']
401
- # TODO: option to override @local_storage_root='xxx'
402
- @local_storage_root = @local_storage_root[PVCL_LOCAL_STORAGE.length..-1] if @local_storage_root.start_with?(PVCL_LOCAL_STORAGE)
403
- # TODO: windows could have "C:" ?
436
+ @local_storage_root = UriReader.file_path(option_root_url)
437
+ # TODO: Windows could have "C:" ?
404
438
  Aspera.assert(@local_storage_root.start_with?('/')){"not local storage: #{@local_storage_root}"}
405
439
  Aspera.assert(File.directory?(@local_storage_root), type: Cli::Error){"Local storage root folder #{@local_storage_root} does not exist."}
406
440
  @local_preview_folder = File.join(@local_storage_root, @option_previews_folder)
407
- raise Cli::Error, "Folder #{@local_preview_folder} does not exist locally. " \
408
- 'Please create it, or specify an alternate name.' unless File.directory?(@local_preview_folder)
409
- # protection to avoid clash of file id for two different access keys
441
+ Aspera.assert(File.directory?(@local_preview_folder), type: Cli::Error){"Folder #{@local_preview_folder} does not exist locally. Please create it, or specify an alternate name."}
442
+ # Protection to avoid clash of file id for two different access keys
410
443
  marker_file = File.join(@local_preview_folder, AK_MARKER_FILE)
411
444
  Log.log.debug{"marker file: #{marker_file}"}
412
445
  if File.exist?(marker_file)
@@ -459,18 +492,21 @@ module Aspera
459
492
  return Main.result_status("#{command} finished")
460
493
  when :check
461
494
  return Main.result_status('Tools validated')
462
- when :test, :show
495
+ when :test
463
496
  source = options.get_next_argument('source file')
464
497
  format = options.get_next_argument('format', accept_list: Aspera::Preview::Generator::PREVIEW_FORMATS, default: :png)
465
498
  generated_file_path = preview_filename(format, options.get_option(:base))
466
- g = Aspera::Preview::Generator.new(source, generated_file_path, @gen_options, @tmp_folder, nil)
467
- g.generate
468
- if command.eql?(:show)
469
- terminal_options = (options.get_option(:query) || {}).symbolize_keys
470
- Log.log.debug{"preview: #{generated_file_path}"}
471
- formatter.display_status(Aspera::Preview::Terminal.build(File.read(generated_file_path), **terminal_options))
472
- end
499
+ Aspera::Preview::Generator.new(source, generated_file_path, @gen_options, @tmp_folder).generate
473
500
  return Main.result_status("generated: #{generated_file_path}")
501
+ when :show
502
+ source = options.get_next_argument('source file')
503
+ # terminal_options = options.get_next_argument('options', validation: Hash, default: {}).symbolize_keys
504
+ generated_file_path = preview_filename(:png, options.get_option(:base))
505
+ Aspera::Preview::Generator.new(source, generated_file_path, @gen_options, @tmp_folder).generate
506
+ formatter.display_status("generated: #{generated_file_path}")
507
+ # formatter.display_status(Aspera::Preview::Terminal.build(File.read(generated_file_path), **terminal_options))
508
+ # return Main.result_status("generated: #{generated_file_path}")
509
+ return Main.result_image(UriReader.file_url(generated_file_path))
474
510
  else Aspera.error_unexpected_value(command)
475
511
  end
476
512
  ensure
@@ -86,21 +86,13 @@ module Aspera
86
86
 
87
87
  def initialize(**_)
88
88
  super
89
- @ssh_opts = {}
90
89
  @connection_type = :ssh
91
90
  options.declare(:ssh_keys, 'SSH key path list', allowed: Allowed::TYPES_STRING_ARRAY)
92
91
  options.declare(:passphrase, 'SSH private key passphrase')
93
- options.declare(:ssh_options, 'SSH options', allowed: Hash, handler: {o: self, m: :option_ssh_opts})
92
+ options.declare(:ssh_options, 'SSH options', allowed: Hash, default: {})
94
93
  SyncActions.declare_options(options)
95
94
  options.parse_options!
96
- end
97
-
98
- def option_ssh_opts; @ssh_opts; end
99
-
100
- # multiple option are merged
101
- def option_ssh_opts=(value)
102
- Aspera.assert_type(value, Hash)
103
- @ssh_opts.deep_merge!(value.compact.symbolize_keys)
95
+ @ssh_opts = options.get_option(:ssh_options).symbolize_keys
104
96
  end
105
97
 
106
98
  # Read command line options
@@ -135,9 +135,9 @@ module Aspera
135
135
  when :node
136
136
  return entity_execute(api: api_shares_admin, entity: 'data/nodes')
137
137
  when :share
138
- share_command = options.get_next_command(%i[user_permissions group_permissions].concat(ALL_OPS))
138
+ share_command = options.get_next_command(%i[user_permissions group_permissions].concat(Operations::ALL))
139
139
  case share_command
140
- when *ALL_OPS
140
+ when *Operations::ALL
141
141
  return entity_execute(
142
142
  api: api_shares_admin,
143
143
  entity: 'data/shares',
@@ -146,7 +146,7 @@ module Aspera
146
146
  &lookup_share
147
147
  )
148
148
  when :user_permissions, :group_permissions
149
- share_id = instance_identifier(&lookup_share)
149
+ share_id = options.instance_identifier(&lookup_share)
150
150
  return entity_execute(api: api_shares_admin, entity: "data/shares/#{share_id}/#{share_command}")
151
151
  end
152
152
  when :transfer_settings
@@ -181,9 +181,9 @@ module Aspera
181
181
  entity_verb = options.get_next_command(entity_commands)
182
182
  lookup_block = ->(field, value){RestList.lookup_entity_generic(entity: entity_type, field: field, value: value){api_shares_admin.read(entities_path)}['id']}
183
183
  case entity_verb
184
- when *ALL_OPS # list, show, delete, create, modify
184
+ when *Operations::ALL
185
185
  display_fields = entity_type.eql?(:user) ? %w[id user_id username first_name last_name email] : nil
186
- display_fields.push(:directory_user) if entity_type.eql?(:user) && entities_location.eql?(:all)
186
+ display_fields.push('directory_user') if entity_type.eql?(:user) && entities_location.eql?(:all)
187
187
  return entity_execute(
188
188
  api: api_shares_admin,
189
189
  entity: entities_path,
@@ -192,7 +192,7 @@ module Aspera
192
192
  &lookup_block
193
193
  )
194
194
  when *USR_GRP_SETTINGS # transfer_settings, app_authorizations, share_permissions
195
- group_id = instance_identifier(&lookup_block)
195
+ group_id = options.instance_identifier(&lookup_block)
196
196
  entities_path = "#{entities_path}/#{group_id}/#{entity_verb}"
197
197
  return entity_execute(api: api_shares_admin, entity: entities_path, is_singleton: !entity_verb.eql?(:share_permissions), &lookup_share)
198
198
  when :import # saml
@@ -210,7 +210,7 @@ module Aspera
210
210
  api_shares_admin.create(entities_path, {entity_type=>entity_name})
211
211
  end
212
212
  when :users # group
213
- return entity_execute(api: api_shares_admin, entity: "#{entities_path}/#{instance_identifier(&lookup_block)}/#{entities_prefix}users")
213
+ return entity_execute(api: api_shares_admin, entity: "#{entities_path}/#{options.instance_identifier(&lookup_block)}/#{entities_prefix}users")
214
214
  else Aspera.error_unexpected_value(entity_verb)
215
215
  end
216
216
  end
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # For beta add extension : .beta1
6
6
  # For dev version add extension : .pre
7
- VERSION = '4.25.6'
7
+ VERSION = '4.26.0'
8
8
  end
9
9
  end
@@ -61,6 +61,7 @@ module Aspera
61
61
  def to_dotted
62
62
  result = {}
63
63
  until @stack.empty?
64
+ # path: Array, current: Array or Hash or other
64
65
  path, current = @stack.pop
65
66
  to_insert = nil
66
67
  # empty things are left intact
@@ -78,8 +79,11 @@ module Aspera
78
79
  elsif current.all?{ |i| i.is_a?(Hash) && i.keys == ['name']}
79
80
  to_insert = current.map{ |i| i['name']}
80
81
  # Array of Hashes with only 'name' and 'value' keys -> Hash of key/values
81
- elsif current.all?{ |i| i.is_a?(Hash) && i.keys.sort == %w[name value]}
82
- add_elements(path, current.to_h{ |i| [i['name'], i['value']]})
82
+ elsif current.all?{ |i| i.is_a?(Hash) && i.key?('name') && i.key?('value') && i.length <= 3}
83
+ # if there is an extra key, other than 'name' and 'value', insert that key as is
84
+ add_elements(path, current.flat_map{ |h| h.except('name', 'value').to_a})
85
+ # Insert name/value pairs as Hash
86
+ add_elements(path, current.to_h{ |h| h.values_at('name', 'value')})
83
87
  else
84
88
  add_elements(path, current.each_with_index.map{ |v, i| [i, v]})
85
89
  end
@@ -97,7 +101,7 @@ module Aspera
97
101
  # Add elements of enumerator to the @stack, in reverse order
98
102
  def add_elements(path, enum)
99
103
  enum.reverse_each do |key, value|
100
- @stack.push([path + [key], value])
104
+ @stack.push([path + [key.to_s], value])
101
105
  end
102
106
  nil
103
107
  end
@@ -91,9 +91,9 @@ module Aspera
91
91
  #
92
92
  # @param cmd [Array<#to_s>] The executable and its arguments.
93
93
  # @param mode [:execute, :background, :capture] The execution strategy:
94
- # - `:execute` Uses {Kernel.system}. Returns `true`, `false`, or `nil`.
94
+ # - `:execute` Uses {Kernel.system}. Returns `true`, `false`, or `nil`. (Default)
95
95
  # - `:background` Uses {Process.spawn}. Returns the spawned process PID.
96
- # - `:capture` Uses {Open3.capture3}. Returns captured output and status.
96
+ # - `:capture` Uses {Open3.capture3}. Returns captured out, err, and status.
97
97
  #
98
98
  # @param kwargs [Hash] Additional options forwarded to the underlying call.
99
99
  #
@@ -316,6 +316,7 @@ module Aspera
316
316
 
317
317
  # Replacement character for illegal filename characters
318
318
  # Can also be used as safe "join" character
319
+ # @return [String] One character
319
320
  def safe_filename_character
320
321
  return REPLACE_CHARACTER if @file_illegal_characters.nil? || @file_illegal_characters.empty?
321
322
  @file_illegal_characters[0]
data/lib/aspera/log.rb CHANGED
@@ -95,7 +95,7 @@ module Aspera
95
95
  PP.pp(object, +'')
96
96
  else error_unexpected_value(instance.dump_format){'dump format'}
97
97
  end
98
- "#{name.to_s.green} (#{instance.dump_format})=\n#{dump_text}"
98
+ "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
99
99
  end
100
100
 
101
101
  # Capture the output of $stderr and log it at debug level
@@ -14,7 +14,7 @@ module Aspera
14
14
  # values for conversion_type : input format
15
15
  CONVERSION_TYPES = %i[image office pdf plaintext video].freeze
16
16
 
17
- # special cases for mime types
17
+ # Special cases for MIME types
18
18
  # spellchecker:disable
19
19
  SUPPORTED_MIME_TYPES = {
20
20
  'application/json' => :plaintext,