droxi 0.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c42bb596852aacff40852b70646a1a74c49930ff
4
+ data.tar.gz: a1cae76af97bc5064f2ee567035d5a50c6e768c9
5
+ SHA512:
6
+ metadata.gz: 1a4b91bca5b16d9bc98b3ab37fde5e5404ec86eb8ffc0f28426657a6555882efa34849b020c410205665044a4e2abe34bce85928e011d5d13b4417a2debe2486
7
+ data.tar.gz: b5fa9257afa11b305124d823efa38dc32c3c8fffc577a094ce6f0b249bccdce1d89f8f95cf4e28114da916a074782c94a96503be0b84d8ec4449c44fe42436b5
data/bin/droxi ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'droxi'
4
+ Droxi.run
@@ -0,0 +1,329 @@
1
+ require 'readline'
2
+
3
+ module Commands
4
+ class UsageError < ArgumentError
5
+ end
6
+
7
+ class Command
8
+ attr_reader :usage, :description
9
+
10
+ def initialize(usage, description, procedure)
11
+ @usage = usage
12
+ @description = description.squeeze(' ')
13
+ @procedure = procedure
14
+ end
15
+
16
+ def exec(client, state, *args)
17
+ if num_args_ok?(args.length)
18
+ block = proc { |line| yield line if block_given? }
19
+ @procedure.yield(client, state, args, block)
20
+ else
21
+ raise UsageError.new(@usage)
22
+ end
23
+ end
24
+
25
+ def num_args_ok?(num_args)
26
+ args = @usage.split.drop(1)
27
+ min_args = args.reject { |arg| arg.start_with?('[') }.length
28
+ if args.last.end_with?('...')
29
+ max_args = num_args
30
+ else
31
+ max_args = args.length
32
+ end
33
+ (min_args..max_args).include?(num_args)
34
+ end
35
+
36
+ def type_of_arg(index)
37
+ args = @usage.split.drop(1)
38
+ index = [index, args.length - 1].min
39
+ args[index].tr('[].', '')
40
+ end
41
+ end
42
+
43
+ CD = Command.new(
44
+ 'cd [REMOTE_DIR]',
45
+ "Change the remote working directory. With no arguments, changes to the \
46
+ Dropbox root. With a remote directory name as the argument, changes to \
47
+ that directory. With - as the argument, changes to the previous working \
48
+ directory.",
49
+ lambda do |client, state, args, output|
50
+ if args.empty?
51
+ state.pwd = '/'
52
+ elsif args[0] == '-'
53
+ state.pwd = state.oldpwd
54
+ else
55
+ path = state.resolve_path(args[0])
56
+ if state.is_dir?(client, path)
57
+ state.pwd = path
58
+ else
59
+ output.call('Not a directory')
60
+ end
61
+ end
62
+ end
63
+ )
64
+
65
+ GET = Command.new(
66
+ 'get REMOTE_FILE...',
67
+ "Download each specified remote file to a file of the same name in the \
68
+ local working directory.",
69
+ lambda do |client, state, args, output|
70
+ state.expand_patterns(client, args).each do |path|
71
+ begin
72
+ contents = client.get_file(path)
73
+ File.open(File.basename(path), 'wb') do |file|
74
+ file.write(contents)
75
+ end
76
+ rescue DropboxError => error
77
+ output.call(error.to_s)
78
+ end
79
+ end
80
+ end
81
+ )
82
+
83
+ HELP = Command.new(
84
+ 'help [COMMAND]',
85
+ "Print usage and help information about a command. If no command is \
86
+ given, print a list of commands instead.",
87
+ lambda do |client, state, args, output|
88
+ if args.empty?
89
+ table_output(NAMES).each { |line| output.call(line) }
90
+ else
91
+ cmd_name = args[0]
92
+ if NAMES.include?(cmd_name)
93
+ cmd = const_get(cmd_name.upcase.to_s)
94
+ output.call(cmd.usage)
95
+ wrap_output(cmd.description).each { |line| output.call(line) }
96
+ else
97
+ output.call("Unrecognized command: #{cmd_name}")
98
+ end
99
+ end
100
+ end
101
+ )
102
+
103
+ LCD = Command.new(
104
+ 'lcd [LOCAL_DIR]',
105
+ "Change the local working directory. With no arguments, changes to the \
106
+ home directory. With a local directory name as the argument, changes to \
107
+ that directory. With - as the argument, changes to the previous working \
108
+ directory.",
109
+ lambda do |client, state, args, output|
110
+ path = if args.empty?
111
+ File.expand_path('~')
112
+ elsif args[0] == '-'
113
+ state.local_oldpwd
114
+ else
115
+ File.expand_path(args[0])
116
+ end
117
+
118
+ if Dir.exists?(path)
119
+ state.local_oldpwd = Dir.pwd
120
+ Dir.chdir(path)
121
+ else
122
+ output.call("lcd: #{args[0]}: No such file or directory")
123
+ end
124
+ end
125
+ )
126
+
127
+ LS = Command.new(
128
+ 'ls [REMOTE_FILE]...',
129
+ "List information about remote files. With no arguments, list the \
130
+ contents of the working directory. When given remote directories as \
131
+ arguments, list the contents of the directories. When given remote files \
132
+ as arguments, list the files.",
133
+ lambda do |client, state, args, output|
134
+ patterns = if args.empty?
135
+ ["#{state.pwd}/*".sub('//', '/')]
136
+ else
137
+ args.map do |path|
138
+ path = state.resolve_path(path)
139
+ begin
140
+ if state.is_dir?(client, path)
141
+ "#{path}/*".sub('//', '/')
142
+ else
143
+ path
144
+ end
145
+ rescue DropboxError
146
+ path
147
+ end
148
+ end
149
+ end
150
+
151
+ items = []
152
+ patterns.each do |pattern|
153
+ begin
154
+ dir = File.dirname(pattern)
155
+ state.contents(client, dir).each do |path|
156
+ items << File.basename(path) if File.fnmatch(pattern, path)
157
+ end
158
+ rescue DropboxError => error
159
+ output.call(error.to_s)
160
+ end
161
+ end
162
+ table_output(items).each { |item| output.call(item) }
163
+ end
164
+ )
165
+
166
+ MKDIR = Command.new(
167
+ 'mkdir REMOTE_DIR...',
168
+ "Create remote directories.",
169
+ lambda do |client, state, args, output|
170
+ args.each do |arg|
171
+ begin
172
+ path = state.resolve_path(arg)
173
+ state.cache[path] = client.file_create_folder(path)
174
+ rescue DropboxError => error
175
+ output.call(error.to_s)
176
+ end
177
+ end
178
+ end
179
+ )
180
+
181
+ PUT = Command.new(
182
+ 'put LOCAL_FILE [REMOTE_FILE]',
183
+ "Upload a local file to a remote path. If a remote file of the same name \
184
+ already exists, Dropbox will rename the upload. When given only a local \
185
+ file path, the remote path defaults to a file of the same name in the \
186
+ remote working directory.",
187
+ lambda do |client, state, args, output|
188
+ from_path = args[0]
189
+ if args.length == 2
190
+ to_path = args[1]
191
+ else
192
+ to_path = File.basename(from_path)
193
+ end
194
+ to_path = state.resolve_path(to_path)
195
+
196
+ begin
197
+ File.open(File.expand_path(from_path), 'rb') do |file|
198
+ state.cache[to_path] = client.put_file(to_path, file)
199
+ end
200
+ rescue Exception => error
201
+ output.call(error.to_s)
202
+ end
203
+ end
204
+ )
205
+
206
+ RM = Command.new(
207
+ 'rm REMOTE_FILE...',
208
+ "Remove each specified remote file or directory.",
209
+ lambda do |client, state, args, output|
210
+ state.expand_patterns(client, args).each do |path|
211
+ begin
212
+ client.file_delete(path)
213
+ state.cache.delete(path)
214
+ rescue DropboxError => error
215
+ output.call(error.to_s)
216
+ end
217
+ end
218
+ end
219
+ )
220
+
221
+ SHARE = Command.new(
222
+ 'share REMOTE_FILE...',
223
+ "Get URLs to share remote files. Shareable links created on Dropbox are \
224
+ time-limited, but don't require any authentication, so they can be given \
225
+ out freely. The time limit should allow at least a day of shareability.",
226
+ lambda do |client, state, args, output|
227
+ state.expand_patterns(client, args).each do |path|
228
+ begin
229
+ output.call("#{path}: #{client.shares(path)['url']}")
230
+ rescue DropboxError => error
231
+ output.call(error.to_s)
232
+ end
233
+ end
234
+ end
235
+ )
236
+
237
+ NAMES = constants.select do |sym|
238
+ const_get(sym).is_a?(Command)
239
+ end.map { |sym| sym.to_s.downcase }
240
+
241
+ def self.exec(input, client, state)
242
+ if input.start_with?('!')
243
+ shell(input[1, input.length - 1]) { |line| puts line }
244
+ elsif not input.empty?
245
+ tokens = input.split
246
+
247
+ # Escape spaces with backslash
248
+ i = 0
249
+ while i < tokens.length - 1
250
+ if tokens[i].end_with?('\\')
251
+ tokens[i] = "#{tokens[i].chop} #{tokens.delete_at(i + 1)}"
252
+ else
253
+ i += 1
254
+ end
255
+ end
256
+
257
+ cmd, args = tokens[0], tokens.drop(1)
258
+
259
+ if NAMES.include?(cmd)
260
+ begin
261
+ const_get(cmd.upcase.to_sym).exec(client, state, *args) do |line|
262
+ puts line
263
+ end
264
+ rescue UsageError => error
265
+ puts "Usage: #{error}"
266
+ end
267
+ else
268
+ puts "Unrecognized command: #{cmd}"
269
+ end
270
+ end
271
+ end
272
+
273
+ private
274
+
275
+ def self.get_screen_size
276
+ begin
277
+ Readline.get_screen_size[1]
278
+ rescue NotImplementedError
279
+ 72
280
+ end
281
+ end
282
+
283
+ def self.shell(cmd)
284
+ begin
285
+ IO.popen(cmd) do |pipe|
286
+ pipe.each_line { |line| yield line.chomp if block_given? }
287
+ end
288
+ rescue Interrupt
289
+ rescue Exception => error
290
+ yield error.to_s if block_given?
291
+ end
292
+ end
293
+
294
+ def self.table_output(items)
295
+ return [] if items.empty?
296
+ columns = get_screen_size
297
+ item_width = items.map { |item| item.length }.max + 2
298
+ column = 0
299
+ lines = ['']
300
+ items.each do |item|
301
+ if column != 0 && column + item_width >= columns
302
+ lines << ''
303
+ column = 0
304
+ end
305
+ lines.last << item.ljust(item_width)
306
+ column += item_width
307
+ end
308
+ lines
309
+ end
310
+
311
+ def self.wrap_output(text)
312
+ columns = get_screen_size
313
+ column = 0
314
+ lines = ['']
315
+ text.split.each do |word|
316
+ if column != 0 && column + word.length >= columns
317
+ lines << ''
318
+ column = 0
319
+ end
320
+ if column != 0
321
+ lines.last << ' '
322
+ column += 1
323
+ end
324
+ lines.last << word
325
+ column += word.length
326
+ end
327
+ lines
328
+ end
329
+ end
@@ -0,0 +1,70 @@
1
+ require 'fileutils'
2
+
3
+ CONFIG_FILE_PATH = File.expand_path('~/.config/droxi/droxirc')
4
+
5
+ class Settings
6
+ def Settings.[](key)
7
+ @@settings[key]
8
+ end
9
+
10
+ def Settings.[]=(key, value)
11
+ if value != @@settings[key]
12
+ @@dirty = true
13
+ @@settings[key] = value
14
+ end
15
+ end
16
+
17
+ def Settings.include?(key)
18
+ @@settings.include?(key)
19
+ end
20
+
21
+ def Settings.delete(key)
22
+ if @@settings.include?(key)
23
+ @@dirty = true
24
+ @@settings.delete(key)
25
+ end
26
+ end
27
+
28
+ def Settings.write
29
+ if @@dirty
30
+ @@dirty = false
31
+ FileUtils.mkdir_p(File.dirname(CONFIG_FILE_PATH))
32
+ File.open(CONFIG_FILE_PATH, 'w') do |file|
33
+ @@settings.each_pair { |k, v| file.write("#{k}=#{v}\n") }
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def Settings.warn_invalid(line)
41
+ warn "invalid setting: #{line}"
42
+ {}
43
+ end
44
+
45
+ def Settings.parse(line)
46
+ if /^(.+?)=(.+)$/ =~ line
47
+ key, value = $1.to_sym, $2
48
+ if [:access_token, :oldpwd].include?(key)
49
+ {key => value}
50
+ else
51
+ warn_invalid(line)
52
+ end
53
+ else
54
+ warn_invalid(line)
55
+ end
56
+ end
57
+
58
+ def Settings.read
59
+ if File.exists?(CONFIG_FILE_PATH)
60
+ File.open(CONFIG_FILE_PATH) do |file|
61
+ file.each_line.reduce({}) { |a, e| a.merge(parse(e.strip)) }
62
+ end
63
+ else
64
+ {}
65
+ end
66
+ end
67
+
68
+ @@settings = read
69
+ @@dirty = false
70
+ end
@@ -0,0 +1,126 @@
1
+ require_relative 'settings'
2
+
3
+ class State
4
+ attr_reader :oldpwd, :pwd, :cache
5
+ attr_accessor :local_oldpwd
6
+
7
+ def initialize
8
+ @pwd = '/'
9
+ @oldpwd = Settings[:oldpwd] || '/'
10
+ @local_oldpwd = Dir.pwd
11
+ @cache = {}
12
+ end
13
+
14
+ def have_all_info_for(path)
15
+ @cache.include?(path) &&
16
+ (@cache[path].include?('contents') || !@cache[path]['is_dir'])
17
+ end
18
+
19
+ def metadata(client, path)
20
+ tokens = path.split('/').drop(1)
21
+
22
+ for i in 0..tokens.length
23
+ partial_path = '/' + tokens.take(i).join('/')
24
+ unless have_all_info_for(partial_path)
25
+ begin
26
+ data = @cache[partial_path] = client.metadata(partial_path)
27
+ rescue DropboxError
28
+ return
29
+ end
30
+ if data.include?('contents')
31
+ data['contents'].each do |datum|
32
+ @cache[datum['path']] = datum
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ @cache[path]
39
+ end
40
+
41
+ def contents(client, path)
42
+ metadata(client, path)
43
+ path = "#{path}/".sub('//', '/')
44
+ @cache.keys.select do |key|
45
+ key.start_with?(path) && key != path && !key.sub(path, '').include?('/')
46
+ end
47
+ end
48
+
49
+ def is_dir?(client, path)
50
+ metadata(client, File.dirname(path))
51
+ @cache.include?(path) && @cache[path]['is_dir']
52
+ end
53
+
54
+ def pwd=(value)
55
+ @oldpwd, @pwd = @pwd, value
56
+ Settings[:oldpwd] = @oldpwd
57
+ end
58
+
59
+ def resolve_path(path)
60
+ path = "#{@pwd}/#{path}" unless path.start_with?('/')
61
+ path.gsub!('//', '/')
62
+ while path.sub!(/\/([^\/]+?)\/\.\./, '')
63
+ end
64
+ path.chomp!('/')
65
+ path = '/' if path.empty?
66
+ path
67
+ end
68
+
69
+ def expand_patterns(client, patterns)
70
+ patterns.map do |pattern|
71
+ final_pattern = resolve_path(pattern)
72
+
73
+ matches = []
74
+ client.metadata(File.dirname(final_pattern))['contents'].each do |data|
75
+ path = data['path']
76
+ matches << path if File.fnmatch(final_pattern, path)
77
+ end
78
+
79
+ if matches.empty?
80
+ [final_pattern]
81
+ else
82
+ matches
83
+ end
84
+ end.flatten
85
+ end
86
+
87
+ def file_complete(client, word)
88
+ tab_complete(client, word, false)
89
+ end
90
+
91
+ def dir_complete(client, word)
92
+ tab_complete(client, word, true)
93
+ end
94
+
95
+ private
96
+
97
+ def complete(path, prefix_length, dir_only)
98
+ @cache.keys.select do |key|
99
+ key.start_with?(path) && key != path &&
100
+ !(dir_only && !@cache[key]['is_dir'])
101
+ end.map do |key|
102
+ if @cache[key]['is_dir']
103
+ key += '/'
104
+ else
105
+ key += ' '
106
+ end
107
+ key[prefix_length, key.length]
108
+ end
109
+ end
110
+
111
+ def tab_complete(client, word, dir_only)
112
+ path = resolve_path(word)
113
+ prefix_length = path.length - word.length
114
+
115
+ if word.end_with?('/')
116
+ # Treat word as directory
117
+ metadata(client, path)
118
+ prefix_length += 1
119
+ else
120
+ # Treat word as file
121
+ metadata(client, File.dirname(path))
122
+ end
123
+
124
+ complete(path, prefix_length, dir_only)
125
+ end
126
+ end
data/lib/droxi.rb ADDED
@@ -0,0 +1,142 @@
1
+ require 'dropbox_sdk'
2
+ require 'readline'
3
+
4
+ require_relative 'droxi/commands'
5
+ require_relative 'droxi/settings'
6
+ require_relative 'droxi/state'
7
+
8
+ module Droxi
9
+ APP_KEY = '5sufyfrvtro9zp7'
10
+ APP_SECRET = 'h99ihzv86jyypho'
11
+
12
+ def self.authorize
13
+ flow = DropboxOAuth2FlowNoRedirect.new(APP_KEY, APP_SECRET)
14
+
15
+ authorize_url = flow.start()
16
+
17
+ # Have the user sign in and authorize this app
18
+ puts '1. Go to: ' + authorize_url
19
+ puts '2. Click "Allow" (you might have to log in first)'
20
+ puts '3. Copy the authorization code'
21
+ print 'Enter the authorization code here: '
22
+ code = gets.strip
23
+
24
+ # This will fail if the user gave us an invalid authorization code
25
+ begin
26
+ access_token, user_id = flow.finish(code)
27
+ Settings[:access_token] = access_token
28
+ rescue DropboxError
29
+ puts 'Invalid authorization code.'
30
+ end
31
+ end
32
+
33
+ def self.get_access_token
34
+ until Settings.include?(:access_token)
35
+ authorize()
36
+ end
37
+ Settings[:access_token]
38
+ end
39
+
40
+ def self.prompt(info, state)
41
+ "droxi #{info['email']}:#{state.pwd}> "
42
+ end
43
+
44
+ def self.file_complete(word, dir_only=false)
45
+ begin
46
+ path = File.expand_path(word)
47
+ rescue ArgumentError
48
+ return []
49
+ end
50
+ if word.empty? || (word.length > 1 && word.end_with?('/'))
51
+ dir = path
52
+ else
53
+ dir = File.dirname(path)
54
+ end
55
+ Dir.entries(dir).map do |file|
56
+ (dir + '/').sub('//', '/') + file
57
+ end.select do |file|
58
+ file.start_with?(path) && !(dir_only && !File.directory?(file))
59
+ end.map do |file|
60
+ if File.directory?(file)
61
+ file << '/'
62
+ else
63
+ file << ' '
64
+ end
65
+ if word.start_with?('/')
66
+ file
67
+ elsif word.start_with?('~')
68
+ file.sub(/\/home\/[^\/]+/, '~')
69
+ else
70
+ file.sub(Dir.pwd + '/', '')
71
+ end
72
+ end
73
+ end
74
+
75
+ def self.dir_complete(word)
76
+ file_complete(word, true)
77
+ end
78
+
79
+ def self.run
80
+ client = DropboxClient.new(get_access_token)
81
+ info = client.account_info
82
+ puts "Logged in as #{info['display_name']} (#{info['email']})"
83
+
84
+ state = State.new
85
+
86
+ Readline.completion_proc = proc do |word|
87
+ words = Readline.line_buffer.split
88
+ index = words.length
89
+ index += 1 if Readline.line_buffer.end_with?(' ')
90
+ if index <= 1
91
+ type = 'COMMAND'
92
+ elsif Commands::NAMES.include?(words[0])
93
+ cmd = Commands.const_get(words[0].upcase.to_sym)
94
+ type = cmd.type_of_arg(index - 2)
95
+ end
96
+
97
+ options = case type
98
+ when 'COMMAND'
99
+ Commands::NAMES.select { |name| name.start_with? word }.map do |name|
100
+ name + ' '
101
+ end
102
+ when 'LOCAL_FILE'
103
+ file_complete(word)
104
+ when 'LOCAL_DIR'
105
+ dir_complete(word)
106
+ when 'REMOTE_FILE'
107
+ begin
108
+ state.file_complete(client, word)
109
+ rescue DropboxError
110
+ []
111
+ end
112
+ when 'REMOTE_DIR'
113
+ begin
114
+ state.dir_complete(client, word)
115
+ rescue DropboxError
116
+ []
117
+ end
118
+ else
119
+ []
120
+ end
121
+
122
+ options.map { |option| option.gsub(' ', '\ ').sub(/\\ $/, ' ') }
123
+ end
124
+
125
+ begin
126
+ Readline.completion_append_character = nil
127
+ rescue NotImplementedError
128
+ end
129
+
130
+ begin
131
+ while line = Readline.readline(prompt(info, state), true)
132
+ Commands.exec(line.chomp, client, state)
133
+ end
134
+ puts
135
+ rescue Interrupt
136
+ puts
137
+ end
138
+
139
+ state.pwd = '/'
140
+ Settings.write
141
+ end
142
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: droxi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Brandon Mulcahy
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-06-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dropbox_sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: ftp-like command-line interface to Dropbox
28
+ email: brandon@jangler.info
29
+ executables:
30
+ - droxi
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - bin/droxi
35
+ - lib/droxi.rb
36
+ - lib/droxi/commands.rb
37
+ - lib/droxi/settings.rb
38
+ - lib/droxi/state.rb
39
+ homepage: https://github.com/jangler/droxi
40
+ licenses:
41
+ - MIT
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubyforge_project:
59
+ rubygems_version: 2.2.2
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: droxi
63
+ test_files: []