droxi 0.0.5 → 0.1.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 +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
|