Module: Commands

Defined in:
lib/droxi/commands.rb

Overview

Module containing definitions for client commands.

Defined Under Namespace

Classes: Command

Constant Summary collapse

UsageError =

Exception indicating that a client command was given the wrong number of arguments.

Class.new(ArgumentError)
CAT =

Print the contents of remote files.

Command.new(
  'cat REMOTE_FILE...',
  'Print the concatenated contents of remote files.',
  lambda do |client, state, args|
    extract_flags(CAT.usage, args, {})
    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "cat: #{path}: no such file or directory"
      else
        puts client.get_file(path)
      end
    end
  end
)
CD =

Change the remote working directory.

Command.new(
  'cd [REMOTE_DIR]',
  "Change the remote working directory. With no arguments, changes to the \
   Dropbox root. With a remote directory name as the argument, changes to \
   that directory. With - as the argument, changes to the previous working \
   directory.",
  lambda do |_client, state, args|
    extract_flags(CD.usage, args, {})
    case
    when args.empty? then state.pwd = '/'
    when args.first == '-' then state.pwd = state.oldpwd
    else
      path = state.resolve_path(args.first)
      if state.directory?(path)
        state.pwd = path
      else
        warn "cd: #{args.first}: no such directory"
      end
    end
  end
)
CP =

Copy remote files.

Command.new(
  'cp [-f] REMOTE_FILE... REMOTE_FILE',
  "When given two arguments, copies the remote file or folder at the first \
   path to the second path. When given more than two arguments or when the \
   final argument is a directory, copies each remote file or folder into \
   that directory. Will refuse to overwrite existing files unless invoked \
   with the -f option.",
  lambda do |client, state, args|
    cp_mv(client, state, args, 'cp', CP.usage)
  end
)
DEBUG =

Execute arbitrary code.

Command.new(
  'debug STRING...',
  "Evaluates the given string as Ruby code and prints the result. Won't \
   work unless the program was invoked with the --debug flag.",
  # rubocop:disable Lint/UnusedBlockArgument, Lint/Eval
  lambda do |client, state, args|
    if ARGV.include?('--debug')
      begin
        p eval(args.join(' '))
        # rubocop:enable Lint/UnusedBlockArgument, Lint/Eval
      rescue SyntaxError => error
        warn error
      rescue => error
        warn error.inspect
      end
    else
      warn 'debug: not enabled.'
    end
  end
)
EXIT =

Terminate the session.

Command.new(
  'exit',
  'Exit the program.',
  lambda do |_client, state, args|
    extract_flags(EXIT.usage, args, {})
    state.exit_requested = true
  end
)
FORGET =

Clear the cache.

Command.new(
  'forget [REMOTE_DIR]...',
  "Clear the client-side cache of remote filesystem metadata. With no \
   arguments, clear the entire cache. If given directories as arguments, \
   (recursively) clear the cache of those directories only.",
  lambda do |_client, state, args|
    extract_flags(FORGET.usage, args, {})
    if args.empty?
      state.cache.clear
    else
      args.each do |arg|
        state.forget_contents(arg) { |line| warn line }
      end
    end
  end
)
GET =

Download remote files.

Command.new(
  'get [-f] REMOTE_FILE...',
  "Download each specified remote file to a file of the same name in the \
   local working directory. Will refuse to overwrite existing files unless \
   invoked with the -f option.",
  lambda do |client, state, args|
    flags = extract_flags(GET.usage, args, '-f' => 0)

    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "get: #{path}: no such file or directory"
      else
        basename = File.basename(path)
        try_and_handle(DropboxError) do
          if flags.include?('-f') || !File.exist?(basename)
            contents = client.get_file(path)
            IO.write(basename, contents, mode: 'wb')
            puts "#{basename} <- #{path}"
          else
            warn "get: #{basename}: local file already exists"
          end
        end
      end
    end
  end
)
HELP =

List commands, or print information about a specific command.

Command.new(
  'help [COMMAND]',
  "Print usage and help information about a command. If no command is \
   given, print a list of commands instead.",
  lambda do |_client, _state, args|
    extract_flags(HELP.usage, args, {})
    if args.empty?
      Text.table(NAMES).each { |line| puts line }
    else
      cmd_name = args.first
      if NAMES.include?(cmd_name)
        cmd = const_get(cmd_name.upcase.to_s)
        puts cmd.usage
        Text.wrap(cmd.description).each { |line| puts line }
      else
        warn "help: #{cmd_name}: no such command"
      end
    end
  end
)
HISTORY =

Get remote file revisions.

Command.new(
  'history REMOTE_FILE',
  "Print a list of revisions for a remote file. The file can be restored to \
   a previous revision using the 'restore' command and a revision ID given \
   by this command.",
  lambda do |client, state, args|
    extract_flags(HISTORY.usage, args, {})
    path = state.resolve_path(args.first)
    if !state.(path) || state.directory?(path)
      warn "history: #{args.first}: no such file"
    else
      try_and_handle(DropboxError) do
        client.revisions(path).each do |rev|

          size = rev['size'].sub(/ (.)B/, '\1').sub(' bytes', '').rjust(7)
          mtime = Time.parse(rev['modified'])
          current_year = (mtime.year == Time.now.year)
          format_str = current_year ? '%b %e %H:%M' : '%b %e  %Y'
          puts "#{size} #{mtime.strftime(format_str)} #{rev['rev']}"
        end
      end
    end
  end
)
LCD =

Change the local working directory.

Command.new(
  'lcd [LOCAL_DIR]',
  "Change the local working directory. With no arguments, changes to the \
   home directory. With a local directory name as the argument, changes to \
   that directory. With - as the argument, changes to the previous working \
   directory.",
  lambda do |_client, state, args|
    extract_flags(LCD.usage, args, {})
    path = case
           when args.empty? then File.expand_path('~')
           when args.first == '-' then state.local_oldpwd
           else
             begin
               File.expand_path(args.first)
             rescue ArgumentError
               args.first
             end
           end

    if Dir.exist?(path)
      state.local_oldpwd = Dir.pwd
      Dir.chdir(path)
    else
      warn "lcd: #{args.first}: no such file or directory"
    end
  end
)
LS =

List remote files.

Command.new(
  'ls [-l] [REMOTE_FILE]...',
  "List information about remote files. With no arguments, list the \
   contents of the working directory. When given remote directories as \
   arguments, list the contents of the directories. When given remote files \
   as arguments, list the files. If the -l option is given, display \
   information about the files.",
  lambda do |_client, state, args|
    long = extract_flags(LS.usage, args, '-l' => 0).include?('-l')

    files, dirs = [], []
    state.expand_patterns(args, true).each do |path|
      if path.is_a?(GlobError)
        warn "ls: #{path}: no such file or directory"
      else
        type = state.directory?(path) ? dirs : files
        type << path
      end
    end

    dirs << state.pwd if args.empty?

    # First list files.
    list(state, files, files, long) { |line| puts line }
    puts unless dirs.empty? || files.empty?

    # Then list directory contents.
    dirs.each_with_index do |dir, i|
      puts "#{dir}:" if dirs.size + files.size > 1
      contents = state.contents(dir)
      names = contents.map { |path| File.basename(path) }
      list(state, contents, names, long) { |line| puts line }
      puts if i < dirs.size - 1
    end
  end
)
MEDIA =

Get temporary links to remote files.

Command.new(
  'media REMOTE_FILE...',
  "Create Dropbox links to publicly share remote files. The links are \
   time-limited and link directly to the files themselves.",
  lambda do |client, state, args|
    extract_flags(MEDIA.usage, args, {})
    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "media: #{path}: no such file or directory"
      else
        try_and_handle(DropboxError) do
          url = client.media(path)['url']
          puts "#{File.basename(path)} -> #{url}"
        end
      end
    end
  end
)
MKDIR =

Create a remote directory.

Command.new(
  'mkdir REMOTE_DIR...',
  'Create remote directories.',
  lambda do |client, state, args|
    extract_flags(MKDIR.usage, args, {})
    args.each do |arg|
      try_and_handle(DropboxError) do
        path = state.resolve_path(arg)
         = client.file_create_folder(path)
        state.cache.add()
      end
    end
  end
)
MV =

Move/rename remote files.

Command.new(
  'mv [-f] REMOTE_FILE... REMOTE_FILE',
  "When given two arguments, moves the remote file or folder at the first \
   path to the second path. When given more than two arguments or when the \
   final argument is a directory, moves each remote file or folder into \
   that directory. Will refuse to overwrite existing files unless invoked \
   with the -f option.",
  lambda do |client, state, args|
    cp_mv(client, state, args, 'mv', MV.usage)
  end
)
PUT =

Upload a local file.

Command.new(
  'put [-f] [-q] [-O REMOTE_DIR] [-t COUNT] LOCAL_FILE...',
  "Upload local files to the remote working directory. If a remote file of \
   the same name already exists, Dropbox will rename the upload unless the \
   the -f option is given, in which case the remote file will be \
   overwritten. If the -O option is given, the files will be uploaded to \
   the given directory instead of the current directory. The -q option \
   prevents progress from being printed. The -t option specifies the \
   number of tries in case of error. The default is 5; -t 0 will retry \
   infinitely.",
  lambda do |client, state, args|
    flags = extract_flags(PUT.usage, args,
                          '-f' => 0,
                          '-q' => 0,
                          '-O' => 1,
                          '-t' => 1)

    dest_index = flags.find_index('-O')
    dest_path = nil
    unless dest_index.nil?
      dest_path = flags[dest_index + 1]
      if state.directory?(dest_path)
        state.pwd = state.resolve_path(dest_path)
      else
        warn "put: #{dest_path}: no such directory"
        return
      end
    end

    tries_index = flags.find_index('-t')
    tries = tries_index ? flags[tries_index + 1].to_i : 5

    args.each do |arg|
      to_path = state.resolve_path(File.basename(arg))

      try_and_handle(StandardError) do
        File.open(File.expand_path(arg), 'rb') do |file|
          if flags.include?('-f') && state.(to_path)
            client.file_delete(to_path)
            state.cache.remove(to_path)
          end

          # Chunked upload if file is more than 1M.
          if file.size > 1024 * 1024
            data = chunked_upload(client, to_path, file,
                                  flags.include?('-q'), tries)
          else
            data = client.put_file(to_path, file)
          end

          state.cache.add(data)
          puts "#{arg} -> #{data['path']}"
        end
      end
    end

    state.pwd = state.oldpwd unless dest_path.nil?
  end
)
RESTORE =

Restore a remove file to a previous version.

Command.new(
  'restore REMOTE_FILE REVISION_ID',
  "Restore a remote file to a previous version. Use the 'history' command \
   to get a list of IDs for previous revisions of the file.",
  lambda do |client, state, args|
    extract_flags(RESTORE.usage, args, {})
    path = state.resolve_path(args.first)
    if !state.(path) || state.directory?(path)
      warn "restore: #{args.first}: no such file"
    else
      try_and_handle(DropboxError) do
        client.restore(path, args.last)
      end
    end
  end
)
RM =

Remove remote files.

Command.new(
  'rm [-r] REMOTE_FILE...',
  "Remove each specified remote file. If the -r option is given, will \
   also remove directories recursively.",
  lambda do |client, state, args|
    flags = extract_flags(RM.usage, args, '-r' => 0)
    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "rm: #{path}: no such file or directory"
      else
        if state.directory?(path) && !flags.include?('-r')
          warn "rm: #{path}: is a directory"
          next
        end
        try_and_handle(DropboxError) do
          client.file_delete(path)
          state.cache.remove(path)
        end
      end
    end
    check_pwd(state)
  end
)
RMDIR =

Remove remote directories.

Command.new(
  'rmdir REMOTE_DIR...',
  'Remove each specified empty remote directory.',
  lambda do |client, state, args|
    extract_flags(RMDIR.usage, args, {})
    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "rmdir: #{path}: no such file or directory"
      else
        unless state.directory?(path)
          warn "rmdir: #{path}: not a directory"
          next
        end
        contents = state.(path)['contents']
        if contents && !contents.empty?
          warn "rmdir: #{path}: directory not empty"
          next
        end
        try_and_handle(DropboxError) do
          client.file_delete(path)
          state.cache.remove(path)
        end
      end
    end
    check_pwd(state)
  end
)
SEARCH =

Search for remote files.

Command.new(
  'search REMOTE_DIR SUBSTRING...',
  "List remote files in a directory or its subdirectories with names that \
   contain all given substrings.",
  lambda do |client, state, args|
    extract_flags(SEARCH.usage, args, {})
    path = state.resolve_path(args.first)
    unless state.directory?(path)
      warn "search: #{args.first}: no such directory"
      return
    end
    query = args.drop(1).join(' ')
    try_and_handle(DropboxError) do
      client.search(path, query).each { |result| puts result['path'] }
    end
  end
)
SHARE =

Get permanent links to remote files.

Command.new(
  'share REMOTE_FILE...',
  "Create Dropbox links to publicly share remote files. The links are \
   shortened and direct to 'preview' pages of the files. Links created by \
   this method are set to expire far enough in the future so that \
   expiration is effectively not an issue.",
  lambda do |client, state, args|
    extract_flags(SHARE.usage, args, {})
    state.expand_patterns(args).each do |path|
      if path.is_a?(GlobError)
        warn "share: #{path}: no such file or directory"
      else
        try_and_handle(DropboxError) do
          url = client.shares(path)['url']
          puts "#{File.basename(path)} -> #{url}"
        end
      end
    end
  end
)
NAMES =

Array of all command names.

names

Class Method Summary collapse

Class Method Details

.check_pwd(state) ⇒ Object

If the remote working directory does not exist, move up the directory tree until at a real location.



669
670
671
# File 'lib/droxi/commands.rb', line 669

def self.check_pwd(state)
  (state.pwd = File.dirname(state.pwd)) until state.(state.pwd)
end

.chunked_upload(client, to_path, file, quiet, tries) ⇒ Object

Attempts to upload a file to the server in chunks, displaying progress.



704
705
706
707
708
709
710
711
712
713
714
715
# File 'lib/droxi/commands.rb', line 704

def self.chunked_upload(client, to_path, file, quiet, tries)
  uploader = DropboxClient::ChunkedUploader.new(client, file, file.size)
  thread = quiet ? nil : Thread.new { monitor_upload(uploader, to_path) }
  tries = -1 if tries == 0
  loop_upload(uploader, thread, tries)
  data = uploader.finish(to_path)
  if thread
    thread.join
    print "\r" + (' ' * (18 + to_path.rpartition('/')[2].size)) + "\r"
  end
  data
end

.copy_move(method, args, flags, client, state) ⇒ Object

Copies or moves a file.



628
629
630
631
632
633
634
635
636
637
# File 'lib/droxi/commands.rb', line 628

def self.copy_move(method, args, flags, client, state)
  from_path, to_path = args.map { |p| state.resolve_path(p) }
  try_and_handle(DropboxError) do
    overwrite(to_path, client, state) if flags.include?('-f')
     = client.send(method, from_path, to_path)
    state.cache.remove(from_path) if method == :file_move
    state.cache.add()
    puts "#{args.first} -> #{args[1]}"
  end
end

.cp_mv(client, state, args, cmd, usage) ⇒ Object

Execute a ‘mv’ or ‘cp’ operation depending on arguments given.



640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/droxi/commands.rb', line 640

def self.cp_mv(client, state, args, cmd, usage)
  flags = extract_flags(usage, args, '-f' => 0)
  sources = expand(state, args.take(args.size - 1), true, true, cmd)
  method = (cmd == 'cp') ? :file_copy : :file_move
  dest = state.resolve_path(args.last)

  if sources.size == 1 && !state.directory?(dest)
    copy_move(method, [sources.first, args.last], flags, client, state)
  else
    cp_mv_to_dir(args, flags, client, state, cmd)
  end
end

.cp_mv_to_dir(args, flags, client, state, cmd) ⇒ Object

Copies or moves files into a directory.



654
655
656
657
658
659
660
661
662
663
664
665
# File 'lib/droxi/commands.rb', line 654

def self.cp_mv_to_dir(args, flags, client, state, cmd)
  sources = expand(state, args.take(args.size - 1), true, false, cmd)
  method = (cmd == 'cp') ? :file_copy : :file_move
  if state.(state.resolve_path(args.last))
    sources.each do |source|
      to_path = args.last.chomp('/') + '/' + File.basename(source)
      copy_move(method, [source, to_path], flags, client, state)
    end
  else
    warn "#{cmd}: #{args.last}: no such directory"
  end
end

.exec(input, client, state) ⇒ Object

Parse and execute a line of user input in the given context.



542
543
544
545
546
547
548
549
550
# File 'lib/droxi/commands.rb', line 542

def self.exec(input, client, state)
  if input.start_with?('!')
    shell(input[1, input.size - 1]) { |line| puts line }
  elsif !input.empty?
    tokens = Text.tokenize(input)
    cmd, args = tokens.first, tokens.drop(1)
    try_command(cmd, args, client, state)
  end
end

.expand(state, paths, preserve_root, output, cmd) ⇒ Object

Return an Array of paths from an Array of globs, printing error messages if output is true.



610
611
612
613
614
615
616
617
618
619
# File 'lib/droxi/commands.rb', line 610

def self.expand(state, paths, preserve_root, output, cmd)
  state.expand_patterns(paths, preserve_root).map do |item|
    if item.is_a?(GlobError)
      warn "#{cmd}: #{item}: no such file or directory" if output
      nil
    else
      item
    end
  end.compact
end

.extract_flag(usage, args, flags, arg, index) ⇒ Object

Removes a flag and its arugments from the Array and returns an Array of the flag and its arguments. Prints warnings if the given flag is invalid.



691
692
693
694
695
696
697
698
699
700
701
# File 'lib/droxi/commands.rb', line 691

def self.extract_flag(usage, args, flags, arg, index)
  num_args = flags[arg]
  if num_args.nil?
    fail UsageError, usage
  else
    if index + num_args < args.size
      return (num_args + 1).times.map { args.delete_at(index) }
    end
    fail UsageError, usage
  end
end

.extract_flags(usage, args, flags) ⇒ Object

Removes flags (e.g. -f) from the Array and returns an Array of the removed flags. Prints warnings if the flags are not in the given String of valid flags (e.g. ‘-rf’).



676
677
678
679
680
681
682
683
684
685
686
687
# File 'lib/droxi/commands.rb', line 676

def self.extract_flags(usage, args, flags)
  extracted, index = [], 0
  while index < args.size
    arg = args[index]
    extracted_flags =
      arg[/^-\w/] ? extract_flag(usage, args, flags, arg, index) : nil
    extracted += extracted_flags unless extracted_flags.nil?
    index += 1 if extracted_flags.nil? || extracted_flags.empty?
  end
  args.delete_if { |a| a[/^-\w/] }
  extracted
end

.list(state, paths, names, long) ⇒ Object

Yield lines of output for the ls command executed on the given file paths and names.



589
590
591
592
593
594
595
# File 'lib/droxi/commands.rb', line 589

def self.list(state, paths, names, long)
  if long
    paths.zip(names).each { |path, name| yield long_info(state, path, name) }
  else
    Text.table(names).each { |line| yield line }
  end
end

.long_info(state, path, name) ⇒ Object

Return a String of information about a remote file for ls -l.



578
579
580
581
582
583
584
585
# File 'lib/droxi/commands.rb', line 578

def self.long_info(state, path, name)
  meta = state.(state.resolve_path(path), false)
  is_dir = meta['is_dir'] ? 'd' : '-'
  size = meta['size'].sub(/ (.)B/, '\1').sub(' bytes', '').rjust(7)
  mtime = Time.parse(meta['modified'])
  format_str = (mtime.year == Time.now.year) ? '%b %e %H:%M' : '%b %e  %Y'
  "#{is_dir} #{size} #{mtime.strftime(format_str)} #{name}"
end

.loop_upload(uploader, monitor_thread, tries) ⇒ Object

Continuously try to upload until successful or interrupted. rubocop:disable Style/MethodLength



719
720
721
722
723
724
725
726
727
728
729
730
731
# File 'lib/droxi/commands.rb', line 719

def self.loop_upload(uploader, monitor_thread, tries)
  while tries != 0 && uploader.offset < uploader.total_size
    begin
      uploader.upload(1024 * 1024)
    rescue DropboxError => error
      puts "\n" + error.to_s
      --tries
    end
  end
rescue Interrupt => error
  monitor_thread.kill if monitor_thread
  raise error
end

.monitor_upload(uploader, to_path) ⇒ Object

Displays real-time progress for the a being uploaded.



735
736
737
738
739
740
741
742
743
# File 'lib/droxi/commands.rb', line 735

def self.monitor_upload(uploader, to_path)
  filename = to_path.rpartition('/')[2]
  loop do
    percent = 100.0 * uploader.offset / uploader.total_size
    printf("\rUploading %s: %.1f%%", filename, percent)
    break if uploader.offset == uploader.total_size
    sleep 1
  end
end

.namesObject

Return an Array of all command names.



533
534
535
536
# File 'lib/droxi/commands.rb', line 533

def self.names
  symbols = constants.select { |sym| const_get(sym).is_a?(Command) }
  symbols.map { |sym| sym.to_s.downcase }
end

.overwrite(path, client, state) ⇒ Object



621
622
623
624
625
# File 'lib/droxi/commands.rb', line 621

def self.overwrite(path, client, state)
  return unless state.(path)
  client.file_delete(path)
  state.cache.remove(path)
end

.shell(cmd) ⇒ Object

Run a command in the system shell and yield lines of output.



598
599
600
601
602
603
604
605
606
# File 'lib/droxi/commands.rb', line 598

def self.shell(cmd)
  IO.popen(cmd) do |pipe|
    pipe.each_line { |line| yield line.chomp if block_given? }
  end
rescue Interrupt
  yield ''
rescue Errno::ENOENT => error
  yield error.to_s if block_given?
end

.try_and_handle(exception_class) ⇒ Object

Attempt to run the associated block, handling the given type of Exception by issuing a warning using its String representation.



556
557
558
559
560
# File 'lib/droxi/commands.rb', line 556

def self.try_and_handle(exception_class)
  yield
rescue exception_class => error
  warn error
end

.try_command(command_name, args, client, state) ⇒ Object

Run a command with the given name, or print an error message if usage is incorrect or no such command exists.



564
565
566
567
568
569
570
571
572
573
574
575
# File 'lib/droxi/commands.rb', line 564

def self.try_command(command_name, args, client, state)
  if NAMES.include?(command_name)
    begin
      command = const_get(command_name.upcase.to_sym)
      command.exec(client, state, *args) { |line| puts line }
    rescue UsageError => error
      warn "Usage: #{error}"
    end
  else
    warn "droxi: #{command_name}: command not found"
  end
end