googlecontacts 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,125 @@
1
+ module GoogleContacts
2
+ module Proxies
3
+ class Emails < BlankSlate
4
+ def initialize(parent)
5
+ @parent = parent
6
+ reinitialize
7
+ end
8
+
9
+ def reinitialize
10
+ @current = ::Hash[*@parent.xml.xpath("./gd:email").map do |entry|
11
+ email = Email.new(self, entry.attributes)
12
+ [email.address, email]
13
+ end.flatten]
14
+
15
+ # create a deep copy
16
+ @new = ::Hash[*@current.map do |k,v|
17
+ [k.dup, v.dup]
18
+ end.flatten]
19
+ end
20
+
21
+ def changed?
22
+ @current != @new
23
+ end
24
+
25
+ def primary!(address)
26
+ @new.each do |key, email|
27
+ if key == address
28
+ email.primary = true
29
+ else
30
+ email.delete(:primary)
31
+ end
32
+ end
33
+ end
34
+
35
+ def <<(address)
36
+ raise "Duplicate address" if @new[address]
37
+ add(address)
38
+ end
39
+
40
+ def [](address)
41
+ @new[address] || add(address)
42
+ end
43
+
44
+ def synchronize
45
+ @parent.remove_xml("./gd:email")
46
+ @new.each_pair do |address, email|
47
+ @parent.insert_xml('gd:email', email)
48
+ end
49
+ end
50
+
51
+ private
52
+ def add(address)
53
+ set_primary = @new.empty?
54
+ @new[address] = Email.new(self, { :address => address })
55
+ @new[address].primary = true if set_primary
56
+ @new[address]
57
+ end
58
+
59
+ def method_missing(sym, *args, &blk)
60
+ if [:size, :delete].include?(sym)
61
+ @new.send(sym, *args, &blk)
62
+ else
63
+ super
64
+ end
65
+ end
66
+
67
+ class Email < ::HashWithIndifferentAccess
68
+ DEFAULTS = {
69
+ :rel => 'http://schemas.google.com/g/2005#home'
70
+ }.freeze
71
+
72
+ alias_attribute :name, :displayName
73
+
74
+ def initialize(parent, attributes = {})
75
+ super(DEFAULTS)
76
+ @parent = parent
77
+
78
+ attributes.each do |key, value|
79
+ send("#{key}=", value)
80
+ end
81
+ end
82
+
83
+ def primary!
84
+ @parent.primary! self[:address]
85
+ end
86
+
87
+ def rel=(arg)
88
+ delete(:label)
89
+ method_missing("rel=", arg)
90
+ end
91
+
92
+ def label=(arg)
93
+ delete(:rel)
94
+ method_missing("label=", arg)
95
+ end
96
+
97
+ def []=(key, value)
98
+ if "#{key}" == 'address' && self[key]
99
+ raise "Cannot modify email address"
100
+ end
101
+ super(key, value.to_s)
102
+ end
103
+
104
+ def dup
105
+ self.class.new(@parent, self)
106
+ end
107
+
108
+ def method_missing(sym, *args, &blk)
109
+ if sym.to_s =~ /^(\w+)(=|\?)?$/
110
+ case $2
111
+ when '='
112
+ send(:[]=, $1, *args)
113
+ when '?'
114
+ send(:[], $1) == 'true'
115
+ else
116
+ send(:[], $1)
117
+ end
118
+ else
119
+ super
120
+ end
121
+ end
122
+ end # class Email
123
+ end # class Emails
124
+ end # module Proxies
125
+ end # module GoogleContacts
@@ -0,0 +1,41 @@
1
+ module GoogleContacts
2
+ module Proxies
3
+ class Hash < BlankSlate
4
+ def initialize(parent, options)
5
+ @parent = parent
6
+ @tag = options[:tag]
7
+ @key = options[:key]
8
+ @value = options[:value]
9
+
10
+ reinitialize
11
+ end
12
+
13
+ def reinitialize
14
+ @current = HashWithIndifferentAccess.new
15
+ @parent.xml.xpath("./#{@tag}").map do |entry|
16
+ @current[entry[@key]] = entry[@value]
17
+ end
18
+
19
+ # create a deep copy
20
+ @new = HashWithIndifferentAccess.new
21
+ @current.each { |k,v| @new[k.dup] = v.dup }
22
+ end
23
+
24
+ def changed?
25
+ @current != @new
26
+ end
27
+
28
+ def synchronize
29
+ @parent.remove_xml("./#{@tag}")
30
+ @new.each_pair do |key, value|
31
+ @parent.insert_xml(@tag, @key => key, @value => value)
32
+ end
33
+ end
34
+
35
+ private
36
+ def method_missing(sym, *args, &blk)
37
+ @new.send(sym, *args, &blk)
38
+ end
39
+ end # class Hash
40
+ end # module Proxies
41
+ end # module GoogleContacts
@@ -0,0 +1,126 @@
1
+ module GoogleContacts
2
+ class Wrapper
3
+ attr_reader :consumer
4
+
5
+ CONTACTS_BATCH = "http://www.google.com/m8/feeds/contacts/default/full/batch".freeze
6
+ GROUPS_BATCH = "http://www.google.com/m8/feeds/groups/default/full/batch".freeze
7
+
8
+ # Proxies for crud
9
+ attr_reader :contacts
10
+ attr_reader :groups
11
+
12
+ def initialize(consumer)
13
+ @consumer = consumer
14
+
15
+ @contacts = CollectionProxy.new(self, 'contacts')
16
+ @groups = CollectionProxy.new(self, 'groups')
17
+ end
18
+
19
+ def get(url, options = {})
20
+ query = options.map { |k,v| "#{k}=#{v}" }.join('&')
21
+ url += "?#{query}" if query.size > 0
22
+
23
+ body = consumer.get(url).body
24
+ Nokogiri::XML.parse body
25
+ end
26
+
27
+ def post(url, body)
28
+ consumer.post(url, body, 'Content-Type' => 'application/atom+xml')
29
+ end
30
+
31
+ def batch(options = {}, &blk)
32
+ raise "Nesting of calls to batch is not allowed" if @batching
33
+ @batching = true
34
+ @batch ||= []
35
+
36
+ yield(blk)
37
+ @batching = false
38
+
39
+ # create documents to be flushed
40
+ documents = @batch.each_slice(100).map do |chunk|
41
+ batch_document(chunk)
42
+ end
43
+ @batch.clear
44
+
45
+ if options[:return_documents]
46
+ documents
47
+ else
48
+ documents.each do |doc|
49
+ flush_batch(doc)
50
+ end
51
+ end
52
+ end
53
+
54
+ def find(what, options = {}, &blk)
55
+ xml = get("http://www.google.com/m8/feeds/#{what}/default/full", options)
56
+ xml.xpath('/xmlns:feed/xmlns:entry').map(&blk)
57
+ end
58
+
59
+ def save(instance)
60
+ entry = instance.entry_for_batch(instance.new? ? :insert : :update)
61
+ append_to_batch(entry)
62
+ end
63
+
64
+ private
65
+
66
+ def append_to_batch(entry)
67
+ if @batching
68
+ if @batch.present?
69
+ # puts @batch.last.a
70
+ # puts @batch.last.at('./category').inspect
71
+ batch_term = @batch.last.at('./category')['term']
72
+ entry_term = entry.at('./category')['term']
73
+ raise "Cannot mix Contact and Group in one batch" if batch_term != entry_term
74
+ end
75
+
76
+ @batch << entry
77
+ else
78
+ batch do
79
+ @batch << entry
80
+ end
81
+ end
82
+ end
83
+
84
+ # Use the <category/> tag of the first entry to find out
85
+ # which type we're flushing
86
+ def flush_batch(document)
87
+ url = case document.at('./xmlns:entry[1]/xmlns:category')['term']
88
+ when /#contact$/i
89
+ CONTACTS_BATCH
90
+ when /#group$/i
91
+ GROUPS_BATCH
92
+ else
93
+ raise "Unable to determine type for batch"
94
+ end
95
+ post(url, document.to_xml)
96
+ end
97
+
98
+ def batch_document(*operations)
99
+ batch_feed = Base.feed_for_batch
100
+ operations.flatten.each do |operation|
101
+ batch_feed << operation
102
+ end
103
+ batch_feed
104
+ end
105
+
106
+ class CollectionProxy
107
+ def initialize(wrapper, collection)
108
+ @wrapper = wrapper
109
+ @collection = collection
110
+ @klass = collection.singularize.camelcase.constantize
111
+ end
112
+
113
+ # :what - all, ID, whatever, currently unused
114
+ def find(what, options = {})
115
+ @wrapper.find(@collection, options) do |entry|
116
+ @klass.new(@wrapper, entry)
117
+ end
118
+ end
119
+
120
+ def build(attributes = {})
121
+ @klass.new(@wrapper)
122
+ end
123
+
124
+ end # class CollectionProxy
125
+ end # class Wrapper
126
+ end # module GoogleContacts
@@ -0,0 +1,60 @@
1
+ <feed xmlns='http://www.w3.org/2005/Atom'
2
+ xmlns:openSearch='http://a9.com/-/spec/opensearch/1.1/'
3
+ xmlns:gContact='http://schemas.google.com/contact/2008'
4
+ xmlns:batch='http://schemas.google.com/gdata/batch'
5
+ xmlns:gd='http://schemas.google.com/g/2005'
6
+ gd:etag='W/"CUMBRHo_fip7ImA9WxRbGU0."'>
7
+ <id>liz@gmail.com</id>
8
+ <updated>2008-12-10T10:04:15.446Z</updated>
9
+ <category scheme='http://schemas.google.com/g/2005#kind'
10
+ term='http://schemas.google.com/contact/2008#contact' />
11
+ <title>Elizabeth Bennet's Contacts</title>
12
+ <link rel='http://schemas.google.com/g/2005#feed'
13
+ type='application/atom+xml'
14
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full' />
15
+ <link rel='http://schemas.google.com/g/2005#post'
16
+ type='application/atom+xml'
17
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full' />
18
+ <link rel='http://schemas.google.com/g/2005#batch'
19
+ type='application/atom+xml'
20
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full/batch' />
21
+ <link rel='self' type='application/atom+xml'
22
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full?max-results=25' />
23
+ <author>
24
+ <name>Elizabeth Bennet</name>
25
+ <email>liz@gmail.com</email>
26
+ </author>
27
+ <generator version='1.0' uri='http://www.google.com/m8/feeds'>
28
+ Contacts
29
+ </generator>
30
+ <openSearch:totalResults>1</openSearch:totalResults>
31
+ <openSearch:startIndex>1</openSearch:startIndex>
32
+ <openSearch:itemsPerPage>25</openSearch:itemsPerPage>
33
+ <entry gd:etag='"Qn04eTVSLyp7ImA9WxRbGEUORAQ."'>
34
+ <id>
35
+ http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base/c9012de
36
+ </id>
37
+ <updated>2008-12-10T04:45:03.331Z</updated>
38
+ <app:edited xmlns:app='http://www.w3.org/2007/app'>2008-12-10T04:45:03.331Z</app:edited>
39
+ <category scheme='http://schemas.google.com/g/2005#kind'
40
+ term='http://schemas.google.com/contact/2008#contact' />
41
+ <title>Fitzwilliam Darcy</title>
42
+ <gd:name>
43
+ <gd:fullName>Fitzwilliam Darcy</gd:fullName>
44
+ </gd:name>
45
+ <link rel='http://schemas.google.com/contacts/2008/rel#photo' type='image/*'
46
+ href='http://www.google.com/m8/feeds/photos/media/liz%40gmail.com/c9012de'
47
+ gd:etag='"KTlcZWs1bCp7ImBBPV43VUV4LXEZCXERZAc."' />
48
+ <link rel='self' type='application/atom+xml'
49
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full/c9012de' />
50
+ <link rel='edit' type='application/atom+xml'
51
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/full/c9012de' />
52
+ <gd:phoneNumber rel='http://schemas.google.com/g/2005#home'
53
+ primary='true'>
54
+ 456
55
+ </gd:phoneNumber>
56
+ <gd:extendedProperty name='pet' value='hamster' />
57
+ <gContact:groupMembershipInfo deleted='false'
58
+ href='http://www.google.com/m8/feeds/groups/liz%40gmail.com/base/270f' />
59
+ </entry>
60
+ </feed>
@@ -0,0 +1,58 @@
1
+ <feed xmlns='http://www.w3.org/2005/Atom'
2
+ xmlns:openSearch='http://a9.com/-/spec/opensearch/1.1/'
3
+ xmlns:gContact='http://schemas.google.com/contact/2008'
4
+ xmlns:batch='http://schemas.google.com/gdata/batch'
5
+ xmlns:gd='http://schemas.google.com/g/2005'
6
+ gd:etag='W/"D08MQnc-fSp7ImA9WxRbGU0."'>
7
+ <id>jo@gmail.com</id>
8
+ <updated>2008-12-10T10:44:43.955Z</updated>
9
+ <category scheme='http://schemas.google.com/g/2005#kind'
10
+ term='http://schemas.google.com/contact/2008#group' />
11
+ <title>Jo March's Contact Groups</title>
12
+ <link rel='alternate' type='text/html'
13
+ href='http://www.google.com/' />
14
+ <link rel='http://schemas.google.com/g/2005#feed'
15
+ type='application/atom+xml'
16
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin' />
17
+ <link rel='http://schemas.google.com/g/2005#post'
18
+ type='application/atom+xml'
19
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin' />
20
+ <link rel='http://schemas.google.com/g/2005#batch'
21
+ type='application/atom+xml'
22
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin/batch' />
23
+ <link rel='self'
24
+ type='application/atom+xml'
25
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin?max-results=25' />
26
+ <author>
27
+ <name>Jo March</name>
28
+ <email>jo@gmail.com</email>
29
+ </author>
30
+ <generator version='1.0'
31
+ uri='http://www.google.com/m8/feeds'>Contacts</generator>
32
+ <openSearch:totalResults>5</openSearch:totalResults>
33
+ <openSearch:startIndex>1</openSearch:startIndex>
34
+ <openSearch:itemsPerPage>25</openSearch:itemsPerPage>
35
+ <entry gd:etag='"YDwqeyI."'>
36
+ <id>http://www.google.com/m8/feeds/groups/jo%40gmail.com/base/6</id>
37
+ <updated>1970-01-01T00:00:00.000Z</updated>
38
+ <category scheme='http://schemas.google.com/g/2005#kind'
39
+ term='http://schemas.google.com/contact/2008#group' />
40
+ <title>System Group: My Contacts</title>
41
+ <content>System Group: My Contacts</content>
42
+ <link rel='self' type='application/atom+xml'
43
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin/6' />
44
+ <gContact:systemGroup id='Contacts' />
45
+ </entry>
46
+ <entry gd:etag='"Rn05fDVSLyp7ImA9WxRbGEUORQM."'>
47
+ <id>http://www.google.com/m8/feeds/groups/jo%40gmail.com/base/68f415478ba1aa69</id>
48
+ <updated>2008-12-10T04:44:37.324Z</updated>
49
+ <category scheme='http://schemas.google.com/g/2005#kind'
50
+ term='http://schemas.google.com/contact/2008#group' />
51
+ <title>joggers</title>
52
+ <content>joggers</content>
53
+ <link rel='self' type='application/atom+xml'
54
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin/68f415478ba1aa69' />
55
+ <link rel='edit' type='application/atom+xml'
56
+ href='http://www.google.com/m8/feeds/groups/jo%40gmail.com/thin/68f415478ba1aa69' />
57
+ </entry>
58
+ </feed>
@@ -0,0 +1,78 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ class BaseTester < Base
4
+ CATEGORY_TERM = "i'm not used here"
5
+ end
6
+
7
+ describe Base do
8
+ it "should not be possible to create" do
9
+ lambda {
10
+ Base.new(wrapper, 'some xml')
11
+ }.should raise_error(/cannot create instance/i)
12
+ end
13
+
14
+ describe "with an XML document" do
15
+ before(:each) do
16
+ @t = BaseTester.new(wrapper)
17
+ end
18
+
19
+ it "should default namespace to document default" do
20
+ node = @t.insert_xml 'tag'
21
+ node.namespace.href.should == 'http://www.w3.org/2005/Atom'
22
+ @t.xpath('xmlns:tag').should have(1).node
23
+ end
24
+
25
+ it "should set namespace when specified in tag" do
26
+ node = @t.insert_xml 'gd:extendedProperty'
27
+ node.namespace.href.should == 'http://schemas.google.com/g/2005'
28
+ @t.xpath('gd:extendedProperty').should have(1).node
29
+
30
+ node = @t.insert_xml 'gContact:birthday'
31
+ node.namespace.href.should == 'http://schemas.google.com/contact/2008'
32
+ @t.xpath('gContact:birthday').should have(1).node
33
+ end
34
+
35
+ it "should raise on unknown namespace" do
36
+ lambda {
37
+ @t.insert_xml 'unknown:foo'
38
+ }.should raise_error(/unknown namespace/i)
39
+ end
40
+
41
+ it "should also set attributes if given" do
42
+ node = @t.insert_xml 'tag', :foo => 'bar'
43
+ node['foo'].should == 'bar'
44
+ end
45
+
46
+ it "should allow removing xml" do
47
+ @t.insert_xml 'gd:extendedProperty'
48
+ @t.xpath('./gd:extendedProperty').should have(1).node
49
+
50
+ @t.remove_xml 'gd:extendedProperty'
51
+ @t.xpath('./gd:extendedProperty').should have(:no).nodes
52
+ end
53
+ end
54
+
55
+ describe "prepare for batch operation" do
56
+ before(:all) do
57
+ @t = BaseTester.new(wrapper, parsed_asset('contacts_full').at('feed > entry'))
58
+ @batch = @t.entry_for_batch(:update)
59
+ end
60
+
61
+ it "should not share the same document" do
62
+ @batch.document.should_not == @t.xml.document
63
+ end
64
+
65
+ it "should create a duplicate node without link tags" do
66
+ @batch.xpath('./xmlns:link').should be_empty
67
+ end
68
+
69
+ it "should remove the updated tag (not useful when updating)" do
70
+ @batch.xpath('./xmlns:updated').should be_empty
71
+ end
72
+
73
+ it "should be possible to combine feed_for_batch and entry_for_batch" do
74
+ feed = BaseTester.feed_for_batch
75
+ feed << @t.entry_for_batch(:update)
76
+ end
77
+ end
78
+ end