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 +4 -4
- data/Rakefile +28 -16
- data/bin/droxi +1 -1
- data/droxi.1.template +6 -1
- data/droxi.gemspec +3 -3
- data/lib/droxi/commands.rb +81 -62
- data/lib/droxi/complete.rb +82 -0
- data/lib/droxi/settings.rb +13 -4
- data/lib/droxi/state.rb +44 -54
- data/lib/droxi/text.rb +67 -0
- data/lib/droxi.rb +39 -61
- data/spec/commands_spec.rb +16 -9
- data/spec/complete_spec.rb +114 -0
- data/spec/state_spec.rb +4 -4
- data/spec/text_spec.rb +60 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dd71a917fd5c4fd91e3193c2805df9759505ac95
|
4
|
+
data.tar.gz: b5a4517dbc2ff604120b559919bc2750616acbfb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
39
|
-
|
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
|
-
|
44
|
-
|
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
|
-
|
47
|
-
|
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
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
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.
|
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 = "
|
7
|
-
coreutils, GNU ftp, and lftp.
|
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'
|
data/lib/droxi/commands.rb
CHANGED
@@ -1,18 +1,38 @@
|
|
1
|
-
|
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.
|
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(
|
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
|
-
|
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
|
-
|
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.
|
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(
|
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
|
-
|
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
|
-
|
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(
|
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(
|
297
|
+
state.expand_patterns(args).each do |path|
|
239
298
|
begin
|
240
|
-
|
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
|
data/lib/droxi/settings.rb
CHANGED
@@ -1,12 +1,15 @@
|
|
1
|
-
|
1
|
+
# Manages persistent (session-independent) application state.
|
2
|
+
class Settings
|
2
3
|
|
3
|
-
|
4
|
+
# The path of the application's rc file.
|
5
|
+
CONFIG_FILE_PATH = File.expand_path('~/.config/droxi/droxirc')
|
4
6
|
|
5
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
43
|
-
|
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
|
-
|
51
|
-
|
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
|
-
|
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
|
99
|
-
@cache.
|
100
|
-
|
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
|
-
|
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
|
-
|
51
|
-
|
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
|
-
|
110
|
-
when '
|
111
|
-
|
112
|
-
|
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.
|
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
|
data/spec/commands_spec.rb
CHANGED
@@ -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.
|
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:
|
34
|
-
and lftp.
|
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
|