dbox 0.4.4 → 0.5.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.
data/README.md CHANGED
@@ -3,7 +3,7 @@ dbox
3
3
 
4
4
  An easy way to push and pull your Dropbox folders, with fine-grained control over what folder you are syncing, where you are syncing it to, and when you are doing it.
5
5
 
6
- **IMPORTANT:** This is **not** an automated Dropbox client. It will exit after sucessfully pushing/pulling, so if you want regular updates, you can run it in cron, a loop, etc.
6
+ **IMPORTANT:** This is **not** an automated Dropbox client. It will exit after sucessfully pushing/pulling, so if you want regular updates, you can run it in cron, a loop, etc. If you do want to run it in a loop, take a look at [sample_polling_script.rb](http://github.com/kenpratt/dbox/blob/master/sample_polling_script.rb).
7
7
 
8
8
 
9
9
  Installation
data/Rakefile CHANGED
@@ -17,6 +17,8 @@ Jeweler::Tasks.new do |gem|
17
17
  gem.add_dependency "multipart-post", ">= 1.1.2"
18
18
  gem.add_dependency "oauth", ">= 0.4.5"
19
19
  gem.add_dependency "json", ">= 1.5.3"
20
+ gem.add_dependency "sqlite3", ">= 1.3.3"
21
+ gem.add_dependency "activesupport", ">= 3.0.1"
20
22
  end
21
23
  Jeweler::RubygemsDotOrgTasks.new
22
24
 
data/TODO.txt CHANGED
@@ -1,3 +1,6 @@
1
- * Look down directory tree until you hit a .dropbox.db file
1
+ * Refactor threading in change detection in pull to use ParallelTasks?
2
+ * Saving SQLite db changes dir timestamp -- try to preserve it?
2
3
  * Add a "sync" command that pushes and pulls in one go
3
- * Add support for partial push/pull
4
+ * See if prepared statements speed up operations on large repos much
5
+ * Look down directory tree until you hit a .dbox.sqlite3 file so you can use commands from anywhere inside a tree (like git)
6
+ * Add support for partial push/pull (subtree push/pull)?
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.4
1
+ 0.5.0
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{dbox}
8
- s.version = "0.4.4"
8
+ s.version = "0.5.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = [%q{Ken Pratt}]
12
- s.date = %q{2011-06-28}
12
+ s.date = %q{2011-09-19}
13
13
  s.description = %q{An easy-to-use Dropbox client with fine-grained control over syncs.}
14
14
  s.email = %q{ken@kenpratt.net}
15
15
  s.executables = [%q{dbox}]
@@ -28,8 +28,12 @@ Gem::Specification.new do |s|
28
28
  "dbox.gemspec",
29
29
  "lib/dbox.rb",
30
30
  "lib/dbox/api.rb",
31
+ "lib/dbox/database.rb",
31
32
  "lib/dbox/db.rb",
32
33
  "lib/dbox/loggable.rb",
34
+ "lib/dbox/parallel_tasks.rb",
35
+ "lib/dbox/syncer.rb",
36
+ "sample_polling_script.rb",
33
37
  "spec/dbox_spec.rb",
34
38
  "spec/spec_helper.rb",
35
39
  "vendor/dropbox-client-ruby/LICENSE",
@@ -45,7 +49,7 @@ Gem::Specification.new do |s|
45
49
  s.homepage = %q{http://github.com/kenpratt/dbox}
46
50
  s.licenses = [%q{MIT}]
47
51
  s.require_paths = [%q{lib}]
48
- s.rubygems_version = %q{1.8.1}
52
+ s.rubygems_version = %q{1.8.5}
49
53
  s.summary = %q{Dropbox made easy.}
50
54
 
51
55
  if s.respond_to? :specification_version then
@@ -55,15 +59,21 @@ Gem::Specification.new do |s|
55
59
  s.add_runtime_dependency(%q<multipart-post>, [">= 1.1.2"])
56
60
  s.add_runtime_dependency(%q<oauth>, [">= 0.4.5"])
57
61
  s.add_runtime_dependency(%q<json>, [">= 1.5.3"])
62
+ s.add_runtime_dependency(%q<sqlite3>, [">= 1.3.3"])
63
+ s.add_runtime_dependency(%q<activesupport>, [">= 3.0.1"])
58
64
  else
59
65
  s.add_dependency(%q<multipart-post>, [">= 1.1.2"])
60
66
  s.add_dependency(%q<oauth>, [">= 0.4.5"])
61
67
  s.add_dependency(%q<json>, [">= 1.5.3"])
68
+ s.add_dependency(%q<sqlite3>, [">= 1.3.3"])
69
+ s.add_dependency(%q<activesupport>, [">= 3.0.1"])
62
70
  end
63
71
  else
64
72
  s.add_dependency(%q<multipart-post>, [">= 1.1.2"])
65
73
  s.add_dependency(%q<oauth>, [">= 0.4.5"])
66
74
  s.add_dependency(%q<json>, [">= 1.5.3"])
75
+ s.add_dependency(%q<sqlite3>, [">= 1.3.3"])
76
+ s.add_dependency(%q<activesupport>, [">= 3.0.1"])
67
77
  end
68
78
  end
69
79
 
@@ -8,10 +8,15 @@ require "time"
8
8
  require "yaml"
9
9
  require "logger"
10
10
  require "cgi"
11
+ require "sqlite3"
12
+ require "active_support/core_ext/hash/indifferent_access"
11
13
 
12
14
  require "dbox/loggable"
13
15
  require "dbox/api"
16
+ require "dbox/database"
14
17
  require "dbox/db"
18
+ require "dbox/parallel_tasks"
19
+ require "dbox/syncer"
15
20
 
16
21
  module Dbox
17
22
  def self.authorize
@@ -19,41 +24,47 @@ module Dbox
19
24
  end
20
25
 
21
26
  def self.create(remote_path, local_path)
27
+ log.debug "Creating (remote: #{remote_path}, local: #{local_path})"
22
28
  remote_path = clean_remote_path(remote_path)
23
29
  local_path = clean_local_path(local_path)
24
- Dbox::DB.create(remote_path, local_path)
30
+ migrate_dbfile(local_path)
31
+ Dbox::Syncer.create(remote_path, local_path)
25
32
  end
26
33
 
27
34
  def self.clone(remote_path, local_path)
35
+ log.debug "Cloning (remote: #{remote_path}, local: #{local_path})"
28
36
  remote_path = clean_remote_path(remote_path)
29
37
  local_path = clean_local_path(local_path)
30
- Dbox::DB.clone(remote_path, local_path)
38
+ migrate_dbfile(local_path)
39
+ Dbox::Syncer.clone(remote_path, local_path)
31
40
  end
32
41
 
33
42
  def self.pull(local_path)
43
+ log.debug "Pulling (local: #{local_path})"
34
44
  local_path = clean_local_path(local_path)
35
- Dbox::DB.pull(local_path)
45
+ migrate_dbfile(local_path)
46
+ Dbox::Syncer.pull(local_path)
36
47
  end
37
48
 
38
49
  def self.push(local_path)
50
+ log.debug "Pushing (local: #{local_path})"
39
51
  local_path = clean_local_path(local_path)
40
- Dbox::DB.push(local_path)
52
+ migrate_dbfile(local_path)
53
+ Dbox::Syncer.push(local_path)
41
54
  end
42
55
 
43
56
  def self.move(new_remote_path, local_path)
57
+ log.debug "Moving (new remote: #{new_remote_path}, local: #{local_path})"
44
58
  new_remote_path = clean_remote_path(new_remote_path)
45
59
  local_path = clean_local_path(local_path)
46
- Dbox::DB.move(new_remote_path, local_path)
60
+ migrate_dbfile(local_path)
61
+ Dbox::Syncer.move(new_remote_path, local_path)
47
62
  end
48
63
 
49
64
  def self.exists?(local_path)
50
65
  local_path = clean_local_path(local_path)
51
- Dbox::DB.exists?(local_path)
52
- end
53
-
54
- def self.corrupt?(local_path)
55
- local_path = clean_local_path(local_path)
56
- Dbox::DB.corrupt?(local_path)
66
+ migrate_dbfile(local_path)
67
+ Dbox::Database.exists?(local_path)
57
68
  end
58
69
 
59
70
  private
@@ -68,4 +79,13 @@ module Dbox
68
79
  raise(ArgumentError, "Missing local path") unless path
69
80
  File.expand_path(path)
70
81
  end
82
+
83
+ def self.migrate_dbfile(path)
84
+ if Dbox::DB.exists?(path)
85
+ log.warn "Old database file format found -- migrating to new database format"
86
+ Dbox::Database.migrate_from_old_db_format(Dbox::DB.load(path))
87
+ Dbox::DB.destroy!(path)
88
+ log.warn "Migration complete"
89
+ end
90
+ end
71
91
  end
@@ -30,12 +30,18 @@ module Dbox
30
30
  api
31
31
  end
32
32
 
33
+ attr_reader :client
34
+
33
35
  # IMPORTANT: API.new is private. Please use API.authorize or API.connect as the entry point.
34
36
  private_class_method :new
35
37
  def initialize
36
38
  @conf = self.class.conf
37
39
  end
38
40
 
41
+ def initialize_copy(other)
42
+ @client = other.client.clone()
43
+ end
44
+
39
45
  def connect
40
46
  auth_key = ENV["DROPBOX_AUTH_KEY"]
41
47
  auth_secret = ENV["DROPBOX_AUTH_SECRET"]
@@ -52,13 +58,17 @@ module Dbox
52
58
  res = yield
53
59
  case res
54
60
  when Hash
55
- res
61
+ HashWithIndifferentAccess.new(res)
56
62
  when String
57
63
  res
58
64
  when Net::HTTPNotFound
59
65
  raise RemoteMissing, "#{path} does not exist on Dropbox"
60
66
  when Net::HTTPForbidden
61
67
  raise RequestDenied, "Operation on #{path} denied"
68
+ when Net::HTTPNotModified
69
+ :not_modified
70
+ when true
71
+ true
62
72
  else
63
73
  raise RuntimeError, "Unexpected result: #{res.inspect}"
64
74
  end
@@ -68,10 +78,10 @@ module Dbox
68
78
  end
69
79
  end
70
80
 
71
- def metadata(path = "/")
81
+ def metadata(path = "/", hash = nil)
72
82
  log.debug "Fetching metadata for #{path}"
73
83
  run(path) do
74
- res = @client.metadata(@conf["root"], escape_path(path))
84
+ res = @client.metadata(@conf["root"], escape_path(path), 10000, hash)
75
85
  log.debug res.inspect
76
86
  res
77
87
  end
@@ -96,10 +106,10 @@ module Dbox
96
106
  end
97
107
  end
98
108
 
99
- def get_file(path)
109
+ def get_file(path, output_file_obj)
100
110
  log.info "Downloading #{path}"
101
111
  run(path) do
102
- @client.get_file(@conf["root"], escape_path(path))
112
+ @client.get_file(@conf["root"], escape_path(path), output_file_obj)
103
113
  end
104
114
  end
105
115
 
@@ -0,0 +1,216 @@
1
+ module Dbox
2
+ class DatabaseError < RuntimeError; end
3
+
4
+ class Database
5
+ include Loggable
6
+
7
+ DB_FILENAME = ".dbox.sqlite3"
8
+
9
+ def self.create(remote_path, local_path)
10
+ db = new(local_path)
11
+ if db.bootstrapped?
12
+ raise DatabaseError, "Database already initialized -- please use 'dbox pull' or 'dbox push'."
13
+ end
14
+ db.bootstrap(remote_path, local_path)
15
+ db
16
+ end
17
+
18
+ def self.load(local_path)
19
+ db = new(local_path)
20
+ unless db.bootstrapped?
21
+ raise DatabaseError, "Database not initialized -- please run 'dbox create' or 'dbox clone'."
22
+ end
23
+ db
24
+ end
25
+
26
+ def self.exists?(local_path)
27
+ File.exists?(File.join(local_path, DB_FILENAME))
28
+ end
29
+
30
+ def self.migrate_from_old_db_format(old_db)
31
+ new_db = create(old_db.remote_path, old_db.local_path)
32
+ new_db.delete_entry_by_path("") # clear out root record
33
+ new_db.migrate_entry_from_old_db_format(old_db.root)
34
+ end
35
+
36
+ # IMPORTANT: Database.new is private. Please use Database.create
37
+ # or Database.load as the entry point.
38
+ private_class_method :new
39
+ def initialize(local_path)
40
+ FileUtils.mkdir_p(local_path)
41
+ @db = SQLite3::Database.new(File.join(local_path, DB_FILENAME))
42
+ @db.trace {|sql| log.debug sql.strip }
43
+ @db.execute("PRAGMA foreign_keys = ON;")
44
+ ensure_schema_exists
45
+ end
46
+
47
+ def ensure_schema_exists
48
+ @db.execute_batch(%{
49
+ CREATE TABLE IF NOT EXISTS metadata (
50
+ id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
51
+ local_path varchar(255) NOT NULL,
52
+ remote_path varchar(255) NOT NULL,
53
+ version integer NOT NULL
54
+ );
55
+ CREATE TABLE IF NOT EXISTS entries (
56
+ id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
57
+ path varchar(255) UNIQUE NOT NULL,
58
+ is_dir boolean NOT NULL,
59
+ parent_id integer REFERENCES entries(id) ON DELETE CASCADE,
60
+ hash varchar(255),
61
+ modified datetime,
62
+ revision integer
63
+ );
64
+ CREATE INDEX IF NOT EXISTS entry_parent_ids ON entries(parent_id);
65
+ })
66
+ end
67
+
68
+ METADATA_COLS = [ :local_path, :remote_path, :version ] # don't need to return id
69
+ ENTRY_COLS = [ :id, :path, :is_dir, :parent_id, :hash, :modified, :revision ]
70
+
71
+ def bootstrap(remote_path, local_path)
72
+ @db.execute(%{
73
+ INSERT INTO metadata (local_path, remote_path, version) VALUES (?, ?, ?);
74
+ }, local_path, remote_path, 1)
75
+ @db.execute(%{
76
+ INSERT INTO entries (path, is_dir) VALUES (?, ?)
77
+ }, "", 1)
78
+ end
79
+
80
+ def bootstrapped?
81
+ n = @db.get_first_value(%{
82
+ SELECT count(id) FROM metadata LIMIT 1;
83
+ })
84
+ n && n > 0
85
+ end
86
+
87
+ def metadata
88
+ cols = METADATA_COLS
89
+ res = @db.get_first_row(%{
90
+ SELECT #{cols.join(',')} FROM metadata LIMIT 1;
91
+ })
92
+ make_fields(cols, res) if res
93
+ end
94
+
95
+ def update_metadata(fields)
96
+ set_str = fields.keys.map {|k| "#{k}=?" }.join(",")
97
+ @db.execute(%{
98
+ UPDATE metadata SET #{set_str};
99
+ }, *fields.values)
100
+ end
101
+
102
+ def root_dir
103
+ find_entry("WHERE parent_id is NULL")
104
+ end
105
+
106
+ def find_by_path(path)
107
+ raise(ArgumentError, "path cannot be null") unless path
108
+ find_entry("WHERE path=?", path)
109
+ end
110
+
111
+ def contents(dir_id)
112
+ raise(ArgumentError, "dir_id cannot be null") unless dir_id
113
+ find_entries("WHERE parent_id=?", dir_id)
114
+ end
115
+
116
+ def subdirs(dir_id)
117
+ raise(ArgumentError, "dir_id cannot be null") unless dir_id
118
+ find_entries("WHERE parent_id=? AND is_dir=1", dir_id)
119
+ end
120
+
121
+ def add_entry(path, is_dir, parent_id, modified, revision, hash)
122
+ insert_entry(:path => path, :is_dir => is_dir, :parent_id => parent_id, :modified => modified, :revision => revision, :hash => hash)
123
+ end
124
+
125
+ def update_entry_by_path(path, fields)
126
+ raise(ArgumentError, "path cannot be null") unless path
127
+ update_entry(["WHERE path=?", path], fields)
128
+ end
129
+
130
+ def delete_entry_by_path(path)
131
+ raise(ArgumentError, "path cannot be null") unless path
132
+ delete_entry("WHERE path=?", path)
133
+ end
134
+
135
+ def migrate_entry_from_old_db_format(entry, parent = nil)
136
+ # insert entry into sqlite db
137
+ add_entry(entry.path, entry.dir?, (parent ? parent[:id] : nil), entry.modified_at, entry.revision, nil)
138
+
139
+ # recur on children
140
+ if entry.dir?
141
+ new_parent = find_by_path(entry.path)
142
+ entry.contents.each {|child_path, child| migrate_entry_from_old_db_format(child, new_parent) }
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def find_entry(conditions = "", *args)
149
+ res = @db.get_first_row(%{
150
+ SELECT #{ENTRY_COLS.join(",")} FROM entries #{conditions} LIMIT 1;
151
+ }, *args)
152
+ entry_res_to_fields(res)
153
+ end
154
+
155
+ def find_entries(conditions = "", *args)
156
+ out = []
157
+ @db.execute(%{
158
+ SELECT #{ENTRY_COLS.join(",")} FROM entries #{conditions} ORDER BY path ASC;
159
+ }, *args) do |res|
160
+ out << entry_res_to_fields(res)
161
+ end
162
+ out
163
+ end
164
+
165
+ def insert_entry(fields)
166
+ log.debug "Inserting entry: #{fields.inspect}"
167
+ h = fields.clone
168
+ h[:modified] = h[:modified].to_i if h[:modified]
169
+ h[:is_dir] = (h[:is_dir] ? 1 : 0) unless h[:is_dir].nil?
170
+ @db.execute(%{
171
+ INSERT INTO entries (#{h.keys.join(",")})
172
+ VALUES (#{(["?"] * h.size).join(",")});
173
+ }, *h.values)
174
+ end
175
+
176
+ def update_entry(where_clause, fields)
177
+ log.debug "Updating entry: #{where_clause}, #{fields.inspect}"
178
+ h = fields.clone
179
+ h[:modified] = h[:modified].to_i if h[:modified]
180
+ conditions, *args = *where_clause
181
+ set_str = h.keys.map {|k| "#{k}=?" }.join(",")
182
+ @db.execute(%{
183
+ UPDATE entries SET #{set_str} #{conditions};
184
+ }, *(h.values + args))
185
+ end
186
+
187
+ def delete_entry(conditions = "", *args)
188
+ @db.execute(%{
189
+ DELETE FROM entries #{conditions};
190
+ }, *args)
191
+ end
192
+
193
+ def entry_res_to_fields(res)
194
+ if res
195
+ h = make_fields(ENTRY_COLS, res)
196
+ h[:is_dir] = (h[:is_dir] == 1)
197
+ h[:modified] = Time.at(h[:modified]) if h[:modified]
198
+ h.delete(:hash) unless h[:is_dir]
199
+ h
200
+ else
201
+ nil
202
+ end
203
+ end
204
+
205
+ def make_fields(keys, vals)
206
+ if keys && vals
207
+ raise ArgumentError.new("Can't make a fields hash with #{keys.size} keys and #{vals.size} vals") unless keys.size == vals.size
208
+ out = {}
209
+ keys.each_with_index {|k, i| out[k] = vals[i] }
210
+ out
211
+ else
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end