bitsa 0.10

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.
@@ -0,0 +1,80 @@
1
+ # Loads Contacts from Gmail into a <tt>ContactsCache</tt> object.
2
+ #
3
+ # Copyright (C) 2011 Colin Noel Bell.
4
+ #
5
+ # This file is part of Bitsa.
6
+ #
7
+ # Bitsa is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # This program is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ require "gdata"
21
+
22
+ module Bitsa #:nodoc:
23
+
24
+ # Loads Contacts from Gmail into a <tt>ContactsCache</tt> object.
25
+ class GmailContactsLoader
26
+
27
+ # Number of contacts to retrieve as a single chunk.
28
+ @@FETCH_SIZE = 25
29
+
30
+ # Ctor specifying the Gmail (or Google Apps) user name and password.
31
+ def initialize(user, pw) #, lifespan_days)
32
+ @user = user
33
+ @pw = pw
34
+ end
35
+
36
+ # Refresh the passsed <tt>ContactsCache</tt> with the latest contact
37
+ # changes/deletions from Gmail.
38
+ def update_cache(cache)
39
+ client = GData::Client::Contacts.new
40
+ client.clientlogin(@user, @pw)
41
+
42
+ idx = 1
43
+ until load_chunk(client, idx, cache) < @@FETCH_SIZE
44
+ idx += @@FETCH_SIZE
45
+ end
46
+ cache.source_last_modified = DateTime.now.to_s
47
+ cache.save
48
+ end
49
+
50
+ private
51
+
52
+ def load_chunk(client, idx, cache)
53
+ last_modified = nil
54
+ url = "http://www.google.com/m8/feeds/contacts/#{@user}/thin"
55
+ url += "?orderby=lastmodified"
56
+ url += "&showdeleted=true"
57
+ url += "&max-results=#{@@FETCH_SIZE}"
58
+ url += "&start-index=#{idx}"
59
+ url += "&updated-min=#{CGI.escape(cache.source_last_modified)}" if cache.source_last_modified
60
+ feed = client.get(url).to_xml
61
+ feed.elements.each('entry') do |entry|
62
+ name = entry.elements['title'].text
63
+ name ||= ""
64
+ gmail_id = entry.elements['id'].text
65
+ deleted = entry.elements['gd:deleted'] ? true : false
66
+ if deleted
67
+ cache.delete(gmail_id)
68
+ else
69
+ addresses = []
70
+ entry.each_element('gd:email') do | addr |
71
+ addresses << addr.attributes['address']
72
+ end
73
+ cache.update(gmail_id, name, addresses)
74
+ end
75
+ last_modified = entry.elements['updated'].text
76
+ end
77
+ feed.elements.count
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # Application settings.
2
+ #
3
+ # Copyright (C) 2011 Colin Noel Bell.
4
+ #
5
+ # This file is part of Bitsa.
6
+ #
7
+ # Bitsa is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # This program is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ module Bitsa #:nodoc:
21
+
22
+ # Application settings.
23
+ class Settings
24
+
25
+ # Login to use to connect to GMail.
26
+ attr_reader :login
27
+
28
+ # Password to use to connect to GMail.
29
+ attr_reader :password
30
+
31
+ # Path to file to store cached contact information in.
32
+ attr_reader :cache_file_path
33
+
34
+ # Load settings from a hash of data from the configuration file and
35
+ # options passed on the command line.
36
+ #
37
+ # Options passed on the command line override those in the
38
+ # configuration file.
39
+ def load(config_file_hash, options)
40
+ @login = config_file_hash.data[:login]
41
+ @password = config_file_hash.data[:password]
42
+ @cache_file_path = config_file_hash.data[:cache_file_path]
43
+
44
+ @login = options[:login] if options[:login]
45
+ @password = options[:password] if options[:password]
46
+ @cache_file_path = options[:cache_file_path] if options[:cache_file_path]
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ # Application Version information.
2
+ #
3
+ # Copyright (C) 2011 Colin Noel Bell.
4
+ #
5
+ # This file is part of Bitsa.
6
+ #
7
+ # Bitsa is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # This program is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ module Bitsa #:nodoc:
21
+ VERSION = "0.10"
22
+ end
@@ -0,0 +1,66 @@
1
+ require "helper"
2
+ require "bitsa/args_processor"
3
+
4
+ describe Bitsa::ArgsProcessor do
5
+ context "when created" do
6
+ before(:all) { @ap = Bitsa::ArgsProcessor.new }
7
+
8
+ it "should recognise the 'update' command" do
9
+ @ap.parse(["update"])
10
+ end
11
+
12
+ it "should throw raise SystemExit if an invalid command passed" do
13
+ lambda {@ap.parse(['unknown'])}.should raise_error(SystemExit)
14
+ end
15
+
16
+ it "should throw raise SystemExit if nothing passed" do
17
+ lambda {@ap.parse([])}.should raise_error(SystemExit)
18
+ end
19
+
20
+ context "and being passed valid commands" do
21
+ [["reload"], ["search", "data"], ["update"]].each do |ar|
22
+ cmd = ar[0]
23
+ it "should recognise the #{cmd} command" do
24
+ @ap.parse(ar)
25
+ @ap.cmd.should == cmd
26
+ end
27
+ end
28
+ end
29
+
30
+ it "should recognise valid long arguments" do
31
+ args = []
32
+ args << "--config-file"
33
+ args << "somefile"
34
+ args << "--login"
35
+ args << "someone"
36
+ args << "--password"
37
+ args << "mypassword"
38
+ args << "update"
39
+
40
+ @ap.parse(args)
41
+
42
+ @ap.global_opts[:config_file].should == "somefile"
43
+ @ap.global_opts[:login].should == "someone"
44
+ @ap.global_opts[:password].should == "mypassword"
45
+ end
46
+
47
+ it "should recognise valid short arguments" do
48
+ args = []
49
+ args << "-c"
50
+ args << "somefile"
51
+ args << "-l"
52
+ args << "someone"
53
+ args << "-p"
54
+ args << "mypassword"
55
+ args << "update"
56
+
57
+ @ap.parse(args)
58
+
59
+ @ap.global_opts[:config_file].should == "somefile"
60
+ @ap.global_opts[:login].should == "someone"
61
+ @ap.global_opts[:password].should == "mypassword"
62
+ end
63
+
64
+ end
65
+
66
+ end
@@ -0,0 +1,32 @@
1
+ require "helper"
2
+ require "bitsa/config_file"
3
+
4
+ describe Bitsa::ConfigFile do
5
+ context "An existing configuration file" do
6
+ before(:all) { @config = Bitsa::ConfigFile.new("spec/data/config.yml") }
7
+ it "should read values from config file" do
8
+ @config.data[:login].should == "test@gmail.com"
9
+ @config.data[:password].should == "myPassword"
10
+ end
11
+ end
12
+
13
+ context "An non-existent configuration file" do
14
+ before(:all) { @config = Bitsa::ConfigFile.new("/tmp/i-dont-exist") }
15
+ it "should have no values" do
16
+ @config.data.should == {}
17
+ end
18
+ end
19
+
20
+ context "An existing configuration file that I have no read rights to" do
21
+ before(:all) do
22
+ @tmp_file = Tempfile.open("cache")
23
+ FileUtils.cp("spec/data/config.yml", @tmp_file.path)
24
+ FileUtils.chmod(0222, @tmp_file.path)
25
+ end
26
+
27
+ it "should throw an exception when I try to read from it" do
28
+ lambda { Bitsa::ConfigFile.new(@tmp_file.path) }.should raise_error(Errno::EACCES)
29
+ end
30
+ end
31
+
32
+ end
@@ -0,0 +1,294 @@
1
+ require "fileutils"
2
+ require "tempfile"
3
+
4
+ require "helper"
5
+
6
+ require "bitsa/contacts_cache"
7
+
8
+ describe Bitsa::ContactsCache do
9
+ context "cache" do
10
+ it "should build successfully if cache file empty" do
11
+ tmp_file = create_empty_temp_file
12
+ cache = Bitsa::ContactsCache.new(tmp_file.path, 1)
13
+ should_be_an_empty_cache cache
14
+ end
15
+
16
+ it "should build successfully if cache file does not exist" do
17
+ cache = Bitsa::ContactsCache.new("/tmp/idonotexististhatoky", 1)
18
+ should_be_an_empty_cache cache
19
+ end
20
+
21
+ it "should fail with exception if user not authorised to read cache file" do
22
+ tmp_file = create_empty_temp_file
23
+ FileUtils.chmod(0222, tmp_file.path)
24
+ lambda { Bitsa::ContactsCache.new(tmp_file.path, 1).should raise_error(Errno::EACCES) }
25
+ end
26
+
27
+ it "should not be stale if last updated today" do
28
+ create_cache(DateTime.now, 1)
29
+ @cache.stale?.should_not be_true
30
+ end
31
+
32
+ it "should be stale if last updated yesterday and lifespan is 1 day" do
33
+ create_cache(DateTime.now-1, 1)
34
+ @cache.stale?.should be_true
35
+ end
36
+
37
+ it "should not be stale if last updated yesterday and lifespan is 2 days" do
38
+ create_cache(DateTime.now-1, 2)
39
+ @cache.stale?.should_not be_true
40
+ end
41
+ end
42
+
43
+ context "clearing the cache" do
44
+ before(:each) do
45
+ create_cache
46
+ @cache.should have_at_least(1).entries
47
+ @cache.empty?.should be_false
48
+ end
49
+
50
+ it "should leave the cache empty" do
51
+ @cache.clear!
52
+ @cache.size.should == 0
53
+ @cache.empty?.should be_true
54
+ end
55
+ end
56
+
57
+ context "searching the test data" do
58
+ # 4 contacts with 5 email addresses
59
+ before(:all) { create_cache }
60
+
61
+ it "should have read the correct number of contacts" do
62
+ @cache.size.should == 4
63
+ end
64
+
65
+ it "should return all entries if blank searched for" do
66
+ results = @cache.search('')
67
+ results.size.should == 5
68
+ results.flatten(0).sort.should == read_test_data
69
+ end
70
+
71
+ it "should return all entries if a nill string searched for" do
72
+ results = @cache.search(nil)
73
+ results.size.should == 5
74
+ results.flatten(0).sort.should == read_test_data
75
+ end
76
+
77
+ it "should find correctly when searching by start of email address" do
78
+ results = @cache.search('test1')
79
+ results.should =~ [["test1@example.com", "My Tester"]]
80
+
81
+ results = @cache.search('jo')
82
+ expected = [["john_smith@here.org", ""], ["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
83
+ results.should =~ expected
84
+ end
85
+
86
+ it "should find correctly when searching by end of email address" do
87
+ results = @cache.search('org')
88
+ expected = [["john_smith@here.org", ""]]
89
+ results.should =~ expected
90
+ end
91
+
92
+ it "should find correctly when searching by middle of email address" do
93
+ results = @cache.search('n_s')
94
+ expected = [["john_smith@here.org", ""]]
95
+ results.should =~ expected
96
+ end
97
+
98
+ it "should find correctly when searching by email address irrespective of case" do
99
+ results = @cache.search('N_sMI')
100
+ expected = [["john_smith@here.org", ""]]
101
+ results.should =~ expected
102
+ end
103
+
104
+ it "should find correctly when searching by start of name" do
105
+ results = @cache.search('Joan Blo')
106
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
107
+ results.should =~ expected
108
+ end
109
+
110
+ it "should find correctly when searching by end of name" do
111
+ results = @cache.search('ggshere')
112
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
113
+ results.should =~ expected
114
+ end
115
+
116
+ it "should find correctly when searching by middle of name" do
117
+ results = @cache.search('n Bl')
118
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
119
+ results.should =~ expected
120
+ end
121
+
122
+ it "should find correctly when searching by name irrespective of case" do
123
+ results = @cache.search('N BL')
124
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
125
+ results.should =~ expected
126
+ end
127
+
128
+ it "should find no results if nothing to find" do
129
+ results = @cache.search('nothing is here')
130
+ results.size.should == 0
131
+ end
132
+
133
+ it "should return all email addresses on a contact with multiple email addresses when matching on name" do
134
+ results = @cache.search('multip')
135
+ expected = [["email2@somewhere.com", "Mr Multiple"], ["email1@somewhere.com", "Mr Multiple"]]
136
+ results.should =~ expected
137
+ end
138
+
139
+ it "should return only the matched email address on a contact with multiple email addresses" do
140
+ results = @cache.search('email1')
141
+ expected = [["email1@somewhere.com", "Mr Multiple"]]
142
+ results.should =~ expected
143
+ end
144
+
145
+ it "should find by ID correctly" do
146
+ id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/685e301a549c176e"
147
+ results = @cache.get(id)
148
+ expected = [["email1@somewhere.com", "Mr Multiple"],
149
+ ["email2@somewhere.com", "Mr Multiple"]]
150
+ results.should =~ expected
151
+ end
152
+
153
+ it "should return nil if finding by a non-existent ID" do
154
+ id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/4783783783"
155
+ @cache.get(id).should be_nil
156
+ end
157
+ end
158
+
159
+ context "updating the test data" do
160
+ # 4 contacts with 5 email addresses
161
+ before(:each) { create_cache }
162
+
163
+ it "should handle update with a single email address correctly" do
164
+ id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/637e301a549c176e"
165
+ results = @cache.get(id)
166
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
167
+ results.should =~ expected
168
+
169
+ @cache.update(id, "Tammy Smith", ["tammy5@example.com"])
170
+ results = @cache.get(id)
171
+ expected = [["tammy5@example.com", "Tammy Smith"]]
172
+ results.should =~ expected
173
+ end
174
+
175
+ it "should handle update with two email addresses correctly" do
176
+ id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/637e301a549c176e"
177
+ results = @cache.get(id)
178
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
179
+ results.should =~ expected
180
+
181
+ @cache.update(id, "Tammy Smith", ["tammy5@example.com", "smithtammy@exampel.org"])
182
+ results = @cache.get(id)
183
+ expected = [["smithtammy@exampel.org", "Tammy Smith"], ["tammy5@example.com", "Tammy Smith"]]
184
+ results.should =~ expected
185
+ end
186
+ end
187
+
188
+ context "deleting a non-existent entry" do
189
+ before(:each) { create_cache }
190
+
191
+ it "should no longer contain the deleted entry" do
192
+ @cache.delete("NONEXISTENT")
193
+ @cache.get("NONEXISTENT").should be_nil
194
+ end
195
+
196
+ it "should not change the number of entries in the cache" do
197
+ lambda {
198
+ @cache.delete("NONEXISTENT")
199
+ }.should_not change(@cache, :size)
200
+ end
201
+
202
+ it "should return nil" do
203
+ @cache.delete("NONEXISTENT").should be_nil
204
+ end
205
+ end
206
+
207
+ context "deleting an existing entry" do
208
+ before(:each) do
209
+ create_cache
210
+ @id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/637e301a549c176e"
211
+ end
212
+
213
+ it "should_change the number of entries by -1" do
214
+ lambda {
215
+ @cache.delete(@id)
216
+ }.should change(@cache, :size).by(-1)
217
+ end
218
+
219
+ it "should return the deleted entry" do
220
+ results = @cache.delete(@id)
221
+ expected = [["Joan.bloggs@somewhere.com.au", "Joan Bloggshere"]]
222
+ results.should =~ expected
223
+ end
224
+
225
+ it "should no longer contain the deleted entry" do
226
+ @cache.delete(@id)
227
+ @cache.get(@id).should be_nil
228
+ end
229
+ end
230
+
231
+ context "saving the test data" do
232
+ before(:each) { create_cache }
233
+ it "should save changes to existing contacts" do
234
+ id = "http://www.google.com/m8/feeds/contacts/person%40example.org/base/637e301a549c176e"
235
+ @cache.update(id, "The Changed Name", ["change1@somewhere.org"])
236
+ @cache.save
237
+ new_cache = Bitsa::ContactsCache.new(@tmp_file.path, 1)
238
+ expected = [["change1@somewhere.org", "The Changed Name"]]
239
+ new_cache.get(id).should =~ expected
240
+ end
241
+
242
+ it "should save newly added entries" do
243
+ id = "NONEXISTENT"
244
+ @cache.update(id, "The Changed Name", ["change1@somewhere.org"])
245
+ @cache.save
246
+ new_cache = Bitsa::ContactsCache.new(@tmp_file.path, 1)
247
+ expected = [["change1@somewhere.org", "The Changed Name"]]
248
+ new_cache.get(id).should =~ expected
249
+ end
250
+
251
+ context "to a file that I have no write rights to" do
252
+ before(:each) { FileUtils.chmod(0444, @tmp_file.path) }
253
+
254
+ it "should throw an exception when I try to write to it" do
255
+ lambda {@cache.save}.should raise_error(Errno::EACCES)
256
+ end
257
+
258
+ end
259
+ end
260
+
261
+ private
262
+
263
+ def create_cache(last_modified = nil, lifespan_days = 1)
264
+ source_last_modified, addresses = YAML::load_file("spec/data/bitsa_cache.yml")
265
+ source_last_modified = last_modified.to_s if last_modified
266
+
267
+ @tmp_file = Tempfile.open("cache")
268
+ File.open(@tmp_file.path, "w") do |f|
269
+ f.write(YAML::dump([source_last_modified, addresses]))
270
+ end
271
+ @cache = Bitsa::ContactsCache.new(@tmp_file.path, lifespan_days)
272
+ end
273
+
274
+ def create_empty_temp_file
275
+ tmp_file = Tempfile.open("cache")
276
+ File.open(tmp_file.path, "w") do |f|
277
+ f.write('')
278
+ end
279
+ tmp_file
280
+ end
281
+
282
+ def should_be_an_empty_cache cache
283
+ cache.should_not be_nil
284
+ cache.size.should == 0
285
+ cache.source_last_modified.should be_nil
286
+ cache.stale?.should be_true
287
+ end
288
+
289
+ def read_test_data
290
+ source_last_modified, addresses = YAML::load_file(@tmp_file.path)
291
+ addresses.values.flatten(1).sort
292
+ end
293
+
294
+ end