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 +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
|