droxi 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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