droxi 0.0.5 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +9 -1
- data/Rakefile +18 -5
- data/bin/droxi +2 -3
- data/droxi.gemspec +2 -2
- data/lib/droxi.rb +45 -33
- data/lib/droxi/commands.rb +117 -97
- data/lib/droxi/complete.rb +36 -31
- data/lib/droxi/settings.rb +51 -52
- data/lib/droxi/state.rb +72 -69
- data/lib/droxi/text.rb +27 -37
- data/spec/all.rb +10 -0
- data/spec/commands_spec.rb +285 -114
- data/spec/complete_spec.rb +93 -28
- data/spec/settings_spec.rb +37 -4
- data/spec/state_spec.rb +51 -23
- data/spec/testutils.rb +65 -0
- data/spec/text_spec.rb +5 -5
- metadata +3 -2
data/lib/droxi/complete.rb
CHANGED
@@ -1,43 +1,43 @@
|
|
1
1
|
# Module containing tab-completion logic and methods.
|
2
2
|
module Complete
|
3
|
+
# Return an +Array+ of potential command name tab-completions for a +String+.
|
4
|
+
def self.command(string, names)
|
5
|
+
names.select { |n| n.start_with?(string) }.map { |n| n + ' ' }
|
6
|
+
end
|
3
7
|
|
4
8
|
# Return the directory in which to search for potential local tab-completions
|
5
9
|
# for a +String+. Defaults to working directory in case of bogus input.
|
6
10
|
def self.local_search_path(string)
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
Dir.pwd
|
11
|
-
end
|
11
|
+
File.expand_path(strip_filename(string))
|
12
|
+
rescue ArgumentError
|
13
|
+
Dir.pwd
|
12
14
|
end
|
13
15
|
|
14
16
|
# Return an +Array+ of potential local tab-completions for a +String+.
|
15
17
|
def self.local(string)
|
16
18
|
dir = local_search_path(string)
|
17
|
-
|
19
|
+
basename = basename(string)
|
18
20
|
|
19
|
-
Dir.entries(dir).select
|
20
|
-
|
21
|
-
|
22
|
-
entry << (File.directory?(dir + '/' + entry) ? '/' : ' ')
|
23
|
-
string + entry[name.length, entry.length]
|
21
|
+
matches = Dir.entries(dir).select { |entry| match?(basename, entry) }
|
22
|
+
matches.map do |entry|
|
23
|
+
final_match(string, entry, File.directory?(dir + '/' + entry))
|
24
24
|
end
|
25
25
|
end
|
26
26
|
|
27
27
|
# Return an +Array+ of potential local tab-completions for a +String+,
|
28
28
|
# including only directories.
|
29
29
|
def self.local_dir(string)
|
30
|
-
local(string).select { |
|
30
|
+
local(string).select { |match| match.end_with?('/') }
|
31
31
|
end
|
32
32
|
|
33
33
|
# Return the directory in which to search for potential remote
|
34
34
|
# tab-completions for a +String+.
|
35
35
|
def self.remote_search_path(string, state)
|
36
36
|
path = case
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
when string.empty? then state.pwd + '/'
|
38
|
+
when string.start_with?('/') then string
|
39
|
+
else state.pwd + '/' + string
|
40
|
+
end
|
41
41
|
|
42
42
|
strip_filename(collapse(path))
|
43
43
|
end
|
@@ -45,15 +45,12 @@ module Complete
|
|
45
45
|
# Return an +Array+ of potential remote tab-completions for a +String+.
|
46
46
|
def self.remote(string, state)
|
47
47
|
dir = remote_search_path(string, state)
|
48
|
-
|
48
|
+
basename = basename(string)
|
49
49
|
|
50
|
-
state.contents(dir).map
|
51
|
-
|
52
|
-
|
53
|
-
entry.
|
54
|
-
end.map do |entry|
|
55
|
-
entry << (state.directory?(dir + '/' + entry) ? '/' : ' ')
|
56
|
-
string + entry[name.length, entry.length]
|
50
|
+
entries = state.contents(dir).map { |entry| File.basename(entry) }
|
51
|
+
matches = entries.select { |entry| match?(basename, entry) }
|
52
|
+
matches.map do |entry|
|
53
|
+
final_match(string, entry, state.directory?(dir + '/' + entry))
|
57
54
|
end
|
58
55
|
end
|
59
56
|
|
@@ -65,22 +62,30 @@ module Complete
|
|
65
62
|
|
66
63
|
private
|
67
64
|
|
65
|
+
def self.basename(string)
|
66
|
+
string.end_with?('/') ? '' : File.basename(string)
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.match?(prefix, candidate)
|
70
|
+
candidate.start_with?(prefix) && !/^\.\.?$/.match(candidate)
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.final_match(string, candidate, is_dir)
|
74
|
+
string + candidate.partition(basename(string))[2] + (is_dir ? '/' : ' ')
|
75
|
+
end
|
76
|
+
|
68
77
|
# Return the name of the directory indicated by a path.
|
69
78
|
def self.strip_filename(path)
|
70
|
-
if path
|
71
|
-
|
72
|
-
else
|
73
|
-
path
|
74
|
-
end
|
79
|
+
return path if path == '/'
|
80
|
+
path.end_with?('/') ? path.sub(/\/$/, '') : File.dirname(path)
|
75
81
|
end
|
76
82
|
|
77
83
|
# Return a version of a path with .. and . resolved to appropriate
|
78
84
|
# directories.
|
79
85
|
def self.collapse(path)
|
80
86
|
new_path = path.dup
|
81
|
-
nil while new_path.sub!(
|
87
|
+
nil while new_path.sub!(%r{[^/]+/\.\./}, '/')
|
82
88
|
nil while new_path.sub!('./', '')
|
83
89
|
new_path
|
84
90
|
end
|
85
|
-
|
86
91
|
end
|
data/lib/droxi/settings.rb
CHANGED
@@ -1,85 +1,84 @@
|
|
1
1
|
# Manages persistent (session-independent) application state.
|
2
|
-
|
2
|
+
module Settings
|
3
|
+
class << self
|
4
|
+
# The path of the application's rc file.
|
5
|
+
attr_accessor :config_file_path
|
6
|
+
end
|
3
7
|
|
4
|
-
|
5
|
-
CONFIG_FILE_PATH = File.expand_path('~/.config/droxi/droxirc')
|
8
|
+
self.config_file_path = File.expand_path('~/.config/droxi/droxirc')
|
6
9
|
|
7
10
|
# Return the value of a setting, or +nil+ if the setting does not exist.
|
8
|
-
def
|
9
|
-
|
11
|
+
def self.[](key)
|
12
|
+
settings[key]
|
10
13
|
end
|
11
14
|
|
12
15
|
# Set the value of a setting.
|
13
|
-
def
|
14
|
-
if value
|
15
|
-
|
16
|
-
|
17
|
-
end
|
16
|
+
def self.[]=(key, value)
|
17
|
+
return value if value == settings[key]
|
18
|
+
self.dirty = true
|
19
|
+
settings[key] = value
|
18
20
|
end
|
19
21
|
|
20
22
|
# Return +true+ if the setting exists, +false+ otherwise.
|
21
|
-
def
|
22
|
-
|
23
|
+
def self.include?(key)
|
24
|
+
settings.include?(key)
|
23
25
|
end
|
24
26
|
|
25
27
|
# Delete the setting and return its value.
|
26
|
-
def
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
end
|
28
|
+
def self.delete(key)
|
29
|
+
return unless settings.include?(key)
|
30
|
+
self.dirty = true
|
31
|
+
settings.delete(key)
|
31
32
|
end
|
32
33
|
|
33
34
|
# Write settings to disk.
|
34
|
-
def
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
end
|
35
|
+
def self.save
|
36
|
+
return unless dirty
|
37
|
+
self.dirty = false
|
38
|
+
require 'fileutils'
|
39
|
+
FileUtils.mkdir_p(File.dirname(config_file_path))
|
40
|
+
File.open(config_file_path, 'w') do |file|
|
41
|
+
settings.each_pair { |k, v| file.write("#{k}=#{v}\n") }
|
42
|
+
file.chmod(0600)
|
43
43
|
end
|
44
44
|
nil
|
45
45
|
end
|
46
46
|
|
47
|
+
# Read and parse the rc file.
|
48
|
+
def self.read
|
49
|
+
self.dirty = false
|
50
|
+
return {} unless File.exist?(config_file_path)
|
51
|
+
File.open(config_file_path) do |file|
|
52
|
+
file.each_line.reduce({}) { |a, e| a.merge(parse(e.strip)) }
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
47
56
|
private
|
48
57
|
|
58
|
+
class << self
|
59
|
+
# +true+ if the settings have been modified since last write, +false+
|
60
|
+
# otherwise.
|
61
|
+
attr_accessor :dirty
|
62
|
+
|
63
|
+
# A +Hash+ of setting keys to values.
|
64
|
+
attr_accessor :settings
|
65
|
+
end
|
66
|
+
|
49
67
|
# Print a warning for an invalid setting and return an empty +Hash+ (the
|
50
68
|
# result of an invalid setting).
|
51
|
-
def
|
69
|
+
def self.warn_invalid(line)
|
52
70
|
warn "invalid setting: #{line}"
|
53
71
|
{}
|
54
72
|
end
|
55
73
|
|
56
74
|
# Parse a line of the rc file and return a +Hash+ containing the resulting
|
57
75
|
# setting data.
|
58
|
-
def
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
else
|
64
|
-
warn_invalid(line)
|
65
|
-
end
|
66
|
-
else
|
67
|
-
warn_invalid(line)
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
# Read and parse the rc file.
|
72
|
-
def Settings.read
|
73
|
-
if File.exists?(CONFIG_FILE_PATH)
|
74
|
-
File.open(CONFIG_FILE_PATH) do |file|
|
75
|
-
file.each_line.reduce({}) { |a, e| a.merge(parse(e.strip)) }
|
76
|
-
end
|
77
|
-
else
|
78
|
-
{}
|
79
|
-
end
|
76
|
+
def self.parse(line)
|
77
|
+
return warn_invalid(line) unless /^(.+?)=(.+)$/ =~ line
|
78
|
+
key, value = Regexp.last_match[1].to_sym, Regexp.last_match[2]
|
79
|
+
return warn_invalid(line) unless [:access_token, :oldpwd].include?(key)
|
80
|
+
{ key => value }
|
80
81
|
end
|
81
82
|
|
82
|
-
|
83
|
-
@@dirty = false
|
84
|
-
|
83
|
+
self.settings = read
|
85
84
|
end
|
data/lib/droxi/state.rb
CHANGED
@@ -4,9 +4,53 @@ require_relative 'settings'
|
|
4
4
|
class GlobError < ArgumentError
|
5
5
|
end
|
6
6
|
|
7
|
+
# Special +Hash+ of remote file paths to cached file metadata.
|
8
|
+
class Cache < Hash
|
9
|
+
# Add a metadata +Hash+ and its contents to the +Cache+ and return the
|
10
|
+
# +Cache+.
|
11
|
+
def add(metadata)
|
12
|
+
store(metadata['path'], metadata)
|
13
|
+
dirname = File.dirname(metadata['path'])
|
14
|
+
if dirname != metadata['path']
|
15
|
+
contents = fetch(dirname, {}).fetch('contents', nil)
|
16
|
+
contents << metadata if contents && !contents.include?(metadata)
|
17
|
+
end
|
18
|
+
return self unless metadata.include?('contents')
|
19
|
+
metadata['contents'].each { |content| add(content) }
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
# Remove a path from the +Cache+ and return the +Cache+.
|
24
|
+
def remove(path)
|
25
|
+
recursive_remove(path)
|
26
|
+
|
27
|
+
dir = File.dirname(path)
|
28
|
+
return self unless fetch(dir, {}).include?('contents')
|
29
|
+
fetch(dir)['contents'].delete_if { |item| item['path'] == path }
|
30
|
+
|
31
|
+
self
|
32
|
+
end
|
33
|
+
|
34
|
+
# Return +true+ if the path's information is cached, +false+ otherwise.
|
35
|
+
def full_info?(path, require_contents = true)
|
36
|
+
info = fetch(path, nil)
|
37
|
+
info && (!require_contents || !info['is_dir'] || info.include?('contents'))
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Recursively remove a path and its sub-files and directories.
|
43
|
+
def recursive_remove(path)
|
44
|
+
if fetch(path, {}).include?('contents')
|
45
|
+
fetch(path)['contents'].each { |item| recursive_remove(item['path']) }
|
46
|
+
end
|
47
|
+
|
48
|
+
delete(path)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
7
52
|
# Encapsulates the session state of the client.
|
8
53
|
class State
|
9
|
-
|
10
54
|
# +Hash+ of remote file paths to cached file metadata.
|
11
55
|
attr_reader :cache
|
12
56
|
|
@@ -18,14 +62,14 @@ class State
|
|
18
62
|
|
19
63
|
# The previous local working directory path.
|
20
64
|
attr_accessor :local_oldpwd
|
21
|
-
|
65
|
+
|
22
66
|
# +true+ if the client has requested to quit, +false+ otherwise.
|
23
67
|
attr_accessor :exit_requested
|
24
68
|
|
25
69
|
# Return a new application state that uses the given client. Starts at the
|
26
70
|
# Dropbox root and with an empty cache.
|
27
71
|
def initialize(client)
|
28
|
-
@cache =
|
72
|
+
@cache = Cache.new
|
29
73
|
@client = client
|
30
74
|
@exit_requested = false
|
31
75
|
@pwd = '/'
|
@@ -33,50 +77,15 @@ class State
|
|
33
77
|
@local_oldpwd = Dir.pwd
|
34
78
|
end
|
35
79
|
|
36
|
-
# Adds a metadata +Hash+ and its contents to the metadata cache.
|
37
|
-
def cache_add(metadata)
|
38
|
-
@cache[metadata['path']] = metadata
|
39
|
-
if metadata.include?('contents')
|
40
|
-
metadata['contents'].each { |content| cache_add(content) }
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
# Removes a path from the metadata cache.
|
45
|
-
def cache_remove(path)
|
46
|
-
if directory?(path) && @cache[path].include?('contents')
|
47
|
-
@cache[path]['contents'].each { |item| cache_remove(item['path']) }
|
48
|
-
end
|
49
|
-
|
50
|
-
@cache.delete(path)
|
51
|
-
|
52
|
-
dir = File.dirname(path)
|
53
|
-
if @cache.include?(dir) && @cache[dir].include?('contents')
|
54
|
-
@cache[dir]['contents'].delete_if { |item| item['path'] == path }
|
55
|
-
end
|
56
|
-
end
|
57
|
-
|
58
80
|
# Return a +Hash+ of the Dropbox metadata for a file, or +nil+ if the file
|
59
81
|
# does not exist.
|
60
|
-
def metadata(path, require_contents=true)
|
82
|
+
def metadata(path, require_contents = true)
|
61
83
|
tokens = path.split('/').drop(1)
|
62
84
|
|
63
|
-
|
85
|
+
(0..tokens.length).each do |i|
|
64
86
|
partial_path = '/' + tokens.take(i).join('/')
|
65
|
-
|
66
|
-
|
67
|
-
data = @client.metadata(partial_path)
|
68
|
-
if !data['is_deleted']
|
69
|
-
@cache[partial_path] = data
|
70
|
-
if data.include?('contents')
|
71
|
-
data['contents'].each do |datum|
|
72
|
-
@cache[datum['path']] = datum
|
73
|
-
end
|
74
|
-
end
|
75
|
-
end
|
76
|
-
rescue DropboxError
|
77
|
-
return nil
|
78
|
-
end
|
79
|
-
end
|
87
|
+
next if @cache.full_info?(partial_path, require_contents)
|
88
|
+
return nil unless fetch_metadata(partial_path)
|
80
89
|
end
|
81
90
|
|
82
91
|
@cache[path]
|
@@ -110,17 +119,16 @@ class State
|
|
110
119
|
def resolve_path(arg)
|
111
120
|
path = arg.start_with?('/') ? arg.dup : "#{@pwd}/#{arg}"
|
112
121
|
path.gsub!('//', '/')
|
113
|
-
nil while path.sub!(
|
122
|
+
nil while path.sub!(%r{/([^/]+?)/\.\.}, '')
|
114
123
|
nil while path.sub!('./', '')
|
115
124
|
path.sub!(/\/\.$/, '')
|
116
125
|
path.chomp!('/')
|
117
|
-
path
|
118
|
-
path
|
126
|
+
path.empty? ? '/' : path
|
119
127
|
end
|
120
128
|
|
121
129
|
# Expand an +Array+ of file globs into an an +Array+ of Dropbox file paths
|
122
130
|
# and return the result.
|
123
|
-
def expand_patterns(patterns, preserve_root=false)
|
131
|
+
def expand_patterns(patterns, preserve_root = false)
|
124
132
|
patterns.map do |pattern|
|
125
133
|
path = resolve_path(pattern)
|
126
134
|
if directory?(path)
|
@@ -135,40 +143,35 @@ class State
|
|
135
143
|
# (error) output if a block is given.
|
136
144
|
def forget_contents(partial_path)
|
137
145
|
path = resolve_path(partial_path)
|
138
|
-
if @cache.
|
146
|
+
if @cache.fetch(path, {}).include?('contents')
|
147
|
+
@cache[path]['contents'].dup.each { |m| @cache.remove(m['path']) }
|
139
148
|
@cache[path].delete('contents')
|
140
|
-
@cache.keys.each do |key|
|
141
|
-
@cache.delete(key) if key.start_with?(path) && key != path
|
142
|
-
end
|
143
149
|
elsif block_given?
|
144
|
-
yield "forget: #{partial_path}:
|
150
|
+
yield "forget: #{partial_path}: nothing to forget"
|
145
151
|
end
|
146
152
|
end
|
147
153
|
|
148
154
|
private
|
149
155
|
|
156
|
+
# Cache metadata for the remote file for a given path. Return +true+ if
|
157
|
+
# successful, +false+ otherwise.
|
158
|
+
def fetch_metadata(path)
|
159
|
+
data = @client.metadata(path)
|
160
|
+
return true if data['is_deleted']
|
161
|
+
@cache.add(data)
|
162
|
+
true
|
163
|
+
rescue DropboxError
|
164
|
+
false
|
165
|
+
end
|
166
|
+
|
150
167
|
# Return an +Array+ of file paths matching a glob pattern, or a GlobError if
|
151
168
|
# no files were matched.
|
152
169
|
def get_matches(pattern, path, preserve_root)
|
153
170
|
dir = File.dirname(path)
|
154
171
|
matches = contents(dir).select { |entry| File.fnmatch(path, entry) }
|
155
|
-
if matches.empty?
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
matches.map { |match| prefix + match.rpartition('/')[2] }
|
160
|
-
else
|
161
|
-
matches
|
162
|
-
end
|
172
|
+
return GlobError.new(pattern) if matches.empty?
|
173
|
+
return matches unless preserve_root
|
174
|
+
prefix = pattern.rpartition('/')[0, 2].join
|
175
|
+
matches.map { |match| prefix + match.rpartition('/')[2] }
|
163
176
|
end
|
164
|
-
|
165
|
-
# Return +true+ if the path's information is cached, +false+ otherwise.
|
166
|
-
def have_all_info_for(path, require_contents=true)
|
167
|
-
@cache.include?(path) && (
|
168
|
-
!require_contents ||
|
169
|
-
!@cache[path]['is_dir'] ||
|
170
|
-
@cache[path].include?('contents')
|
171
|
-
)
|
172
|
-
end
|
173
|
-
|
174
177
|
end
|