googlecontacts 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.
@@ -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