droxi 0.0.1 → 0.0.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9e5303c28f4ee1e364769a554be98314b4c3c953
4
- data.tar.gz: 5170d5affcbf11282c0a2c8e305c0c553ca961b6
3
+ metadata.gz: dd71a917fd5c4fd91e3193c2805df9759505ac95
4
+ data.tar.gz: b5a4517dbc2ff604120b559919bc2750616acbfb
5
5
  SHA512:
6
- metadata.gz: 335e2e4caa00193fb73a0f912ac819ba23b677ba42bb0185de8a143fc7fdb98bf9c5d51724df7ef7da4bd238b231e052f13b686ea17c52f0da4960a947aef294
7
- data.tar.gz: ea91d29b4b67e3384f3605bb884f4ea07edb828bf6fac7e3c084d6e7320af748c9af878f378330dfd5b81cec1a92c4bd0f0a8f07113be46a61f65f6f4751d040
6
+ metadata.gz: b547ebc0138aa9e79bcfd97b47a6809d2b63af71493b45e8cca19136b063f2f8fcf91c45216cdbdb617e7e05c1dbffb68d0bc33409ec6efae73ae5c91c21d658
7
+ data.tar.gz: 43011ae5570b2eafcfbe7124a56f0422fe84136b6937e8a72d782e96887b952433f7f2ba1519912bc2c501e15e4e934f09dbbe0c0698dceffa4c0699cf875c77
data/Rakefile CHANGED
@@ -18,10 +18,13 @@ task :gem do
18
18
  sh 'gem install ./droxi-*.gem'
19
19
  end
20
20
 
21
+ desc 'create rdoc documentation'
22
+ task :doc do
23
+ sh 'rdoc `find lib -name *.rb`'
24
+ end
25
+
21
26
  desc 'install man page (must have root permissions)'
22
27
  task :man do
23
- gemspec = IO.read('droxi.gemspec')
24
-
25
28
  def date(gemspec)
26
29
  require 'time'
27
30
  Time.parse(/\d{4}-\d{2}-\d{2}/.match(gemspec)[0]).strftime('%B %Y')
@@ -35,22 +38,31 @@ task :man do
35
38
  end.join.strip
36
39
  end
37
40
 
38
- contents = IO.read('droxi.1.template').
39
- sub('{date}', date(gemspec)).
40
- sub('{version}', /\d+\.\d+\.\d+/.match(gemspec)[0]).
41
- sub('{commands}', commands)
41
+ def build_page
42
+ gemspec = IO.read('droxi.gemspec')
42
43
 
43
- Dir.mkdir('build') unless Dir.exists?('build')
44
- IO.write('build/droxi.1', contents)
44
+ contents = IO.read('droxi.1.template').
45
+ sub('{date}', date(gemspec)).
46
+ sub('{version}', /\d+\.\d+\.\d+/.match(gemspec)[0]).
47
+ sub('{commands}', commands)
45
48
 
46
- prefix = ENV['PREFIX'] || ENV['prefix'] || '/usr/local'
47
- install_path = "#{prefix}/share/man/man1"
49
+ Dir.mkdir('build') unless Dir.exists?('build')
50
+ IO.write('build/droxi.1', contents)
51
+ end
52
+
53
+ def install_page
54
+ prefix = ENV['PREFIX'] || ENV['prefix'] || '/usr/local'
55
+ install_path = "#{prefix}/share/man/man1"
48
56
 
49
- require 'fileutils'
50
- begin
51
- FileUtils.mkdir_p(install_path)
52
- FileUtils.cp('build/droxi.1', install_path)
53
- rescue
54
- puts 'Failed to install man page. This target must be run as root.'
57
+ require 'fileutils'
58
+ begin
59
+ FileUtils.mkdir_p(install_path)
60
+ FileUtils.cp('build/droxi.1', install_path)
61
+ rescue
62
+ puts 'Failed to install man page. This target must be run as root.'
63
+ end
55
64
  end
65
+
66
+ build_page
67
+ install_page
56
68
  end
data/bin/droxi CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'droxi'
4
- Droxi.run
4
+ Droxi.run(*ARGV)
data/droxi.1.template CHANGED
@@ -2,7 +2,12 @@
2
2
  .SH NAME
3
3
  droxi \- ftp-like command-line interface to Dropbox
4
4
  .SH SYNOPSIS
5
- droxi
5
+ droxi [COMMAND] [ARGUMENT]...
6
+ .SH DESCRIPTION
7
+ A command-line Dropbox interface inspired by GNU coreutils, GNU ftp, and lftp.
8
+ Features include smart tab completion, globbing, and interactive help. If
9
+ invoked without arguments, runs in interactive mode. If invoked with arguments,
10
+ parses the arguments as a command invocation, executes the command, and exits.
6
11
  .SH COMMANDS
7
12
  {commands}
8
13
  .SH AUTHOR
data/droxi.gemspec CHANGED
@@ -1,10 +1,10 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'droxi'
3
- s.version = '0.0.1'
3
+ s.version = '0.0.2'
4
4
  s.date = '2014-06-02'
5
5
  s.summary = 'ftp-like command-line interface to Dropbox'
6
- s.description = "a command-line Dropbox interface inspired by GNU \
7
- coreutils, GNU ftp, and lftp. features smart tab \
6
+ s.description = "A command-line Dropbox interface inspired by GNU \
7
+ coreutils, GNU ftp, and lftp. Features include smart tab \
8
8
  completion, globbing, and interactive help.".squeeze(' ')
9
9
  s.authors = ['Brandon Mulcahy']
10
10
  s.email = 'brandon@jangler.info'
@@ -1,18 +1,38 @@
1
- require 'readline'
1
+ require_relative 'text'
2
2
 
3
+ # Module containing definitions for client commands.
3
4
  module Commands
5
+
6
+ # Exception indicating that a client command was given the wrong number of
7
+ # arguments.
4
8
  class UsageError < ArgumentError
5
9
  end
6
10
 
11
+ # A client command. Contains metadata as well as execution procedure.
7
12
  class Command
8
- attr_reader :usage, :description
9
13
 
14
+ # A +String+ specifying the usage of the command in the style of a man page
15
+ # synopsis. Optional arguments are enclosed in brackets; varargs-style
16
+ # arguments are suffixed with an ellipsis.
17
+ attr_reader :usage
18
+
19
+ # A complete description of the command, suitable for display to the end
20
+ # user.
21
+ attr_reader :description
22
+
23
+ # Create a new +Command+ with the given metadata and a +Proc+ specifying
24
+ # its behavior. The +Proc+ will receive four arguments: the
25
+ # +DropboxClient+, the +State+, an +Array+ of command-line arguments, and
26
+ # a +Proc+ to be called for output.
10
27
  def initialize(usage, description, procedure)
11
28
  @usage = usage
12
29
  @description = description.squeeze(' ')
13
30
  @procedure = procedure
14
31
  end
15
32
 
33
+ # Attempt to execute the +Command+, yielding lines of output if a block is
34
+ # given. Raises a +UsageError+ if an invalid number of command-line
35
+ # arguments is given.
16
36
  def exec(client, state, *args)
17
37
  if num_args_ok?(args.length)
18
38
  block = proc { |line| yield line if block_given? }
@@ -22,6 +42,21 @@ module Commands
22
42
  end
23
43
  end
24
44
 
45
+ # Return a +String+ describing the type of argument at the given index.
46
+ # If the index is out of range, return the type of the final argument. If
47
+ # the +Command+ takes no arguments, return +nil+.
48
+ def type_of_arg(index)
49
+ args = @usage.split.drop(1)
50
+ if args.empty?
51
+ nil
52
+ else
53
+ index = [index, args.length - 1].min
54
+ args[index].tr('[].', '')
55
+ end
56
+ end
57
+
58
+ private
59
+
25
60
  def num_args_ok?(num_args)
26
61
  args = @usage.split.drop(1)
27
62
  min_args = args.reject { |arg| arg.start_with?('[') }.length
@@ -34,14 +69,9 @@ module Commands
34
69
  end
35
70
  (min_args..max_args).include?(num_args)
36
71
  end
37
-
38
- def type_of_arg(index)
39
- args = @usage.split.drop(1)
40
- index = [index, args.length - 1].min
41
- args[index].tr('[].', '')
42
- end
43
72
  end
44
73
 
74
+ # Change the remote working directory.
45
75
  CD = Command.new(
46
76
  'cd [REMOTE_DIR]',
47
77
  "Change the remote working directory. With no arguments, changes to the \
@@ -55,7 +85,7 @@ module Commands
55
85
  state.pwd = state.oldpwd
56
86
  else
57
87
  path = state.resolve_path(args[0])
58
- if state.is_dir?(client, path)
88
+ if state.directory?(path)
59
89
  state.pwd = path
60
90
  else
61
91
  output.call('Not a directory')
@@ -64,6 +94,7 @@ module Commands
64
94
  end
65
95
  )
66
96
 
97
+ # Terminate the session.
67
98
  EXIT = Command.new(
68
99
  'exit',
69
100
  "Exit the program.",
@@ -72,17 +103,19 @@ module Commands
72
103
  end
73
104
  )
74
105
 
106
+ # Download remote files.
75
107
  GET = Command.new(
76
108
  'get REMOTE_FILE...',
77
109
  "Download each specified remote file to a file of the same name in the \
78
110
  local working directory.",
79
111
  lambda do |client, state, args, output|
80
- state.expand_patterns(client, args).each do |path|
112
+ state.expand_patterns(args).each do |path|
81
113
  begin
82
114
  contents = client.get_file(path)
83
115
  File.open(File.basename(path), 'wb') do |file|
84
116
  file.write(contents)
85
117
  end
118
+ output.call("#{File.basename(path)} <- #{path}")
86
119
  rescue DropboxError => error
87
120
  output.call(error.to_s)
88
121
  end
@@ -90,19 +123,20 @@ module Commands
90
123
  end
91
124
  )
92
125
 
126
+ # List commands, or print information about a specific command.
93
127
  HELP = Command.new(
94
128
  'help [COMMAND]',
95
129
  "Print usage and help information about a command. If no command is \
96
130
  given, print a list of commands instead.",
97
131
  lambda do |client, state, args, output|
98
132
  if args.empty?
99
- table_output(NAMES).each { |line| output.call(line) }
133
+ Text.table(NAMES).each { |line| output.call(line) }
100
134
  else
101
135
  cmd_name = args[0]
102
136
  if NAMES.include?(cmd_name)
103
137
  cmd = const_get(cmd_name.upcase.to_s)
104
138
  output.call(cmd.usage)
105
- wrap_output(cmd.description).each { |line| output.call(line) }
139
+ Text.wrap(cmd.description).each { |line| output.call(line) }
106
140
  else
107
141
  output.call("Unrecognized command: #{cmd_name}")
108
142
  end
@@ -110,6 +144,7 @@ module Commands
110
144
  end
111
145
  )
112
146
 
147
+ # Change the local working directory.
113
148
  LCD = Command.new(
114
149
  'lcd [LOCAL_DIR]',
115
150
  "Change the local working directory. With no arguments, changes to the \
@@ -134,6 +169,7 @@ module Commands
134
169
  end
135
170
  )
136
171
 
172
+ # List remote files.
137
173
  LS = Command.new(
138
174
  'ls [REMOTE_FILE]...',
139
175
  "List information about remote files. With no arguments, list the \
@@ -147,7 +183,7 @@ module Commands
147
183
  args.map do |path|
148
184
  path = state.resolve_path(path)
149
185
  begin
150
- if state.is_dir?(client, path)
186
+ if state.directory?(path)
151
187
  "#{path}/*".sub('//', '/')
152
188
  else
153
189
  path
@@ -162,17 +198,35 @@ module Commands
162
198
  patterns.each do |pattern|
163
199
  begin
164
200
  dir = File.dirname(pattern)
165
- state.contents(client, dir).each do |path|
201
+ state.contents(dir).each do |path|
166
202
  items << File.basename(path) if File.fnmatch(pattern, path)
167
203
  end
168
204
  rescue DropboxError => error
169
205
  output.call(error.to_s)
170
206
  end
171
207
  end
172
- table_output(items).each { |item| output.call(item) }
208
+ Text.table(items).each { |item| output.call(item) }
173
209
  end
174
210
  )
175
211
 
212
+ # Get temporary links to remote files.
213
+ MEDIA = Command.new(
214
+ 'media REMOTE_FILE...',
215
+ "Create Dropbox links to publicly share remote files. The links are \
216
+ time-limited and link directly to the files themselves.",
217
+ lambda do |client, state, args, output|
218
+ state.expand_patterns(args).each do |path|
219
+ begin
220
+ url = client.media(path)['url']
221
+ output.call("#{File.basename(path)} -> #{url}")
222
+ rescue DropboxError => error
223
+ output.call(error.to_s)
224
+ end
225
+ end
226
+ end
227
+ )
228
+
229
+ # Create a remote directory.
176
230
  MKDIR = Command.new(
177
231
  'mkdir REMOTE_DIR...',
178
232
  "Create remote directories.",
@@ -188,6 +242,7 @@ module Commands
188
242
  end
189
243
  )
190
244
 
245
+ # Upload a local file.
191
246
  PUT = Command.new(
192
247
  'put LOCAL_FILE [REMOTE_FILE]',
193
248
  "Upload a local file to a remote path. If a remote file of the same name \
@@ -205,7 +260,9 @@ module Commands
205
260
 
206
261
  begin
207
262
  File.open(File.expand_path(from_path), 'rb') do |file|
208
- state.cache[to_path] = client.put_file(to_path, file)
263
+ data = client.put_file(to_path, file)
264
+ state.cache[data['path']] = data
265
+ output.call("#{from_path} -> #{data['path']}")
209
266
  end
210
267
  rescue Exception => error
211
268
  output.call(error.to_s)
@@ -213,11 +270,12 @@ module Commands
213
270
  end
214
271
  )
215
272
 
273
+ # Remove remote files.
216
274
  RM = Command.new(
217
275
  'rm REMOTE_FILE...',
218
276
  "Remove each specified remote file or directory.",
219
277
  lambda do |client, state, args, output|
220
- state.expand_patterns(client, args).each do |path|
278
+ state.expand_patterns(args).each do |path|
221
279
  begin
222
280
  client.file_delete(path)
223
281
  state.cache.delete(path)
@@ -228,6 +286,7 @@ module Commands
228
286
  end
229
287
  )
230
288
 
289
+ # Get permanent links to remote files.
231
290
  SHARE = Command.new(
232
291
  'share REMOTE_FILE...',
233
292
  "Create Dropbox links to publicly share remote files. The links are \
@@ -235,9 +294,10 @@ module Commands
235
294
  this method are set to expire far enough in the future so that \
236
295
  expiration is effectively not an issue.",
237
296
  lambda do |client, state, args, output|
238
- state.expand_patterns(client, args).each do |path|
297
+ state.expand_patterns(args).each do |path|
239
298
  begin
240
- output.call("#{path}: #{client.shares(path)['url']}")
299
+ url = client.shares(path)['url']
300
+ output.call("#{File.basename(path)} -> #{url}")
241
301
  rescue DropboxError => error
242
302
  output.call(error.to_s)
243
303
  end
@@ -245,10 +305,12 @@ module Commands
245
305
  end
246
306
  )
247
307
 
308
+ # +Array+ of all command names.
248
309
  NAMES = constants.select do |sym|
249
310
  const_get(sym).is_a?(Command)
250
311
  end.map { |sym| sym.to_s.downcase }
251
312
 
313
+ # Parse and execute a line of user input in the given context.
252
314
  def self.exec(input, client, state)
253
315
  if input.start_with?('!')
254
316
  shell(input[1, input.length - 1]) { |line| puts line }
@@ -283,14 +345,6 @@ module Commands
283
345
 
284
346
  private
285
347
 
286
- def self.get_screen_size
287
- begin
288
- Readline.get_screen_size[1]
289
- rescue NotImplementedError
290
- 72
291
- end
292
- end
293
-
294
348
  def self.shell(cmd)
295
349
  begin
296
350
  IO.popen(cmd) do |pipe|
@@ -302,39 +356,4 @@ module Commands
302
356
  end
303
357
  end
304
358
 
305
- def self.table_output(items)
306
- return [] if items.empty?
307
- columns = get_screen_size
308
- item_width = items.map { |item| item.length }.max + 2
309
- column = 0
310
- lines = ['']
311
- items.each do |item|
312
- if column != 0 && column + item_width >= columns
313
- lines << ''
314
- column = 0
315
- end
316
- lines.last << item.ljust(item_width)
317
- column += item_width
318
- end
319
- lines
320
- end
321
-
322
- def self.wrap_output(text)
323
- columns = get_screen_size
324
- column = 0
325
- lines = ['']
326
- text.split.each do |word|
327
- if column != 0 && column + word.length >= columns
328
- lines << ''
329
- column = 0
330
- end
331
- if column != 0
332
- lines.last << ' '
333
- column += 1
334
- end
335
- lines.last << word
336
- column += word.length
337
- end
338
- lines
339
- end
340
359
  end
@@ -0,0 +1,82 @@
1
+ # Module containing tab-completion logic and methods.
2
+ module Complete
3
+
4
+ # Return the directory in which to search for potential local tab-completions
5
+ # for a +String+. Defaults to working directory in case of bogus input.
6
+ def self.local_search_path(string)
7
+ begin
8
+ File.expand_path(strip_filename(string))
9
+ rescue
10
+ Dir.pwd
11
+ end
12
+ end
13
+
14
+ # Returns an +Array+ of potential local tab-completions for a +String+.
15
+ def self.local(string)
16
+ dir = local_search_path(string)
17
+ name = string.end_with?('/') ? '' : File.basename(string)
18
+
19
+ Dir.entries(dir).select do |entry|
20
+ entry.start_with?(name) && !/^\.{1,2}$/.match(entry)
21
+ end.map do |entry|
22
+ entry << (File.directory?(dir + '/' + entry) ? '/' : ' ')
23
+ string + entry[name.length, entry.length]
24
+ end
25
+ end
26
+
27
+ # Returns an +Array+ of potential local tab-completions for a +String+,
28
+ # including only directories.
29
+ def self.local_dir(string)
30
+ local(string).select { |result| result.end_with?('/') }
31
+ end
32
+
33
+ # Return the directory in which to search for potential remote
34
+ # tab-completions for a +String+.
35
+ def self.remote_search_path(string, state)
36
+ path = case
37
+ when string.empty? then state.pwd + '/'
38
+ when string.start_with?('/') then string
39
+ else state.pwd + '/' + string
40
+ end
41
+
42
+ strip_filename(collapse(path))
43
+ end
44
+
45
+ # Returns an +Array+ of potential remote tab-completions for a +String+.
46
+ def self.remote(string, state)
47
+ dir = remote_search_path(string, state)
48
+ name = string.end_with?('/') ? '' : File.basename(string)
49
+
50
+ state.contents(dir).map do |entry|
51
+ File.basename(entry)
52
+ end.select do |entry|
53
+ entry.start_with?(name) && !/^\.{1,2}$/.match(entry)
54
+ end.map do |entry|
55
+ entry << (state.directory?(dir + '/' + entry) ? '/' : ' ')
56
+ string + entry[name.length, entry.length]
57
+ end
58
+ end
59
+
60
+ # Returns an +Array+ of potential remote tab-completions for a +String+,
61
+ # including only directories.
62
+ def self.remote_dir(string, state)
63
+ remote(string, state).select { |result| result.end_with?('/') }
64
+ end
65
+
66
+ private
67
+
68
+ def self.strip_filename(path)
69
+ if path != '/'
70
+ path.end_with?('/') ? path.sub(/\/$/, '') : File.dirname(path)
71
+ else
72
+ path
73
+ end
74
+ end
75
+
76
+ def self.collapse(path)
77
+ nil while path.sub!(/[^\/]+\/\.\.\//, '/')
78
+ nil while path.sub!('./', '')
79
+ path
80
+ end
81
+
82
+ end
@@ -1,12 +1,15 @@
1
- require 'fileutils'
1
+ # Manages persistent (session-independent) application state.
2
+ class Settings
2
3
 
3
- CONFIG_FILE_PATH = File.expand_path('~/.config/droxi/droxirc')
4
+ # The path of the application's rc file.
5
+ CONFIG_FILE_PATH = File.expand_path('~/.config/droxi/droxirc')
4
6
 
5
- class Settings
7
+ # Return the value of a setting, or +nil+ if the setting does not exist.
6
8
  def Settings.[](key)
7
9
  @@settings[key]
8
10
  end
9
11
 
12
+ # Set the value of a setting.
10
13
  def Settings.[]=(key, value)
11
14
  if value != @@settings[key]
12
15
  @@dirty = true
@@ -14,10 +17,12 @@ class Settings
14
17
  end
15
18
  end
16
19
 
20
+ # Return +true+ if the setting exists, +false+ otherwise.
17
21
  def Settings.include?(key)
18
22
  @@settings.include?(key)
19
23
  end
20
24
 
25
+ # Delete the setting and return its value.
21
26
  def Settings.delete(key)
22
27
  if @@settings.include?(key)
23
28
  @@dirty = true
@@ -25,14 +30,17 @@ class Settings
25
30
  end
26
31
  end
27
32
 
28
- def Settings.write
33
+ # Write settings to disk.
34
+ def Settings.save
29
35
  if @@dirty
30
36
  @@dirty = false
37
+ require 'fileutils'
31
38
  FileUtils.mkdir_p(File.dirname(CONFIG_FILE_PATH))
32
39
  File.open(CONFIG_FILE_PATH, 'w') do |file|
33
40
  @@settings.each_pair { |k, v| file.write("#{k}=#{v}\n") }
34
41
  end
35
42
  end
43
+ nil
36
44
  end
37
45
 
38
46
  private
@@ -67,4 +75,5 @@ class Settings
67
75
 
68
76
  @@settings = read
69
77
  @@dirty = false
78
+
70
79
  end
data/lib/droxi/state.rb CHANGED
@@ -1,32 +1,46 @@
1
1
  require_relative 'settings'
2
2
 
3
+ # Encapsulates the session state of the client.
3
4
  class State
4
- attr_reader :oldpwd, :pwd, :cache
5
- attr_accessor :local_oldpwd, :exit_requested
6
5
 
7
- def initialize
6
+ # +Hash+ of remote file paths to cached file metadata.
7
+ attr_reader :cache
8
+
9
+ # The remote working directory path.
10
+ attr_reader :pwd
11
+
12
+ # The previous remote working directory path.
13
+ attr_reader :oldpwd
14
+
15
+ # The previous local working directory path.
16
+ attr_accessor :local_oldpwd
17
+
18
+ # +true+ if the client has requested to quit, +false+ otherwise.
19
+ attr_accessor :exit_requested
20
+
21
+ # Return a new application state that uses the given client. Starts at the
22
+ # Dropbox root and with an empty cache.
23
+ def initialize(client)
24
+ @cache = {}
25
+ @client = client
26
+ @exit_requested = false
8
27
  @pwd = '/'
9
28
  @oldpwd = Settings[:oldpwd] || '/'
10
29
  @local_oldpwd = Dir.pwd
11
- @cache = {}
12
- @exit_requested = false
13
30
  end
14
31
 
15
- def have_all_info_for(path)
16
- @cache.include?(path) &&
17
- (@cache[path].include?('contents') || !@cache[path]['is_dir'])
18
- end
19
-
20
- def metadata(client, path)
32
+ # Return a +Hash+ of the Dropbox metadata for a file, or +nil+ if the file
33
+ # does not exist.
34
+ def metadata(path)
21
35
  tokens = path.split('/').drop(1)
22
36
 
23
37
  for i in 0..tokens.length
24
38
  partial_path = '/' + tokens.take(i).join('/')
25
39
  unless have_all_info_for(partial_path)
26
40
  begin
27
- data = @cache[partial_path] = client.metadata(partial_path)
41
+ data = @cache[partial_path] = @client.metadata(partial_path)
28
42
  rescue DropboxError
29
- return
43
+ return nil
30
44
  end
31
45
  if data.include?('contents')
32
46
  data['contents'].each do |datum|
@@ -39,24 +53,30 @@ class State
39
53
  @cache[path]
40
54
  end
41
55
 
42
- def contents(client, path)
43
- metadata(client, path)
56
+ # Return an +Array+ of paths of files in a Dropbox directory.
57
+ def contents(path)
58
+ metadata(path)
44
59
  path = "#{path}/".sub('//', '/')
45
60
  @cache.keys.select do |key|
46
61
  key.start_with?(path) && key != path && !key.sub(path, '').include?('/')
47
62
  end
48
63
  end
49
64
 
50
- def is_dir?(client, path)
51
- metadata(client, File.dirname(path))
65
+ # Return +true+ if the Dropbox path is a directory, +false+ otherwise.
66
+ def directory?(path)
67
+ path = path.sub('//', '/')
68
+ metadata(File.dirname(path))
52
69
  @cache.include?(path) && @cache[path]['is_dir']
53
70
  end
54
71
 
72
+ # Set the remote working directory, and set the previous remote working
73
+ # directory to the old value.
55
74
  def pwd=(value)
56
75
  @oldpwd, @pwd = @pwd, value
57
76
  Settings[:oldpwd] = @oldpwd
58
77
  end
59
78
 
79
+ # Expand a Dropbox file path and return the result.
60
80
  def resolve_path(path)
61
81
  path = "#{@pwd}/#{path}" unless path.start_with?('/')
62
82
  path.gsub!('//', '/')
@@ -67,12 +87,14 @@ class State
67
87
  path
68
88
  end
69
89
 
70
- def expand_patterns(client, patterns)
90
+ # Expand an +Array+ of file globs into an an +Array+ of Dropbox file paths
91
+ # and return the result.
92
+ def expand_patterns(patterns)
71
93
  patterns.map do |pattern|
72
94
  final_pattern = resolve_path(pattern)
73
95
 
74
96
  matches = []
75
- client.metadata(File.dirname(final_pattern))['contents'].each do |data|
97
+ @client.metadata(File.dirname(final_pattern))['contents'].each do |data|
76
98
  path = data['path']
77
99
  matches << path if File.fnmatch(final_pattern, path)
78
100
  end
@@ -85,43 +107,11 @@ class State
85
107
  end.flatten
86
108
  end
87
109
 
88
- def file_complete(client, word)
89
- tab_complete(client, word, false)
90
- end
91
-
92
- def dir_complete(client, word)
93
- tab_complete(client, word, true)
94
- end
95
-
96
110
  private
97
111
 
98
- def complete(path, prefix_length, dir_only)
99
- @cache.keys.select do |key|
100
- key.start_with?(path) && key != path &&
101
- !(dir_only && !@cache[key]['is_dir'])
102
- end.map do |key|
103
- if @cache[key]['is_dir']
104
- key += '/'
105
- else
106
- key += ' '
107
- end
108
- key[prefix_length, key.length]
109
- end
112
+ def have_all_info_for(path)
113
+ @cache.include?(path) &&
114
+ (@cache[path].include?('contents') || !@cache[path]['is_dir'])
110
115
  end
111
116
 
112
- def tab_complete(client, word, dir_only)
113
- path = resolve_path(word)
114
- prefix_length = path.length - word.length
115
-
116
- if word.end_with?('/')
117
- # Treat word as directory
118
- metadata(client, path)
119
- prefix_length += 1
120
- else
121
- # Treat word as file
122
- metadata(client, File.dirname(path))
123
- end
124
-
125
- complete(path, prefix_length, dir_only)
126
- end
127
117
  end
data/lib/droxi/text.rb ADDED
@@ -0,0 +1,67 @@
1
+ # Module containing text-manipulation methods.
2
+ module Text
3
+
4
+ # The assumed width of the terminal if GNU Readline can't retrieve it.
5
+ DEFAULT_WIDTH = 72
6
+
7
+ # Format an +Array+ of +Strings+ as a table and return an +Array+ of lines
8
+ # in the result.
9
+ def self.table(items)
10
+ if items.empty?
11
+ []
12
+ else
13
+ columns = get_columns
14
+ item_width = items.map { |item| item.length }.max + 2
15
+ items_per_line = [1, columns / item_width].max
16
+ num_lines = (items.length.to_f / items_per_line).ceil
17
+ format_table(items, item_width, items_per_line, num_lines)
18
+ end
19
+ end
20
+
21
+ # Wrap a +String+ to fit the terminal and return an +Array+ of lines in the
22
+ # result.
23
+ def self.wrap(text)
24
+ columns = get_columns
25
+ position = 0
26
+ lines = []
27
+ while position < text.length
28
+ lines << get_wrap_segment(text[position, text.length], columns)
29
+ position += lines.last.length + 1
30
+ end
31
+ lines
32
+ end
33
+
34
+ private
35
+
36
+ def self.get_columns
37
+ require 'readline'
38
+ begin
39
+ columns = Readline.get_screen_size[1]
40
+ columns > 0 ? columns : DEFAULT_WIDTH
41
+ rescue NotImplementedError
42
+ DEFAULT_WIDTH
43
+ end
44
+ end
45
+
46
+ def self.format_table(items, item_width, items_per_line, num_lines)
47
+ num_lines.times.map do |i|
48
+ items[i * items_per_line, items_per_line].map do |item|
49
+ item.ljust(item_width)
50
+ end.join
51
+ end
52
+ end
53
+
54
+ def self.get_wrap_segment(text, columns)
55
+ segment, sep, text = text.partition(' ')
56
+ while !text.empty? && segment.length < columns
57
+ head, sep, text = text.partition(' ')
58
+ segment << " #{head}"
59
+ end
60
+ if segment.length > columns && segment.include?(' ')
61
+ segment.rpartition(' ')[0]
62
+ else
63
+ segment
64
+ end
65
+ end
66
+
67
+ end
data/lib/droxi.rb CHANGED
@@ -2,15 +2,19 @@ require 'dropbox_sdk'
2
2
  require 'readline'
3
3
 
4
4
  require_relative 'droxi/commands'
5
+ require_relative 'droxi/complete'
5
6
  require_relative 'droxi/settings'
6
7
  require_relative 'droxi/state'
7
8
 
9
+ # Command-line Dropbox client module.
8
10
  module Droxi
9
- APP_KEY = '5sufyfrvtro9zp7'
10
- APP_SECRET = 'h99ihzv86jyypho'
11
11
 
12
+ # Attempt to authorize the user for app usage.
12
13
  def self.authorize
13
- flow = DropboxOAuth2FlowNoRedirect.new(APP_KEY, APP_SECRET)
14
+ app_key = '5sufyfrvtro9zp7'
15
+ app_secret = 'h99ihzv86jyypho'
16
+
17
+ flow = DropboxOAuth2FlowNoRedirect.new(app_key, app_secret)
14
18
 
15
19
  authorize_url = flow.start()
16
20
 
@@ -34,8 +38,12 @@ module Droxi
34
38
  rescue DropboxError
35
39
  puts 'Invalid authorization code.'
36
40
  end
41
+
42
+ nil
37
43
  end
38
44
 
45
+ # Get the access token for the user, requesting authorization if no token
46
+ # exists.
39
47
  def self.get_access_token
40
48
  until Settings.include?(:access_token)
41
49
  authorize()
@@ -43,52 +51,16 @@ module Droxi
43
51
  Settings[:access_token]
44
52
  end
45
53
 
54
+ # Print a prompt message reflecting the current state of the application.
46
55
  def self.prompt(info, state)
47
56
  "droxi #{info['email']}:#{state.pwd}> "
48
57
  end
49
58
 
50
- def self.file_complete(word, dir_only=false)
51
- begin
52
- path = File.expand_path(word)
53
- rescue ArgumentError
54
- return []
55
- end
56
- if word.empty? || (word.length > 1 && word.end_with?('/'))
57
- dir = path
58
- else
59
- dir = File.dirname(path)
60
- end
61
- Dir.entries(dir).map do |file|
62
- (dir + '/').sub('//', '/') + file
63
- end.select do |file|
64
- file.start_with?(path) && !(dir_only && !File.directory?(file))
65
- end.map do |file|
66
- if File.directory?(file)
67
- file << '/'
68
- else
69
- file << ' '
70
- end
71
- if word.start_with?('/')
72
- file
73
- elsif word.start_with?('~')
74
- file.sub(/\/home\/[^\/]+/, '~')
75
- else
76
- file.sub(Dir.pwd + '/', '')
77
- end
78
- end
79
- end
80
-
81
- def self.dir_complete(word)
82
- file_complete(word, true)
83
- end
84
-
85
- def self.run
86
- client = DropboxClient.new(get_access_token)
59
+ # Run the client in interactive mode.
60
+ def self.run_interactive(client, state)
87
61
  info = client.account_info
88
62
  puts "Logged in as #{info['display_name']} (#{info['email']})"
89
63
 
90
- state = State.new
91
-
92
64
  Readline.completion_proc = proc do |word|
93
65
  words = Readline.line_buffer.split
94
66
  index = words.length
@@ -105,24 +77,11 @@ module Droxi
105
77
  Commands::NAMES.select { |name| name.start_with? word }.map do |name|
106
78
  name + ' '
107
79
  end
108
- when 'LOCAL_FILE'
109
- file_complete(word)
110
- when 'LOCAL_DIR'
111
- dir_complete(word)
112
- when 'REMOTE_FILE'
113
- begin
114
- state.file_complete(client, word)
115
- rescue DropboxError
116
- []
117
- end
118
- when 'REMOTE_DIR'
119
- begin
120
- state.dir_complete(client, word)
121
- rescue DropboxError
122
- []
123
- end
124
- else
125
- []
80
+ when 'LOCAL_FILE' then Complete.local(word)
81
+ when 'LOCAL_DIR' then Complete.local_dir(word)
82
+ when 'REMOTE_FILE' then Complete.remote(word, state)
83
+ when 'REMOTE_DIR' then Complete.remote_dir(word, state)
84
+ else []
126
85
  end
127
86
 
128
87
  options.map { |option| option.gsub(' ', '\ ').sub(/\\ $/, ' ') }
@@ -143,7 +102,26 @@ module Droxi
143
102
  puts
144
103
  end
145
104
 
105
+ # Set pwd so that the oldpwd setting is set to pwd
146
106
  state.pwd = '/'
147
- Settings.write
107
+ Settings.save
108
+ end
109
+
110
+ # Run the client.
111
+ def self.run(*args)
112
+ client = DropboxClient.new(get_access_token)
113
+ state = State.new(client)
114
+
115
+ if args.empty?
116
+ run_interactive(client, state)
117
+ else
118
+ cmd = args.map { |arg| arg.gsub(' ', '\ ') }.join(' ')
119
+ begin
120
+ Commands.exec(cmd, client, state)
121
+ rescue Interrupt
122
+ puts
123
+ end
124
+ end
148
125
  end
126
+
149
127
  end
@@ -35,7 +35,7 @@ describe Commands do
35
35
  original_dir = Dir.pwd
36
36
 
37
37
  client = DropboxClient.new(Settings[:access_token])
38
- state = State.new
38
+ state = State.new(client)
39
39
 
40
40
  TEMP_FILENAME = 'test.txt'
41
41
  TEMP_FOLDER = 'test'
@@ -44,6 +44,10 @@ describe Commands do
44
44
  ignore(DropboxError) { client.file_delete("/#{TEST_FOLDER}") }
45
45
  ignore(DropboxError) { client.file_create_folder("/#{TEST_FOLDER}") }
46
46
 
47
+ before do
48
+ Dir.chdir(original_dir)
49
+ end
50
+
47
51
  describe 'when executing a shell command' do
48
52
  it 'must yield the output' do
49
53
  lines = []
@@ -88,7 +92,6 @@ describe Commands do
88
92
 
89
93
  describe 'when executing the get command' do
90
94
  it 'must get a file of the same name when given args' do
91
- Dir.chdir(original_dir)
92
95
  put_temp_file(client, state)
93
96
  Commands::GET.exec(client, state, '/testing/test.txt')
94
97
  delete_temp_file(client, state)
@@ -99,26 +102,22 @@ describe Commands do
99
102
 
100
103
  describe 'when executing the lcd command' do
101
104
  it 'must change to home directory when given no args' do
102
- Dir.chdir(original_dir)
103
105
  Commands::LCD.exec(client, state)
104
106
  Dir.pwd.must_equal File.expand_path('~')
105
107
  end
106
108
 
107
109
  it 'must change to specific directory when specified' do
108
- Dir.chdir(original_dir)
109
110
  Commands::LCD.exec(client, state, '/home')
110
111
  Dir.pwd.must_equal File.expand_path('/home')
111
112
  end
112
113
 
113
114
  it 'must set oldpwd correctly' do
114
- Dir.chdir(original_dir)
115
115
  oldpwd = Dir.pwd
116
116
  Commands::LCD.exec(client, state, '/')
117
117
  state.local_oldpwd.must_equal oldpwd
118
118
  end
119
119
 
120
120
  it 'must change to previous directory when given -' do
121
- Dir.chdir(original_dir)
122
121
  oldpwd = Dir.pwd
123
122
  Commands::LCD.exec(client, state, '/')
124
123
  Commands::LCD.exec(client, state, '-')
@@ -126,7 +125,6 @@ describe Commands do
126
125
  end
127
126
 
128
127
  it 'must fail if given bogus directory name' do
129
- Dir.chdir(original_dir)
130
128
  pwd = Dir.pwd
131
129
  oldpwd = state.local_oldpwd
132
130
  Commands::LCD.exec(client, state, '/bogus_dir')
@@ -155,6 +153,17 @@ describe Commands do
155
153
  end
156
154
  end
157
155
 
156
+ describe 'when executing the media command' do
157
+ it 'must yield URL when given file path' do
158
+ put_temp_file(client, state)
159
+ to_path = "/#{TEST_FOLDER}/#{TEMP_FILENAME}"
160
+ lines = get_output(:MEDIA, client, state, to_path)
161
+ delete_temp_file(client, state)
162
+ lines.length.must_equal 1
163
+ /https:\/\/.+\..+\//.match(lines[0]).wont_equal nil
164
+ end
165
+ end
166
+
158
167
  describe 'when executing the mkdir command' do
159
168
  it 'must create a directory when given args' do
160
169
  Commands::MKDIR.exec(client, state, '/testing/test')
@@ -165,7 +174,6 @@ describe Commands do
165
174
 
166
175
  describe 'when executing the put command' do
167
176
  it 'must put a file of the same name when given 1 arg' do
168
- Dir.chdir(original_dir)
169
177
  state.pwd = '/testing'
170
178
  `echo hello > test.txt`
171
179
  Commands::PUT.exec(client, state, 'test.txt')
@@ -175,7 +183,6 @@ describe Commands do
175
183
  end
176
184
 
177
185
  it 'must put a file with the stated name when given 2 args' do
178
- Dir.chdir(original_dir)
179
186
  state.pwd = '/testing'
180
187
  `echo hello > test.txt`
181
188
  Commands::PUT.exec(client, state, 'test.txt', 'dest.txt')
@@ -0,0 +1,114 @@
1
+ require 'dropbox_sdk'
2
+ require 'minitest/autorun'
3
+
4
+ require_relative '../lib/droxi/complete'
5
+ require_relative '../lib/droxi/settings'
6
+ require_relative '../lib/droxi/state'
7
+
8
+ describe Complete do
9
+ CHARACTERS = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
10
+
11
+ def random_character
12
+ CHARACTERS[rand(CHARACTERS.length)]
13
+ end
14
+
15
+ def random_string(length)
16
+ rand(length).times.map { random_character }.join
17
+ end
18
+
19
+ describe "when resolving a local search path" do
20
+ it "must resolve unqualified string to working directory" do
21
+ Complete.local_search_path('').must_equal Dir.pwd
22
+ Complete.local_search_path('f').must_equal Dir.pwd
23
+ end
24
+
25
+ it "must resolve / to root directory" do
26
+ Complete.local_search_path('/').must_equal '/'
27
+ Complete.local_search_path('/f').must_equal '/'
28
+ end
29
+
30
+ it "must resolve directory name to named directory" do
31
+ Complete.local_search_path('/home/').must_equal '/home'
32
+ Complete.local_search_path('/home/f').must_equal '/home'
33
+ end
34
+
35
+ it "must resolve ~/ to home directory" do
36
+ Complete.local_search_path('~/').must_equal Dir.home
37
+ Complete.local_search_path('~/f').must_equal Dir.home
38
+ end
39
+
40
+ it "must resolve ./ to working directory" do
41
+ Complete.local_search_path('./').must_equal Dir.pwd
42
+ Complete.local_search_path('./f').must_equal Dir.pwd
43
+ end
44
+
45
+ it "must resolve ../ to parent directory" do
46
+ Complete.local_search_path('../').must_equal File.dirname(Dir.pwd)
47
+ Complete.local_search_path('../f').must_equal File.dirname(Dir.pwd)
48
+ end
49
+
50
+ it "won't raise an exception on a bogus string" do
51
+ Complete.local_search_path('~bogus')
52
+ end
53
+ end
54
+
55
+ describe "when finding potential local tab completions" do
56
+ def check(path)
57
+ 100.times.all? do |i|
58
+ prefix = path + random_string(5)
59
+ Complete.local(prefix).all? { |match| match.start_with?(prefix) }
60
+ end.must_equal true
61
+ 1000.times.any? do |i|
62
+ prefix = path + random_string(5)
63
+ !Complete.local(prefix).empty?
64
+ end.must_equal true
65
+ end
66
+
67
+ it "seed must prefix results for unqualified string" do check('') end
68
+ it "seed must prefix results for /" do check('/') end
69
+ it "seed must prefix results for named directory" do check('/home/') end
70
+ it "seed must prefix results for ~/" do check('~/') end
71
+ it "seed must prefix results for ./" do check('./') end
72
+ it "seed must prefix results for ../" do check('../') end
73
+
74
+ it "won't raise an exception on a bogus string" do
75
+ Complete.local('~bogus')
76
+ end
77
+ end
78
+
79
+ describe "when resolving a remote search path" do
80
+ client = DropboxClient.new(Settings[:access_token])
81
+ begin
82
+ client.file_create_folder('/testing')
83
+ rescue DropboxError
84
+ end
85
+ state = State.new(client)
86
+ state.pwd = '/testing'
87
+
88
+ it "must resolve unqualified string to working directory" do
89
+ Complete.remote_search_path('', state).must_equal state.pwd
90
+ Complete.remote_search_path('f', state).must_equal state.pwd
91
+ end
92
+
93
+ it "must resolve / to root directory" do
94
+ Complete.remote_search_path('/', state).must_equal '/'
95
+ Complete.remote_search_path('/f', state).must_equal '/'
96
+ end
97
+
98
+ it "must resolve directory name to named directory" do
99
+ Complete.remote_search_path('/testing/', state).must_equal '/testing'
100
+ Complete.remote_search_path('/testing/f', state).must_equal '/testing'
101
+ end
102
+
103
+ it "must resolve ./ to working directory" do
104
+ Complete.remote_search_path('./', state).must_equal state.pwd
105
+ Complete.remote_search_path('./f', state).must_equal state.pwd
106
+ end
107
+
108
+ it "must resolve ../ to parent directory" do
109
+ parent = File.dirname(state.pwd)
110
+ Complete.remote_search_path('../', state).must_equal parent
111
+ Complete.remote_search_path('../f', state).must_equal parent
112
+ end
113
+ end
114
+ end
data/spec/state_spec.rb CHANGED
@@ -6,19 +6,19 @@ require_relative '../lib/droxi/settings'
6
6
  describe State do
7
7
  describe 'when initializing' do
8
8
  it 'must set pwd to root' do
9
- State.new.pwd.must_equal '/'
9
+ State.new(nil).pwd.must_equal '/'
10
10
  end
11
11
 
12
12
  it 'must set oldpwd to saved oldpwd' do
13
13
  if Settings.include?(:oldpwd)
14
- State.new.oldpwd.must_equal Settings[:oldpwd]
14
+ State.new(nil).oldpwd.must_equal Settings[:oldpwd]
15
15
  end
16
16
  end
17
17
  end
18
18
 
19
19
  describe 'when setting pwd' do
20
20
  it 'must change pwd and set oldpwd to previous pwd' do
21
- state = State.new
21
+ state = State.new(nil)
22
22
  state.pwd = '/testing'
23
23
  state.pwd.must_equal '/testing'
24
24
  state.pwd = '/'
@@ -27,7 +27,7 @@ describe State do
27
27
  end
28
28
 
29
29
  describe 'when resolving path' do
30
- state = State.new
30
+ state = State.new(nil)
31
31
 
32
32
  it 'must resolve root to itself' do
33
33
  state.resolve_path('/').must_equal '/'
data/spec/text_spec.rb ADDED
@@ -0,0 +1,60 @@
1
+ require 'minitest/autorun'
2
+
3
+ require_relative '../lib/droxi/text'
4
+
5
+ describe Text do
6
+ before do
7
+ @columns = Text::DEFAULT_WIDTH
8
+ @paragraph = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, \
9
+ sed do eiusmod tempor incididunt ut labore et dolore \
10
+ magna aliqua. Ut enim ad minim veniam, quis nostrud \
11
+ exercitation ullamco laboris nisi ut aliquip ex ea \
12
+ commodo consequat. Duis aute irure dolor in reprehenderit \
13
+ in voluptate velit esse cillum dolore eu fugiat nulla \
14
+ pariatur. Excepteur sint occaecat cupidatat non proident, \
15
+ sunt in culpa qui officia deserunt mollit anim id est \
16
+ laborum.".squeeze(' ')
17
+ @big_word = "Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphi\
18
+ oparaomelitokatakechymenokichlepikossyphophattoperisteralektr\
19
+ yonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon".
20
+ gsub(' ', '')
21
+ end
22
+
23
+ describe "when wrapping text" do
24
+ it "won't return any line larger than the screen width if unnecessary" do
25
+ Text.wrap(@paragraph).all? do |line|
26
+ line.length <= @columns
27
+ end.must_equal true
28
+ end
29
+
30
+ it "won't split a word larger than the screen width" do
31
+ Text.wrap(@big_word).length.must_equal 1
32
+ end
33
+ end
34
+
35
+ describe "when tabulating text" do
36
+ it "must space items equally" do
37
+ lines = Text.table(@paragraph.split)
38
+ lines = lines[0, lines.length - 1]
39
+
40
+ space_positions = [0]
41
+ while lines.first.index(/ \S/, space_positions.last + 3)
42
+ space_positions << lines.first.index(/ \S/, space_positions.last + 3)
43
+ end
44
+
45
+ space_positions.drop(1).all? do |position|
46
+ lines.all? { |line| / \S/.match(line[position, 3]) }
47
+ end.must_equal true
48
+ end
49
+
50
+ it "won't return any line larger than the screen width if unnecessary" do
51
+ Text.table(@paragraph.split).all? do |line|
52
+ line.length <= @columns
53
+ end.must_equal true
54
+ end
55
+
56
+ it "won't split a word larger than the screen width" do
57
+ Text.table([@big_word]).length.must_equal 1
58
+ end
59
+ end
60
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: droxi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brandon Mulcahy
@@ -30,8 +30,8 @@ dependencies:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: 1.6.1
33
- description: a command-line Dropbox interface inspired by GNU coreutils, GNU ftp,
34
- and lftp. features smart tab completion, globbing, and interactive help.
33
+ description: A command-line Dropbox interface inspired by GNU coreutils, GNU ftp,
34
+ and lftp. Features include smart tab completion, globbing, and interactive help.
35
35
  email: brandon@jangler.info
36
36
  executables:
37
37
  - droxi
@@ -46,12 +46,16 @@ files:
46
46
  - droxi.gemspec
47
47
  - lib/droxi.rb
48
48
  - lib/droxi/commands.rb
49
+ - lib/droxi/complete.rb
49
50
  - lib/droxi/settings.rb
50
51
  - lib/droxi/state.rb
52
+ - lib/droxi/text.rb
51
53
  - spec/all.rb
52
54
  - spec/commands_spec.rb
55
+ - spec/complete_spec.rb
53
56
  - spec/settings_spec.rb
54
57
  - spec/state_spec.rb
58
+ - spec/text_spec.rb
55
59
  homepage: https://github.com/jangler/droxi
56
60
  licenses:
57
61
  - MIT