public
Description: Address Book tool
Homepage: http://addressbooker.appspot.com/
Clone URL: git://github.com/bradfitz/addressbooker.git
addressbooker / addressbooker.py
100644 539 lines (430 sloc) 17.161 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# Copyright (C) 2008 Brad Fitzpatrick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
 
 
__author__ = 'brad@danga.com (Brad Fitzpatrick)'
 
# Core Python
import cgi
import logging
import pprint
import random
import re
import urllib
 
# Core/AppEngine stuff
import wsgiref.handlers
from google.appengine.api import users
from google.appengine.ext import webapp
from google.appengine.ext import db
from google.appengine.ext.webapp import template
from google.appengine.api import urlfetch
 
# Libraries included w/ app
import atom
import atom.http_interface
import atom.token_store
import atom.url
import gdata.alt.appengine
import gdata.auth
import gdata.contacts.service as contactsservice
import gdata.service
import simplejson
 
# App stuff
import settings
import models
 
VALID_HANDLE = re.compile(r"^\w+$")
 
 
def NumberSuffixesMatch(num1, num2):
  """Given two phone numbers, return bool if they match.
 
Numbers are strings. A match is 7 matching final
numbers, ignoring punctuations and space and stuff.
"""
  num1 = re.sub(r"[^\d]", "", num1)
  num2 = re.sub(r"[^\d]", "", num2)
  if len(num1) < 6 or len(num2) < 6:
    return False
  return num1[-7:] == num2[-7:]
 
 
def PhoneNumberListContainsNumber(number_list, number):
  """Searches number_list for number. Returns True if found.
 
Args:
number_list: list of gdata PhoneNumber
number: string phone number.
Returns: bool.
"""
  for phone_number in number_list:
    google_number = phone_number.text
    if NumberSuffixesMatch(google_number, number):
      return True
  return False
 
 
def GroupListContainsGroup(group_list, group):
  """Returns true if group found in group_list.
 
Args:
group_list: array of GroupMembershipInfo
group: a GroupMembershipInfo
Returns: bool.
"""
  assert group
 
  sought_href = group.href
  for potential_match in group_list:
    if potential_match.href == sought_href:
      return True
  return False
 
 
def FindEntryToMergeInto(contact, feed):
  """Finds Entry (or None) in feed to merge contact into."""
  contact_name = contact["name"]
  for entry in feed.entry:
    if entry.title and entry.title.text and \
       entry.title.text == contact_name:
      return entry
      
    for number_rec in contact["numbers"]:
      contact_number = number_rec["number"]
      if PhoneNumberListContainsNumber(entry.phone_number,
                                       contact_number):
        return entry
 
  return None
 
 
def PhoneRelType(text):
  """Given some free-formish text, map that to the GData phone rel."""
  if re.match(r"^\s*(mobile|cell)", text, re.I):
    return "http://schemas.google.com/g/2005#mobile"
  if re.match(r"^\s*(work|office)", text, re.I):
    return "http://schemas.google.com/g/2005#work"
  if re.match(r"^\s*(house|home)", text, re.I):
    return "http://schemas.google.com/g/2005#home"
  return "http://schemas.google.com/g/2005#other"
 
 
def VcardPhoneType(phone_rel):
  """Given a phone rel type from PhoneRelType, returns the vcard type."""
  vcard_map = {
    "http://schemas.google.com/g/2005#mobile": "CELL",
    "http://schemas.google.com/g/2005#home": "HOME",
    "http://schemas.google.com/g/2005#work": "WORK",
  }
  if phone_rel in vcard_map:
    return vcard_map[phone_rel]
  return "OTHER"
 
 
def NewContactEntry(contact, group=None):
  """Make a new GData Contact Entry from a submitted contact dict.
 
Returns: The new, populated cgdata.contacts.ContactEntry object.
"""
  new_entry = gdata.contacts.ContactEntry()
  UpdateContactEntry(new_entry, contact, group)
  return new_entry
 
 
def UpdateContactEntry(merge_entry, contact, group=None):
  """Merge user-submitted contact data into GData entry.
 
Args:
merge_entry: The gdata.contacts.ContactEntry() object to
merge into:
contact: Input contact dictionary from user w/ fields
group: optional GroupMembershipInfo to put user in
 
Returns: List of changes (terse English each). If no changes,
returns the empty list, which is false in boolean context.
"""
  changes = []
 
  if contact["name"]:
    # TODO: promote short names (e.g. "Brad" or "Brad F.") to
    # full names ("Brad Fitzpatrick"). For now, never modify
    # the name.
    if not merge_entry.title or not merge_entry.title.text:
      changes.append("set name: %s" % contact["name"])
      merge_entry.title = atom.Title(text=contact["name"])
 
  for number_rec in contact["numbers"]:
    if not PhoneNumberListContainsNumber(merge_entry.phone_number,
                                         number_rec["number"]):
      changes.append("adding number: %s" % number_rec["number"])
      merge_entry.phone_number.append(gdata.contacts.PhoneNumber(
          rel=PhoneRelType(number_rec["type"]),
          text=number_rec["number"]))
 
  if group and not GroupListContainsGroup(merge_entry.group_membership_info,
                                          group):
    changes.append("adding to group.")
    merge_entry.group_membership_info.append(group)
 
  return changes
  
 
class Updater(object):
  """Queues up updates and flushes them to gdata batch as needed."""
 
  def __init__(self, client=None, noop_mode=False):
    self.client = client
    self.batch_feed = gdata.contacts.ContactsFeed()
    self.noop_mode = noop_mode
 
  def AddInsert(self, entry):
    if self.noop_mode:
      return
    self.batch_feed.AddInsert(entry)
    self.FlushIfNeeded()
 
  def AddUpdate(self, entry):
    if self.noop_mode:
      return
    self.batch_feed.AddUpdate(entry)
    self.FlushIfNeeded()
 
  def FlushIfNeeded(self):
    if len(self.batch_feed.entry) >= 15: # 100 is max but too slow for App Engine
      self.Flush()
 
  def Flush(self):
    if not len(self.batch_feed.entry):
      return
    self.client.ExecuteBatch(self.batch_feed,
                             gdata.contacts.service.DEFAULT_BATCH_URL)
    self.batch_feed = gdata.contacts.ContactsFeed()
 
  def FlushBufferEmpty(self):
    return len(self.batch_feed.entry) == 0
 
 
class AddressBookerBaseHandler(webapp.RequestHandler):
  def WritePage(self, title, template_file, dict=None):
    sign_inout = ""
    user = users.get_current_user()
    if user:
      sign_inout = 'Hello, %s! <a href="%s">Sign Out</a>' % (
          cgi.escape(user.nickname()),
          users.create_logout_url('http://%s/' % settings.HOST_NAME))
    else:
      sign_inout = '<a href="%s">Sign In</a>' % (
        users.create_login_url('http://%s/' % settings.HOST_NAME))
    
    self.response.out.write(template.render('page.html', {
          'title': title or "AddressBooker",
          'sign_inout': sign_inout,
          'body': template.render(template_file, dict or {}),
          }))
 
class AddressBooker(AddressBookerBaseHandler):
 
  def get(self):
    self.response.headers['Content-Type'] = 'text/html'
    self.WritePage("AddressBooker", "index.html")
 
 
class Submit(webapp.RequestHandler):
 
  def get(self):
    self.redirect("/")
 
  def post(self):
    handle = self.request.get('handle')
    if not handle:
      raise "Missing argument 'handle'"
    if not VALID_HANDLE.match(handle):
      raise "Bogus handle."
 
    json = self.request.get('json')
    group = self.request.get('group')
    
    post_dump = models.PostDump(key_name="handle:" + handle,
                                json=json,
                                group=group,
                                handle=handle)
    post_dump.put()
 
    user = users.get_current_user()
    target_url = "http://%s/menu?key=%s" % (
      settings.HOST_NAME, str(post_dump.key()))
    if user:
      self.redirect(target_url)
    else:
      self.redirect(users.create_login_url(target_url))
 
 
class Menu(AddressBookerBaseHandler):
  def get(self):
    key = self.request.get('key')
    if not key:
      raise "Missing argument 'key'"
    post_dump = models.PostDump.get(db.Key(key))
    if not post_dump:
      raise "State lost? Um, do it again."
      
    contacts = simplejson.loads(post_dump.json)
 
    self.WritePage("AddressBooker Menu", "menu.html", {
        'n_contacts': len(contacts),
        'key': str(post_dump.key()),
        })
   
 
class MergeView(webapp.RequestHandler):
  """View the contacts for a given handle."""
 
  def get(self):
    key = self.request.get('key')
    if not key:
      raise "Missing argument 'key'"
    post_dump = models.PostDump.get(db.Key(key))
    if not post_dump:
      raise "State lost? Um, do it again."
 
    contacts = simplejson.loads(post_dump.json)
    for contact in contacts:
      self.response.out.write("<br clear='both'><h2>%s</h2>" % contact["name"])
      self.response.out.write("<img src='%s' style='float:left' />" % contact["img"])
      for number in contact["numbers"]:
        # obf_number = re.sub(r"\d{3}$", "<i>xxx</i>", number["number"])
        self.response.out.write("<p><b>%s</b> %s</p>" % (
          cgi.escape(number["type"]), cgi.escape(number["number"])))
 
 
class VCard(webapp.RequestHandler):
  """Get a vcard."""
 
  def get(self):
    key = self.request.get('key')
    if not key:
      raise "Missing argument 'key'"
    post_dump = models.PostDump.get(db.Key(key))
    if not post_dump:
      raise "State lost? Um, do it again."
 
    contacts = simplejson.loads(post_dump.json)
    self.response.headers['Content-Type'] = "text/x-vcard; charset=UTF-8"
    self.response.headers['Content-Disposition'] = "attachment; filename=\"addressbooker.vcf\""
 
    for contact in contacts:
      self.response.out.write("BEGIN:VCARD\n")
      self.response.out.write("VERSION:3.0\n")
      self.response.out.write("FN:%s\n" % (contact["name"] or ""))
      for number in contact["numbers"]:
        self.response.out.write("TEL;type=%s:%s\n" % (
            VcardPhoneType(PhoneRelType(number["type"])),
            number["number"]))
      self.response.out.write("END:VCARD\n")
 
 
class MergeGoogle(AddressBookerBaseHandler):
  """Merge contacts into Google Contacts w/ Google Contacts API."""
 
  def get(self):
    self.ProcessMerge(method='GET')
 
  def post(self):
    self.ProcessMerge(method='POST')
 
  def ProcessMerge(self, method):
    key = self.request.get('key')
    if not key:
      raise "Missing argument 'key'"
    post_dump = models.PostDump.get(db.Key(key))
    if not post_dump:
      raise "State lost? Um, do it again."
 
    body = [] # of str
    def out(str):
      body.append(str)
 
    # We need a logged-in user for the GData.client.token_store to work.
    user = users.get_current_user()
    if not user:
      logging.info("Redirecting to sign-in.");
      sign_in_url = users.create_login_url('http://%s/gcontacts?key=%s' %
                                           (settings.HOST_NAME, key))
      self.redirect(sign_in_url)
      return
 
    # And the subclass of the Service for the Contacts API:
    client = contactsservice.ContactsService()
    gdata.alt.appengine.run_on_appengine(client)
 
    contacts = simplejson.loads(post_dump.json)
 
    contacts_url = "http://www.google.com/m8/feeds/contacts/default/full"
    auth_base_url = "http://www.google.com/m8/feeds/"
 
    session_token = client.token_store.find_token(auth_base_url)
    if type(session_token) == atom.http_interface.GenericToken:
      session_token = None
 
    if not session_token:
      # Find the AuthSub token and upgrade it to a session token.
      auth_token = gdata.auth.extract_auth_sub_token_from_url(self.request.uri)
      if auth_token:
        session_token = client.upgrade_to_session_token(auth_token)
        client.token_store.add_token(session_token)
        # just to sanitize our URL:
        self.redirect('http://%s/gcontacts?key=%s' %
                      (settings.HOST_NAME, key))
      else:
        next = self.request.uri
        auth_sub_url = client.GenerateAuthSubURL(next, auth_base_url,
                                                 secure=False, session=True)
        self.redirect(str(auth_sub_url))
      return
 
    # Are we a GET request in auto-submit mode?
    working = (method == "GET" and self.request.get("continue"))
 
    # Process Groups
    groups_feed = client.Get("http://www.google.com/m8/feeds/groups/default/full")
    groups_feed = gdata.contacts.GroupsFeedFromString(groups_feed.ToString().decode("utf-8"))
    group_name = {} # id -> name
    group_id = {} # name -> id
    for group in groups_feed.entry:
      group_name[group.id.text] = group.content.text
      group_id[group.content.text] = group.id.text
      if False:
        out("<h3>Group</h3><ul>")
        out("<li>id: %s</li>" % cgi.escape(group.id.text))
        out("<li>content: %s</li>" % cgi.escape(group.content.text.decode("utf-8")))
        out("</ul>")
 
    # Initialize 'group' (or keep it None, if not using groups), creating the
    # group if necessary.
    group = None
    dest_group_name = post_dump.group
    if dest_group_name and dest_group_name not in group_id:
      new_group = gdata.contacts.GroupEntry(title=atom.Title(
          text=dest_group_name))
      group = client.CreateGroup(new_group)
      group_name[group.id] = dest_group_name
      group_id[dest_group_name] = group.id
    if dest_group_name:
      group = gdata.contacts.GroupMembershipInfo(href=unicode(group_id[dest_group_name]))
 
    full_feed_url = contacts_url + "?max-results=99999"
    feed = client.Get(full_feed_url, converter=gdata.contacts.ContactsFeedFromString)
 
    preview_mode = True
    title = "Preview Proposed GContacts Changes"
    if method == "POST":
      preview_mode = False
      title = "GContacts Updates Complete"
 
    contact_changes = []
 
    updater = Updater(client=client, noop_mode=preview_mode);
 
    no_change_contacts = []
    for contact in contacts:
      contact_change = {
        "contact": contact,
        }
 
      merge_entry = FindEntryToMergeInto(contact, feed)
      if merge_entry:
        entry_changes = UpdateContactEntry(merge_entry, contact, group=group)
        if entry_changes:
          contact_change["action"] = "merge"
          contact_change["merge_target"] = merge_entry.title.text.decode("utf-8")
          contact_change["changes"] = entry_changes
          updater.AddUpdate(merge_entry)
        else:
          contact_change["action"] = "none"
      else:
        contact_change["action"] = "new"
        contact_change["changes"] = ["Create new contact."]
        updater.AddInsert(NewContactEntry(contact, group=group))
 
      if contact_change["action"] == "none":
        no_change_contacts.append(contact_change)
      else:
        contact_changes.append(contact_change)
        if not preview_mode and updater.FlushBufferEmpty():
          # redirect for the next batch; new HTTP request
          # to get around App Engine long request deadlines.
          self.redirect('http://%s/gcontacts?key=%s&continue=1' %
                        (settings.HOST_NAME, key))
          return
 
 
    # Put the boring ones at bottom.
    n_changes = len(contact_changes)
    contact_changes.extend(no_change_contacts)
 
    render_google_list = False
    if render_google_list:
      out("<hr />")
      for entry in feed.entry:
        if entry.title and entry.title.text:
          out('<h3>Entry Title: %s</h3>' % (
              entry.title.text.decode('UTF-8')))
        else:
          out("<h3>(title-less entry)</h3>");
      
        for phone_number in entry.phone_number:
          out("<p><b>Phone: (%s)</b> %s</p>" %
                                (phone_number.rel, phone_number.text))
 
        for email in entry.email:
          out("<p><b>Email: (%s)</b> %s</p>" %
                                (email.rel, email.address))
 
        for group in entry.group_membership_info:
          out("<p><b>Group: (%s)</b> %s</p>" %
              (group.href, cgi.escape(str(group))))
 
    updater.Flush()
 
    self.WritePage(title, "google-merge.html", {
        "preview_mode": preview_mode,
        "body": "".join(body),
        "session_token": str(session_token),
        "key": key,
        "changes": contact_changes,
        "n_changes": n_changes,
        "working": working,
        })
    
 
class Acker(webapp.RequestHandler):
  """Simulates an HTML page to prove ownership of this domain for AuthSub
registration."""
 
  def get(self):
    self.response.headers['Content-Type'] = 'text/plain'
    self.response.out.write('This file present for AuthSub registration.')
 
 
def main():
  application = webapp.WSGIApplication([
    ('/', AddressBooker),
    ('/submit', Submit),
    ('/menu', Menu),
    ('/vcard', VCard),
    ('/gcontacts', MergeGoogle),
    ('/view', MergeView),
    ('/google72db3d6838b4c438.html', Acker),
    ], debug=True)
  wsgiref.handlers.CGIHandler().run(application)
 
 
if __name__ == '__main__':
  main()