bradfitz / addressbooker

Address Book tool

This URL has Read+Write access

addressbooker / addressbooker.py
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 1 # Copyright (C) 2008 Brad Fitzpatrick
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15
16 __author__ = 'brad@danga.com (Brad Fitzpatrick)'
17
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 18 # Core Python
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 19 import cgi
20 import logging
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 21 import pprint
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 22 import random
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 23 import re
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 24 import urllib
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 25
26 # Core/AppEngine stuff
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 27 import wsgiref.handlers
28 from google.appengine.api import users
29 from google.appengine.ext import webapp
30 from google.appengine.ext import db
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 31 from google.appengine.ext.webapp import template
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 32 from google.appengine.api import urlfetch
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 33
34 # Libraries included w/ app
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 35 import atom
36 import atom.http_interface
37 import atom.token_store
38 import atom.url
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 39 import gdata.alt.appengine
40 import gdata.auth
41 import gdata.contacts.service as contactsservice
42 import gdata.service
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 43 import simplejson
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 44
45 # App stuff
46 import settings
47 import models
48
49 VALID_HANDLE = re.compile(r"^\w+$")
50
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 51
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 52 def NumberSuffixesMatch(num1, num2):
53 """Given two phone numbers, return bool if they match.
54
55 Numbers are strings. A match is 7 matching final
56 numbers, ignoring punctuations and space and stuff.
57 """
58 num1 = re.sub(r"[^\d]", "", num1)
59 num2 = re.sub(r"[^\d]", "", num2)
60 if len(num1) < 6 or len(num2) < 6:
61 return False
62 return num1[-7:] == num2[-7:]
63
64
65 def FindEntryToMergeInto(contact, feed):
66 """Finds Entry (or None) in feed to merge contact into."""
67 contact_name = contact["name"]
68 for entry in feed.entry:
69 if entry.title and entry.title.text and \
70 entry.title.text == contact_name:
71 return entry
72
73 for phone_number in entry.phone_number:
74 google_number = phone_number.text
75 for number_rec in contact["numbers"]:
76 contact_number = number_rec["number"]
77 if NumberSuffixesMatch(google_number, contact_number):
78 return entry
79
80 return None
81
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 82
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 83 def PhoneRelType(text):
84 """Given some free-formish text, map that to the GData phone rel."""
85 if re.match(r"^\s*(mobile|cell)", text, re.I):
86 return "http://schemas.google.com/g/2005#mobile"
87 if re.match(r"^\s*(work|office)", text, re.I):
88 return "http://schemas.google.com/g/2005#work"
89 if re.match(r"^\s*(house|home)", text, re.I):
90 return "http://schemas.google.com/g/2005#home"
91 return "http://schemas.google.com/g/2005#other"
92
93
94 def NewContactEntry(contact, group=None):
95 """Make a new GData Contact Entry from a submitted contact dict."""
96 new_entry = gdata.contacts.ContactEntry()
97 if contact["name"]:
98 new_entry.title = atom.Title(text=contact["name"])
99
100 for number_rec in contact["numbers"]:
101 new_entry.phone_number.append(gdata.contacts.PhoneNumber(
102 rel=PhoneRelType(number_rec["type"]),
103 text=number_rec["number"]))
104
105 if group:
106 new_entry.group_membership_info.append(group)
107
108 return new_entry
109
110
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 111 class Updater(object):
112 """Queues up updates and flushes them to gdata batch as needed."""
113
114 def __init__(self, client=None):
115 self.client = client
116 self.batch_feed = gdata.contacts.ContactsFeed()
117
118 def AddInsert(self, entry):
119 self.batch_feed.AddInsert(entry)
120 self.FlushIfNeeded()
121
122 def AddUpdate(self, entry):
123 self.batch_feed.AddUpdate(entry)
124 self.FlushIfNeeded()
125
126 def FlushIfNeeded(self):
127 if len(self.batch_feed.entry) >= 50: # could be 100 max
128 self.Flush()
129
130 def Flush(self):
131 if not len(self.batch_feed.entry):
132 return
133 self.client.ExecuteBatch(self.batch_feed,
134 gdata.contacts.service.DEFAULT_BATCH_URL)
135 self.batch_feed = gdata.contacts.ContactsFeed()
136
137
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 138 class AddressBooker(webapp.RequestHandler):
139
140 def get(self):
141 self.response.headers['Content-Type'] = 'text/html'
142
143 self.response.out.write("""<!DOCTYPE html><html><head>
144 <title>AddressBooker: merge contacts into your Google Address Book
145 </title>
146 <link rel="stylesheet" type="text/css"
147 href="/static/feedfetcher.css"/>
148 </head><body>""")
149
150 self.response.out.write("""<div id="nav"><a href="/">Home</a>""")
151 if users.get_current_user():
152 self.response.out.write('<a href="%s">Sign Out</a>' % (
153 users.create_logout_url('http://%s/merge/' % settings.HOST_NAME)))
154 else:
155 self.response.out.write('<a href="%s">Sign In</a>' % (
156 users.create_login_url('http://%s/merge/' % settings.HOST_NAME)))
157 self.response.out.write('</div>')
158
159
160 def post(self):
4ee0964e » Brad Fitzpatrick 2008-11-28 start of simple model to ho... 161 handle = self.request.get('handle')
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 162 if not handle:
163 raise "Missing argument 'handle'"
164 if not VALID_HANDLE.match(handle):
165 raise "Bogus handle."
166
4ee0964e » Brad Fitzpatrick 2008-11-28 start of simple model to ho... 167 json = self.request.get('json')
168 group = self.request.get('group')
169
170 if handle:
171 post_dump = models.PostDump(key_name="handle:" + handle,
172 json=json,
173 group=group,
174 handle=handle)
175 post_dump.put()
176
177 contacts = simplejson.loads(json)
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 178
179 self.response.out.write(template.render('now_what.html', {
180 'n_contacts': len(contacts),
181 'handle': str(post_dump.key()),
182 }))
183
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 184 #self.response.out.write("You posted: " + pprint.pformat(contacts));
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 185
186
187 class MergeView(webapp.RequestHandler):
188 """View the contacts for a given handle."""
189
190 def get(self):
191 key = self.request.get('key')
192 if not key:
193 raise "Missing argument 'key'"
194 post_dump = models.PostDump.get(db.Key(key))
195 if not post_dump:
196 raise "State lost? Um, do it again."
197
198 contacts = simplejson.loads(post_dump.json)
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 199 for contact in contacts:
200 self.response.out.write("<br clear='both'><h2>%s</h2>" % contact["name"])
201 self.response.out.write("<img src='%s' style='float:left' />" % contact["img"])
202 for number in contact["numbers"]:
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 203 obf_number = re.sub(r"\d{3}$", "<i>xxx</i>", number["number"])
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 204 self.response.out.write("<p><b>%s</b> %s</p>" % (
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 205 number["type"], obf_number))
206
207
208 class MergeGoogle(webapp.RequestHandler):
209 """Merge contacts into Google Contacts w/ Google Contacts API."""
210
211 def get(self):
212 key = self.request.get('key')
213 if not key:
214 raise "Missing argument 'key'"
215 post_dump = models.PostDump.get(db.Key(key))
216 if not post_dump:
217 raise "State lost? Um, do it again."
218
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 219 def out(str):
220 self.response.out.write(str)
221
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 222 user = users.get_current_user()
223 logging.info("Current user: " + str(user))
224
225 # We need a logged-in user for the GData.client.token_store to work.
226 if not user:
227 logging.info("Redirecting to sign-in.");
228 sign_in_url = users.create_login_url('http://%s/merge/google?key=%s' %
229 (settings.HOST_NAME, key))
230 self.redirect(sign_in_url)
231 return
232
233 # And the subclass of the Service for the Contacts API:
234 client = contactsservice.ContactsService()
235 gdata.alt.appengine.run_on_appengine(client)
236
237 contacts = simplejson.loads(post_dump.json)
238
239 contacts_url = "http://www.google.com/m8/feeds/contacts/default/full"
240 auth_base_url = "http://www.google.com/m8/feeds/"
241
242 session_token = client.token_store.find_token(auth_base_url)
243 if type(session_token) == atom.http_interface.GenericToken:
244 session_token = None
245
246 if not session_token:
247 # Find the AuthSub token and upgrade it to a session token.
248 auth_token = gdata.auth.extract_auth_sub_token_from_url(self.request.uri)
249 if auth_token:
250 session_token = client.upgrade_to_session_token(auth_token)
251 client.token_store.add_token(session_token)
252 # just to sanitize our URL:
253 self.redirect('http://%s/merge/google?key=%s' %
254 (settings.HOST_NAME, key))
255 else:
256 next = self.request.uri
257 auth_sub_url = client.GenerateAuthSubURL(next, auth_base_url,
258 secure=False, session=True)
259 self.redirect(str(auth_sub_url))
260 return
261
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 262 out("<p>We're good to go. %s</p>" % str(session_token));
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 263 sign_out_url = users.create_logout_url('http://%s/merge/' %
264 (settings.HOST_NAME))
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 265 out("\nOr you want to <a href='%s'>log out</a>?" % sign_out_url)
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 266
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 267 # Process Groups
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 268 groups_feed = client.Get("http://www.google.com/m8/feeds/groups/default/full")
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 269 groups_feed = gdata.contacts.GroupsFeedFromString(str(groups_feed))
270 group_name = {} # id -> name
271 group_id = {} # name -> id
272 for group in groups_feed.entry:
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 273 group_name[group.id.text] = group.content.text
274 group_id[group.content.text] = group.id.text
275 if True:
276 out("<h3>Group</h3><ul>")
277 out("<li>id: %s</li>" % cgi.escape(group.id.text))
278 out("<li>content: %s</li>" % cgi.escape(group.content.text))
279 out("</ul>")
280
281 dest_group_name = post_dump.group or "AddressBooker"
282 if dest_group_name not in group_id:
283 new_group = gdata.contacts.GroupEntry(title=atom.Title(
284 text=dest_group_name))
285 group = client.CreateGroup(new_group)
286 out("<h3>group: %s</h3>" % group.id)
287 group_name[group.id] = dest_group_name
288 group_id[dest_group_name] = group.id
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 289
290 full_feed_url = contacts_url + "?max-results=99999"
291 feed = client.Get(full_feed_url, converter=gdata.contacts.ContactsFeedFromString)
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 292
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 293 updater = Updater(client=client);
294
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 295 #new_entry.email.append(gdata.contacts.Email(
296 # rel='http://schemas.google.com/g/2005#work',
297 # address='TESTTEST@gmail.com'))
298 #new_entry.content = atom.Content(text='Test Notes')
299
300 group = gdata.contacts.GroupMembershipInfo(href=unicode(group_id[dest_group_name]))
f3be64f3 » Brad Fitzpatrick 2008-11-29 remove unused crap, and add... 301
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 302 for contact in contacts:
303 out("<br clear='both'><h2>%s</h2>" % contact["name"])
304 out("<img src='%s' style='float:left' />" % contact["img"])
305 for number in contact["numbers"]:
306 out("<p><b>%s</b> %s</p>" % (number["type"], number["number"]))
307 merge_entry = FindEntryToMergeInto(contact, feed)
308 if merge_entry:
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 309 out("<p><b>Action: merge into: </b> %s</p>" % cgi.escape(
310 merge_entry.ToString().decode("utf-8")))
311 #UpdateContactEntry(merge_entry, contact)
312 #updater.AddUpdate(merge_entry)
f2b7da9b » Brad Fitzpatrick 2008-11-29 find candidates for merging. 313 else:
314 out("<p><b>Action: new Google Contact</b>")
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 315 updater.AddInsert(NewContactEntry(contact, group=group))
316
317 render_google_list = False
318 if render_google_list:
319 out("<hr />")
320 for entry in feed.entry:
321 if entry.title and entry.title.text:
322 out('<h3>Entry Title: %s</h3>' % (
323 entry.title.text.decode('UTF-8')))
324 else:
325 out("<h3>(title-less entry)</h3>");
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 326
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 327 for phone_number in entry.phone_number:
328 out("<p><b>Phone: (%s)</b> %s</p>" %
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 329 (phone_number.rel, phone_number.text))
330
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 331 for email in entry.email:
332 out("<p><b>Email: (%s)</b> %s</p>" %
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 333 (email.rel, email.address))
334
15176620 » Brad Fitzpatrick 2008-11-29 Inserting new entries works... 335 for group in entry.group_membership_info:
336 out("<p><b>Group: (%s)</b> %s</p>" %
337 (group.href, cgi.escape(str(group))))
338
339 updater.Flush()
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 340
341
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 342
343 class Acker(webapp.RequestHandler):
344 """Simulates an HTML page to prove ownership of this domain for AuthSub
345 registration."""
346
347 def get(self):
348 self.response.headers['Content-Type'] = 'text/plain'
349 self.response.out.write('This file present for AuthSub registration.')
350
351
352 def main():
04acea54 » Brad Fitzpatrick 2008-11-29 gdata contacts stuff workin... 353 application = webapp.WSGIApplication([
354 ('/merge/', AddressBooker),
355 ('/merge/google', MergeGoogle),
356 ('/merge/view', MergeView),
357 ('/google72db3d6838b4c438.html', Acker),
358 ], debug=True)
0ed12f30 » Brad Fitzpatrick 2008-11-28 forked feedfetcher into add... 359 wsgiref.handlers.CGIHandler().run(application)
360
361
362 if __name__ == '__main__':
363 main()