PyXR

c:\projects\bitpim\src \ phonebook.py



0001 ### BITPIM
0002 ###
0003 ### Copyright (C) 2003-2004 Roger Binns <rogerb@rogerbinns.com>
0004 ### Copyright (C) 2004 Adit Panchal <apanchal@bastula.org>
0005 ###
0006 ### This program is free software; you can redistribute it and/or modify
0007 ### it under the terms of the BitPim license as detailed in the LICENSE file.
0008 ###
0009 ### $Id: phonebook.py 4639 2008-07-22 19:50:21Z djpham $
0010 
0011 """A widget for displaying/editting the phone information
0012 
0013 The format for a phonebook entry is standardised.  It is a
0014 dict with the following fields.  Each field is a list, most
0015 important first, with each item in the list being a dict.
0016 
0017 names:
0018 
0019    - title      ??Job title or salutation??
0020    - first
0021    - middle
0022    - last
0023    - full       You should specify the fullname or the 4 above
0024    - nickname   (This could also be an alias)
0025 
0026 categories:
0027 
0028   - category    User defined category name
0029   - ringtone    (optional) Ringtone name for this category
0030 
0031 emails:
0032 
0033   - email       Email address
0034   - type        (optional) 'home' or 'business'
0035   - speeddial   (optional) Speed dial for this entry
0036   - ringtone    (optional) ringtone name for this entry
0037   - wallpaper   (optional) wallpaper name for this entry
0038 
0039 maillist:
0040 
0041   - entry       string of '\x00\x00' separated of number or email entries.
0042   - speeddial   (optional) Speed dial for this entry
0043   - ringtone    (optional) ringtone name for this entry
0044   - wallpaper   (optional) wallpaper name for this entry
0045 
0046 urls:
0047 
0048   - url         URL
0049   - type        (optional) 'home' or 'business'
0050 
0051 ringtones:
0052 
0053   - ringtone    Name of a ringtone
0054   - use         'call', 'message'
0055 
0056 addresses:
0057 
0058   - type        'home' or 'business'
0059   - company     (only for type of 'business')
0060   - street      Street part of address
0061   - street2     Second line of street address
0062   - city
0063   - state
0064   - postalcode
0065   - country     Can also be the region
0066 
0067 wallpapers:
0068 
0069   - wallpaper   Name of wallpaper
0070   - use         see ringtones.use
0071 
0072 flags:
0073 
0074   - secret     Boolean if record is private/secret (if not present - value is false)
0075   - sim        Boolean if record should be stored on SIM card of GSM phones.
0076 
0077 memos:
0078 
0079   - memo       Note
0080 
0081 numbers:
0082 
0083   - number     Phone number as ascii string
0084   - type       'home', 'office', 'cell', 'fax', 'pager', 'data', 'none'  (if you have home2 etc, list
0085                them without the digits.  The second 'home' is implicitly home2 etc)
0086   - speeddial  (optional) Speed dial number
0087   - ringtone   (optional) ringtone name for this entry
0088   - wallpaper  (optional) wallpaper name for this entry
0089 
0090 serials:
0091 
0092   - sourcetype        identifies source driver in bitpim (eg "lgvx4400", "windowsaddressbook")
0093   - sourceuniqueid    (optional) identifier for where the serial came from (eg ESN of phone, wab host/username)
0094                       (imagine having multiple phones of the same model to see why this is needed)
0095   - *                 other names of use to sourcetype
0096 """
0097 
0098 # Standard imports
0099 from __future__ import with_statement
0100 import os
0101 import cStringIO
0102 import re
0103 import time
0104 import copy
0105 
0106 # GUI
0107 import wx
0108 import wx.grid
0109 import wx.html
0110 
0111 # My imports
0112 import common
0113 import xyaptu
0114 import guihelper
0115 import phonebookentryeditor
0116 import pubsub
0117 import nameparser
0118 import bphtml
0119 import guihelper
0120 import guiwidgets
0121 import phonenumber
0122 import helpids
0123 import database
0124 import widgets
0125 
0126 
0127 ###
0128 ###  The object we use to store a record.  See detailed description of
0129 ###  fields at top of file
0130 ###
0131 
0132 class phonebookdataobject(database.basedataobject):
0133     # no change to _knownproperties (all of ours are list properties)
0134     _knownlistproperties=database.basedataobject._knownlistproperties.copy()
0135     _knownlistproperties.update( {'names': ['title', 'first', 'middle', 'last', 'full', 'nickname'],
0136                                   'categories': ['category'],
0137                                   'emails': ['email', 'type', 'speeddial',
0138                                              'ringtone', 'wallpaper' ],
0139                                   'urls': ['url', 'type'],
0140                                   'ringtones': ['ringtone', 'use'],
0141                                   'addresses': ['type', 'company', 'street', 'street2', 'city', 'state', 'postalcode', 'country'],
0142                                   'wallpapers': ['wallpaper', 'use'],
0143                                   'flags': ['secret', 'sim'],
0144                                   'memos': ['memo'],
0145                                   'numbers': ['number', 'type', 'speeddial',
0146                                               'ringtone', 'wallpaper' ],
0147                                   'ice': [ 'iceindex' ],
0148 ##                                  'maillist': ['entry', 'speeddial',
0149 ##                                               'ringtone', 'wallpaper' ],
0150                                   # serials is in parent object
0151                                   })
0152 
0153 phonebookobjectfactory=database.dataobjectfactory(phonebookdataobject)
0154 
0155 ###
0156 ### Phonebook entry display (Derived from HTML)
0157 ###
0158 
0159 class PhoneEntryDetailsView(bphtml.HTMLWindow):
0160 
0161     def __init__(self, parent, id, stylesfile="styles.xy", layoutfile="pblayout.xy"):
0162         bphtml.HTMLWindow.__init__(self, parent, id)
0163         self.stylesfile=guihelper.getresourcefile(stylesfile)
0164         self.pblayoutfile=guihelper.getresourcefile(layoutfile)
0165         self.xcp=None
0166         self.xcpstyles=None
0167         self.ShowEntry({})
0168 
0169     def ShowEntry(self, entry):
0170         if self.xcp is None:
0171             template=open(self.pblayoutfile, "rt").read()
0172             self.xcp=xyaptu.xcopier(None)
0173             self.xcp.setupxcopy(template)
0174         if self.xcpstyles is None:
0175             self.xcpstyles={}
0176             try:
0177                 execfile(self.stylesfile,  self.xcpstyles, self.xcpstyles)
0178             except UnicodeError:
0179                 common.unicode_execfile(self.stylesfile,  self.xcpstyles, self.xcpstyles)
0180         self.xcpstyles['entry']=entry
0181         text=self.xcp.xcopywithdns(self.xcpstyles)
0182         try:
0183             text=bphtml.applyhtmlstyles(text, self.xcpstyles['styles'])
0184         except:
0185             if __debug__:
0186                 open("debug.html", "wt").write(common.forceascii(text))
0187             raise
0188         self.SetPage(text)
0189 
0190 ###
0191 ### Functions used to get data from a record
0192 ###
0193 
0194 
0195 def formatcategories(cats):
0196     c=[cat['category'] for cat in cats]
0197     c.sort()
0198     return "; ".join(c)
0199 
0200 def formataddress(address):
0201     l=[]
0202     for i in 'company', 'street', 'street2', 'city', 'state', 'postalcode', 'country':
0203         if i in address:
0204             l.append(address[i])
0205     return "; ".join(l)
0206 
0207 def formattypenumber(number):
0208     t=number['type']
0209     t=t[0].upper()+t[1:]
0210     sd=number.get("speeddial", None)
0211     if sd is None:
0212         return "%s (%s)" % (phonenumber.format(number['number']), t)
0213     return "%s [%d] (%s)" % (phonenumber.format(number['number']), sd, t)
0214 
0215 def formatnumber(number):
0216     sd=number.get("speeddial", None)
0217     if sd is None:
0218         return phonenumber.format(number['number'])
0219     return "%s [%d]" % (phonenumber.format(number['number']), sd)
0220 
0221 def formatstorage(flag):
0222     return 'SIM' if flag.get('sim', False) else ''
0223 
0224 def formatsecret(flag):
0225     return 'True' if flag.get('secret', False) else ''
0226 
0227 # this is specified here as a list so that we can get the
0228 # keys in the order below for the settings UI (alpha sorting
0229 # or dictionary order would be user hostile).  The data
0230 # is converted to a dict below
0231 _getdatalist=[
0232     # column   (key matchnum   match   func_or_field  showinimport)
0233     'Name', ("names", 0, None, nameparser.formatfullname, True),
0234     'First', ("names", 0, None, nameparser.getfirst, False),
0235     'Middle', ("names", 0, None, nameparser.getmiddle, False),
0236     'Last', ("names", 0, None, nameparser.getlast, False),
0237 
0238     'Category', ("categories", 0,  None, "category", False),
0239     'Category2', ("categories", 1,  None, "category", False),
0240     'Category3', ("categories", 2,  None, "category", False),
0241     'Category4', ("categories", 3,  None, "category", False),
0242     'Category5', ("categories", 4,  None, "category", False),
0243     'Categories', ("categories", None, None, formatcategories, True),
0244 
0245     "Phone", ("numbers", 0, None, formattypenumber, False),
0246     "Phone2", ("numbers", 1, None, formattypenumber, False),
0247     "Phone3", ("numbers", 2, None, formattypenumber, False),
0248     "Phone4", ("numbers", 3, None, formattypenumber, False),
0249     "Phone5", ("numbers", 4, None, formattypenumber, False),
0250     "Phone6", ("numbers", 5, None, formattypenumber, False),
0251     "Phone7", ("numbers", 6, None, formattypenumber, False),
0252     "Phone8", ("numbers", 7, None, formattypenumber, False),
0253     "Phone9", ("numbers", 8, None, formattypenumber, False),
0254     "Phone10", ("numbers", 9, None, formattypenumber, False),
0255     
0256     # phone numbers are inserted here
0257 
0258     'Email', ("emails", 0, None, "email", True),
0259     'Email2', ("emails", 1, None, "email", True),
0260     'Email3', ("emails", 2, None, "email", True),
0261     'Email4', ("emails", 3, None, "email", True),
0262     'Email5', ("emails", 4, None, "email", True),
0263     'Business Email', ("emails", 0, ("type", "business"), "email", False),
0264     'Business Email2', ("emails", 1, ("type", "business"), "email", False),
0265     'Home Email', ("emails", 0, ("type", "home"), "email", False),
0266     'Home Email2', ("emails", 1, ("type", "home"), "email", False),
0267 
0268     'URL', ("urls", 0, None, "url", True),
0269     'URL2', ("urls", 1, None, "url", True),
0270     'URL3', ("urls", 2, None, "url", True),
0271     'URL4', ("urls", 3, None, "url", True),
0272     'URL5', ("urls", 4, None, "url", True),
0273     'Business URL', ("urls", 0, ("type", "business"), "url", False),
0274     'Business URL2', ("urls", 1, ("type", "business"), "url", False),
0275     'Home URL', ("urls", 0, ("type", "home"), "url", False),
0276     'Home URL2', ("urls", 1, ("type", "home"), "url", False),
0277 
0278     'Ringtone', ("ringtones", 0, ("use", "call"), "ringtone", True),
0279     'Message Ringtone', ("ringtones", 0, ("use", "message"), "ringtone", True),
0280 
0281     'Address', ("addresses", 0, None, formataddress, True),
0282     'Address2', ("addresses", 1, None, formataddress, True),
0283     'Address3', ("addresses", 2, None, formataddress, True),
0284     'Address4', ("addresses", 3, None, formataddress, True),
0285     'Address5', ("addresses", 4, None, formataddress, True),
0286     'Home Address', ("addresses", 0, ("type", "home"), formataddress, False),
0287     'Home Address2', ("addresses", 1, ("type", "home"), formataddress, False),
0288     'Business Address', ("addressess", 0, ("type", "business"), formataddress, False),
0289     'Business Address2', ("addressess", 1, ("type", "business"), formataddress, False),
0290 
0291     "Wallpaper", ("wallpapers", 0, None, "wallpaper", True),
0292 
0293     "Secret", ("flags", 0, ("secret", True), formatsecret, True),
0294     "Storage", ("flags", 0,('sim', True), formatstorage, True),
0295     "Memo", ("memos", 0, None, "memo", True),
0296     "Memo2", ("memos", 1, None, "memo", True),
0297     "Memo3", ("memos", 2, None, "memo", True),
0298     "Memo4", ("memos", 3, None, "memo", True),
0299     "Memo5", ("memos", 4, None, "memo", True),
0300 
0301     "ICEindex", ("ice", 0, None, "iceindex", False),
0302 
0303     ]
0304 
0305 ll=[]
0306 for pretty, actual in ("Home", "home"), ("Office", "office"), ("Cell", "cell"), ("Fax", "fax"), ("Pager", "pager"), ("Data", "data"):
0307     for suf,n in ("", 0), ("2", 1), ("3", 2):
0308         ll.append(pretty+suf)
0309         ll.append(("numbers", n, ("type", actual), formatnumber, True))
0310 _getdatalist[40:40]=ll
0311 
0312 _getdatatable={}
0313 AvailableColumns=[]
0314 DefaultColumns=['Name', 'Phone', 'Phone2', 'Phone3', 'Email', 'Categories', 'Memo', 'Secret']
0315 ImportColumns=[_getdatalist[x*2] for x in range(len(_getdatalist)/2) if _getdatalist[x*2+1][4]]
0316 
0317 for n in range(len(_getdatalist)/2):
0318     AvailableColumns.append(_getdatalist[n*2])
0319     _getdatatable[_getdatalist[n*2]]=_getdatalist[n*2+1]
0320 
0321 del _getdatalist  # so we don't accidentally use it
0322 
0323 def getdata(column, entry, default=None):
0324     """Returns the value in a particular column.
0325     Note that the data is appropriately formatted.
0326 
0327     @param column: column name
0328     @param entry: the dict representing a phonebook entry
0329     @param default: what to return if the entry has no data for that column
0330     """
0331     key, count, prereq, formatter, _ =_getdatatable[column]
0332 
0333     # do we even have that key
0334     if key not in entry:
0335         return default
0336 
0337     if count is None:
0338         # value is all the fields (eg Categories)
0339         thevalue=entry[key]
0340     elif prereq is None:
0341         # no prereq
0342         if len(entry[key])<=count:
0343             return default
0344         thevalue=entry[key][count]
0345     else:
0346         # find the count instance of value matching k,v in prereq
0347         ptr=0
0348         togo=count+1
0349         l=entry[key]
0350         k,v=prereq
0351         while togo:
0352             if ptr==len(l):
0353                 return default
0354             if k not in l[ptr]:
0355                 ptr+=1
0356                 continue
0357             if l[ptr][k]!=v:
0358                 ptr+=1
0359                 continue
0360             togo-=1
0361             if togo!=0:
0362                 ptr+=1
0363                 continue
0364             thevalue=entry[key][ptr]
0365             break
0366 
0367     # thevalue now contains the dict with value we care about
0368     if callable(formatter):
0369         return formatter(thevalue)
0370 
0371     return thevalue.get(formatter, default)
0372 
0373 def getdatainfo(column, entry):
0374     """Similar to L{getdata} except returning higher level information.
0375 
0376     Returns the key name and which index from the list corresponds to
0377     the column.
0378 
0379     @param column: Column name
0380     @param entry: The dict representing a phonebook entry
0381     @returns: (keyname, index) tuple.  index will be None if the entry doesn't
0382          have the relevant column value and -1 if all of them apply
0383     """
0384     key, count, prereq, formatter, _ =_getdatatable[column]
0385 
0386     # do we even have that key
0387     if key not in entry:
0388         return (key, None)
0389 
0390     # which value or values do we want
0391     if count is None:
0392         return (key, -1)
0393     elif prereq is None:
0394         # no prereq
0395         if len(entry[key])<=count:
0396             return (key, None)
0397         return (key, count)
0398     else:
0399         # find the count instance of value matching k,v in prereq
0400         ptr=0
0401         togo=count+1
0402         l=entry[key]
0403         k,v=prereq
0404         while togo:
0405             if ptr==len(l):
0406                 return (key,None)
0407             if k not in l[ptr]:
0408                 ptr+=1
0409                 continue
0410             if l[ptr][k]!=v:
0411                 ptr+=1
0412                 continue
0413             togo-=1
0414             if togo!=0:
0415                 ptr+=1
0416                 continue
0417             return (key, ptr)
0418     return (key, None)
0419     
0420 class CategoryManager:
0421 
0422     # this is only used to prevent the pubsub module
0423     # from being GC while any instance of this class exists
0424     __publisher=pubsub.Publisher
0425 
0426     def __init__(self):
0427         self.categories=[]
0428         pubsub.subscribe(self.OnListRequest, pubsub.REQUEST_CATEGORIES)
0429         pubsub.subscribe(self.OnSetCategories, pubsub.SET_CATEGORIES)
0430         pubsub.subscribe(self.OnMergeCategories, pubsub.MERGE_CATEGORIES)
0431         pubsub.subscribe(self.OnAddCategory, pubsub.ADD_CATEGORY)
0432 
0433     def OnListRequest(self, msg=None):
0434         # nb we publish a copy of the list, not the real
0435         # thing.  otherwise other code inadvertently modifies it!
0436         pubsub.publish(pubsub.ALL_CATEGORIES, self.categories[:])
0437 
0438     def OnAddCategory(self, msg):
0439         name=msg.data
0440         if name in self.categories:
0441             return
0442         self.categories.append(name)
0443         self.categories.sort()
0444         self.OnListRequest()
0445 
0446     def OnSetCategories(self, msg):
0447         cats=msg.data[:]
0448         self.categories=cats
0449         self.categories.sort()
0450         self.OnListRequest()
0451 
0452     def OnMergeCategories(self, msg):
0453         cats=msg.data[:]
0454         newcats=self.categories[:]
0455         for i in cats:
0456             if i not in newcats:
0457                 newcats.append(i)
0458         newcats.sort()
0459         if newcats!=self.categories:
0460             self.categories=newcats
0461             self.OnListRequest()
0462 
0463 CategoryManager=CategoryManager() # shadow out class name
0464 
0465 ###
0466 ### We use a table for speed
0467 ###
0468 
0469 class PhoneDataTable(wx.grid.PyGridTableBase):
0470 
0471     def __init__(self, widget, columns):
0472         self.main=widget
0473         self.rowkeys=self.main._data.keys()
0474         wx.grid.PyGridTableBase.__init__(self)
0475         self.oddattr=wx.grid.GridCellAttr()
0476         self.oddattr.SetBackgroundColour("OLDLACE")
0477         self.evenattr=wx.grid.GridCellAttr()
0478         self.evenattr.SetBackgroundColour("ALICE BLUE")
0479         self.columns=columns
0480         assert len(self.rowkeys)==0  # we can't sort here, and it isn't necessary because list is zero length
0481 
0482     def GetColLabelValue(self, col):
0483         return self.columns[col]
0484 
0485     def OnDataUpdated(self):
0486         newkeys=self.main._data.keys()
0487         newkeys.sort()
0488         oldrows=self.rowkeys
0489         self.rowkeys=newkeys
0490         lo=len(oldrows)
0491         ln=len(self.rowkeys)
0492         if ln>lo:
0493             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_ROWS_APPENDED, ln-lo)
0494         elif lo>ln:
0495             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_ROWS_DELETED, 0, lo-ln)
0496         else:
0497             msg=None
0498         if msg is not None:
0499             self.GetView().ProcessTableMessage(msg)
0500         self.Sort()
0501         msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_REQUEST_VIEW_GET_VALUES)
0502         self.GetView().ProcessTableMessage(msg)
0503         self.GetView().AutoSizeColumns()
0504 
0505     def SetColumns(self, columns):
0506         oldcols=self.columns
0507         self.columns=columns
0508         lo=len(oldcols)
0509         ln=len(self.columns)
0510         if ln>lo:
0511             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_COLS_APPENDED, ln-lo)
0512         elif lo>ln:
0513             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_COLS_DELETED, 0, lo-ln)
0514         else:
0515             msg=None
0516         if msg is not None:
0517             self.GetView().ProcessTableMessage(msg)
0518         msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_REQUEST_VIEW_GET_VALUES)
0519         self.GetView().ProcessTableMessage(msg)
0520         self.GetView().AutoSizeColumns()
0521 
0522     def Sort(self):
0523         bycol=self.main.sortedColumn
0524         descending=self.main.sortedColumnDescending
0525         ### ::TODO:: this sorting is not stable - it should include the current pos rather than key
0526         l=[ (getdata(self.columns[bycol], self.main._data[key]), key) for key in self.rowkeys]
0527         l.sort()
0528         if descending:
0529             l.reverse()
0530         self.rowkeys=[key for val,key in l]
0531         msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_REQUEST_VIEW_GET_VALUES)
0532         self.GetView().ProcessTableMessage(msg)
0533 
0534     def IsEmptyCell(self, row, col):
0535         return False
0536 
0537     def GetNumberRows(self):
0538         return len(self.rowkeys)
0539 
0540     def GetNumberCols(self):
0541         return len(self.columns)
0542 
0543     def GetValue(self, row, col):
0544         try:
0545             entry=self.main._data[self.rowkeys[row]]
0546         except:
0547             print "bad row", row
0548             return "<error>"
0549 
0550         return getdata(self.columns[col], entry, "")
0551 
0552     def GetAttr(self, row, col, _):
0553         r=[self.evenattr, self.oddattr][row%2]
0554         r.IncRef()
0555         return r
0556 
0557 class PhoneWidget(wx.Panel, widgets.BitPimWidget):
0558     """Main phone editing/displaying widget"""
0559     CURRENTFILEVERSION=2
0560     # Data selector const
0561     _Current_Data=0
0562     _Historic_Data=1
0563 
0564     def __init__(self, mainwindow, parent, config):
0565         wx.Panel.__init__(self, parent,-1)
0566         self.sash_pos=config.ReadInt('phonebooksashpos', -300)
0567         self.update_sash=False
0568         # keep this around while we exist
0569         self.categorymanager=CategoryManager
0570         split=wx.SplitterWindow(self, -1, style=wx.SP_3D|wx.SP_LIVE_UPDATE)
0571         split.SetMinimumPaneSize(20)
0572         self.mainwindow=mainwindow
0573         self._data={}
0574         self.parent=parent
0575         self.categories=[]
0576         self.modified=False
0577         self.table_panel=wx.Panel(split)
0578         self.table=wx.grid.Grid(self.table_panel, wx.NewId())
0579         self.table.EnableGridLines(False)
0580         self.error_log=guihelper.MultiMessageBox(self.mainwindow , "Contact Export Errors", 
0581                    "Bitpim is unable to send the following data to your phone")
0582         # which columns?
0583         cur=config.Read("phonebookcolumns", "")
0584         if len(cur):
0585             cur=cur.split(",")
0586             # ensure they all exist
0587             cur=[c for c in cur if c in AvailableColumns]
0588         else:
0589             cur=DefaultColumns
0590         # column sorter info
0591         self.sortedColumn=0
0592         self.sortedColumnDescending=False
0593 
0594         self.dt=PhoneDataTable(self, cur)
0595         self.table.SetTable(self.dt, False, wx.grid.Grid.wxGridSelectRows)
0596         self.table.SetSelectionMode(wx.grid.Grid.wxGridSelectRows)
0597         self.table.SetRowLabelSize(0)
0598         self.table.EnableEditing(False)
0599         self.table.EnableDragRowSize(False)
0600         self.table.SetMargins(1,0)
0601         # data date adjuster
0602         hbs=wx.BoxSizer(wx.HORIZONTAL)
0603         self.read_only=False
0604         self.historical_date=None
0605         static_bs=wx.StaticBoxSizer(wx.StaticBox(self.table_panel, -1,
0606                                                  'Historical Data Status:'),
0607                                     wx.VERTICAL)
0608         self.historical_data_label=wx.StaticText(self.table_panel, -1,
0609                                                  'Current Data')
0610         static_bs.Add(self.historical_data_label, 1, wx.EXPAND|wx.ALL, 5)
0611         hbs.Add(static_bs, 1, wx.EXPAND|wx.ALL, 5)
0612         # show the number of contacts
0613         static_bs=wx.StaticBoxSizer(wx.StaticBox(self.table_panel, -1,
0614                                                  'Number of Contacts:'),
0615                                     wx.VERTICAL)
0616         self.contactcount_label=wx.StaticText(self.table_panel, -1, '0')
0617         static_bs.Add(self.contactcount_label, 1, wx.EXPAND|wx.ALL, 5)
0618         hbs.Add(static_bs, 1, wx.EXPAND|wx.ALL, 5)
0619         # main sizer
0620         vbs=wx.BoxSizer(wx.VERTICAL)
0621         vbs.Add(hbs, 0, wx.EXPAND|wx.ALL, 5)
0622         vbs.Add(self.table, 1, wx.EXPAND, 0)
0623         self.table_panel.SetSizer(vbs)
0624         self.table_panel.SetAutoLayout(True)
0625         vbs.Fit(self.table_panel)
0626         self.preview=PhoneEntryDetailsView(split, -1, "styles.xy", "pblayout.xy")
0627         # for some reason, preview doesn't show initial background
0628         wx.CallAfter(self.preview.ShowEntry, {})
0629         split.SplitVertically(self.table_panel, self.preview, self.sash_pos)
0630         self.split=split
0631         bs=wx.BoxSizer(wx.VERTICAL)
0632         bs.Add(split, 1, wx.EXPAND)
0633         self.SetSizer(bs)
0634         self.SetAutoLayout(True)
0635         wx.EVT_IDLE(self, self.OnIdle)
0636         wx.grid.EVT_GRID_SELECT_CELL(self, self.OnCellSelect)
0637         wx.grid.EVT_GRID_CELL_LEFT_DCLICK(self, self.OnCellDClick)
0638         wx.grid.EVT_GRID_CELL_RIGHT_CLICK(self, self.OnCellRightClick)
0639         wx.EVT_LEFT_DCLICK(self.preview, self.OnPreviewDClick)
0640         pubsub.subscribe(self.OnCategoriesUpdate, pubsub.ALL_CATEGORIES)
0641         pubsub.subscribe(self.OnPBLookup, pubsub.REQUEST_PB_LOOKUP)
0642         pubsub.subscribe(self.OnMediaNameChanged, pubsub.MEDIA_NAME_CHANGED)
0643         # we draw the column headers
0644         # code based on original implementation by Paul Mcnett
0645         wx.EVT_PAINT(self.table.GetGridColLabelWindow(), self.OnColumnHeaderPaint)
0646         wx.grid.EVT_GRID_LABEL_LEFT_CLICK(self.table, self.OnGridLabelLeftClick)
0647         wx.grid.EVT_GRID_LABEL_LEFT_DCLICK(self.table, self.OnGridLabelLeftClick)
0648         wx.EVT_SPLITTER_SASH_POS_CHANGED(self, self.split.GetId(),
0649                                          self.OnSashPosChanged)
0650         # context menu
0651         self.context_menu=wx.Menu()
0652         id=wx.NewId()
0653         self.context_menu.Append(id, 'Set to current',
0654                                  'Set the selected item to current data')
0655         wx.EVT_MENU(self, id, self.OnSetToCurrent)
0656 
0657     def OnInit(self):
0658         # whether or not to turn on phonebook preview pane
0659         if not self.config.ReadInt("viewphonebookpreview", 1):
0660             self.OnViewPreview(False)
0661 
0662     def OnColumnHeaderPaint(self, evt):
0663         w = self.table.GetGridColLabelWindow()
0664         dc = wx.PaintDC(w)
0665         font = dc.GetFont()
0666         dc.SetTextForeground(wx.BLACK)
0667         
0668         # For each column, draw it's rectangle, it's column name,
0669         # and it's sort indicator, if appropriate:
0670         totColSize = -self.table.GetViewStart()[0]*self.table.GetScrollPixelsPerUnit()[0]
0671         for col in range(self.table.GetNumberCols()):
0672             dc.SetBrush(wx.Brush("WHEAT", wx.TRANSPARENT))
0673             colSize = self.table.GetColSize(col)
0674             rect = (totColSize,0,colSize,32)
0675             dc.DrawRectangle(rect[0] - (col!=0 and 1 or 0), rect[1], rect[2] + (col!=0 and 1 or 0), rect[3])
0676             totColSize += colSize
0677             
0678             if col == self.sortedColumn:
0679                 font.SetWeight(wx.BOLD)
0680                 # draw a triangle, pointed up or down, at the
0681                 # top left of the column.
0682                 left = rect[0] + 3
0683                 top = rect[1] + 3
0684                 
0685                 dc.SetBrush(wx.Brush("WHEAT", wx.SOLID))
0686                 if self.sortedColumnDescending:
0687                     dc.DrawPolygon([(left,top), (left+6,top), (left+3,top+4)])
0688                 else:
0689                     dc.DrawPolygon([(left+3,top), (left+6, top+4), (left, top+4)])
0690             else:
0691                 font.SetWeight(wx.NORMAL)
0692 
0693             dc.SetFont(font)
0694             dc.DrawLabel("%s" % self.table.GetTable().columns[col],
0695                      rect, wx.ALIGN_CENTER | wx.ALIGN_TOP)
0696 
0697 
0698     def OnGridLabelLeftClick(self, evt):
0699         col=evt.GetCol()
0700         if col==self.sortedColumn:
0701             self.sortedColumnDescending=not self.sortedColumnDescending
0702         else:
0703             self.sortedColumn=col
0704             self.sortedColumnDescending=False
0705         self.dt.Sort()
0706         self.table.Refresh()
0707 
0708     def OnSashPosChanged(self, _):
0709         if self.update_sash:
0710             self.sash_pos=self.split.GetSashPosition()
0711             self.config.WriteInt('phonebooksashpos', self.sash_pos)
0712     def OnPreActivate(self):
0713         self.update_sash=False
0714     def OnPostActivate(self):
0715         self.split.SetSashPosition(self.sash_pos)
0716         self.update_sash=True
0717 
0718     def SetColumns(self, columns):
0719         c=self.GetColumns()[self.sortedColumn]
0720         self.dt.SetColumns(columns)
0721         if c in columns:
0722             self.sortedColumn=columns.index(c)
0723         else:
0724             self.sortedColumn=0
0725             self.sortedColumnDescending=False
0726         self.dt.Sort()
0727         self.table.Refresh()
0728 
0729     def GetColumns(self):
0730         return self.dt.columns
0731 
0732     def OnCategoriesUpdate(self, msg):
0733         if self.categories!=msg.data:
0734             self.categories=msg.data[:]
0735             self.modified=True
0736 
0737     def OnPBLookup(self, msg):
0738         d=msg.data
0739         s=d.get('item', '')
0740         if not len(s):
0741             return
0742         d['name']=None
0743         for k,e in self._data.items():
0744             for n in e.get('numbers', []):
0745                 if s==n.get('number', None):
0746                     # found a number, stop and reply
0747                     d['name']=nameparser.getfullname(e['names'][0])+'('+\
0748                                n.get('type', '')+')'
0749                     pubsub.publish(pubsub.RESPONSE_PB_LOOKUP, d)
0750                     return
0751             for n in e.get('emails', []):
0752                 if s==n.get('email', None):
0753                     # found an email, stop and reply
0754                     d['name']=nameparser.getfullname(e['names'][0])+'(email)'
0755                     pubsub.publish(pubsub.RESPONSE_PB_LOOKUP, d)
0756                     return
0757         # done and reply
0758         pubsub.publish(pubsub.RESPONSE_PB_LOOKUP, d)
0759 
0760     def OnMediaNameChanged(self, msg):
0761         d=msg.data
0762         _type=d.get(pubsub.media_change_type, None)
0763         _old_name=d.get(pubsub.media_old_name, None)
0764         _new_name=d.get(pubsub.media_new_name, None)
0765         if _type is None or _old_name is None or _new_name is None:
0766             # invalid/incomplete data
0767             return
0768         if _type!=pubsub.wallpaper_type and \
0769            _type!=pubsub.ringtone_type:
0770             # neither wallpaper nor ringtone
0771             return
0772         _old_name=common.basename(_old_name)
0773         _new_name=common.basename(_new_name)
0774         if _type==pubsub.wallpaper_type:
0775             main_key='wallpapers'
0776             element_key='wallpaper'
0777         else:
0778             main_key='ringtones'
0779             element_key='ringtone'
0780         for k,e in self._data.items():
0781             for i,n in enumerate(e.get(main_key, [])):
0782                 if _old_name==n.get(element_key, None):
0783                     # found it, update the name
0784                     self._data[k][main_key][i][element_key]=_new_name
0785                     self.modified=True
0786 
0787     def HasColumnSelector(self):
0788         return True
0789 
0790     def OnViewColumnSelector(self):
0791         with guihelper.WXDialogWrapper(ColumnSelectorDialog(self.parent, self.config, self),
0792                                        True):
0793             pass
0794 
0795     def HasPreviewPane(self):
0796         return True
0797 
0798     def IsPreviewPaneEnabled(self):
0799         return self.split.IsSplit()
0800     
0801     def OnViewPreview(self, preview_on):
0802         if preview_on:
0803             self.split.SplitVertically(self.table_panel, self.preview,
0804                                        self.sash_pos)
0805         else:
0806             if self.sash_pos is None:
0807                 self.sash_pos=-300
0808             else:
0809                 self.sash_pos=self.split.GetSashPosition()
0810             self.split.Unsplit(self.preview)
0811         # refresh the table view
0812         self.config.WriteInt('viewphonebookpreview', preview_on)
0813         self.dt.GetView().AutoSizeColumns()
0814 
0815     def HasHistoricalData(self):
0816         return True
0817 
0818     def OnHistoricalData(self):
0819         """Display current or historical data"""
0820         if self.read_only:
0821             current_choice=guiwidgets.HistoricalDataDialog.Historical_Data
0822         else:
0823             current_choice=guiwidgets.HistoricalDataDialog.Current_Data
0824         with guihelper.WXDialogWrapper(guiwidgets.HistoricalDataDialog(self,
0825                                                                        current_choice=current_choice,
0826                                                                        historical_date=self.historical_date,
0827                                                                        historical_events=\
0828                                                                        self.mainwindow.database.getchangescount('phonebook')),
0829                                            True) as (dlg, retcode):
0830             if retcode==wx.ID_OK:
0831                 with guihelper.MWBusyWrapper(self.mainwindow):
0832                     current_choice, self.historical_date=dlg.GetValue()
0833                     r={}
0834                     if current_choice==guiwidgets.HistoricalDataDialog.Current_Data:
0835                         self.read_only=False
0836                         msg_str='Current Data'
0837                         self.getfromfs(r)
0838                     else:
0839                         self.read_only=True
0840                         msg_str='Historical Data as of %s'%\
0841                                  str(wx.DateTimeFromTimeT(self.historical_date))
0842                         self.getfromfs(r, self.historical_date)
0843                     self.populate(r, False)
0844                     self.historical_data_label.SetLabel(msg_str)
0845 
0846     def OnIdle(self, _):
0847         "We save out changed data"
0848         if self.modified:
0849             self.modified=False
0850             self.populatefs(self.getdata({}))
0851 
0852     def updateserials(self, results):
0853         "update the serial numbers after having written to the phone"
0854         if not results.has_key('serialupdates'):
0855             return
0856 
0857         # each item is a tuple.  bpserial is the bitpim serialid,
0858         # and updserial is what to update with.
0859         for bpserial,updserial in results['serialupdates']:
0860             # find the entry with bpserial
0861             for k in self._data:
0862                 entry=self._data[k]
0863                 if not entry.has_key('serials'):
0864                     assert False, "serials have gone horribly wrong"
0865                     continue
0866                 found=False
0867                 for serial in entry['serials']:
0868                     if bpserial==serial:
0869                         found=True
0870                         break
0871                 if not found:
0872                     # not this entry
0873                     continue
0874                 # we will be updating this entry
0875                 # see if there is a matching serial for updserial that we will update
0876                 st=updserial['sourcetype']
0877                 remove=None
0878                 for serial in entry['serials']:
0879                     if serial['sourcetype']!=st:
0880                         continue
0881                     if updserial.has_key("sourceuniqueid"):
0882                         if updserial["sourceuniqueid"]!=serial.get("sourceuniqueid", None):
0883                             continue
0884                     remove=serial
0885                     break
0886                 # remove if needbe
0887                 if remove is not None:
0888                     for count,serial in enumerate(entry['serials']):
0889                         if remove==serial:
0890                             break
0891                     del entry['serials'][count]
0892                 # add update on end
0893                 entry['serials'].append(updserial)
0894         self.modified=True
0895                     
0896     def CanSelectAll(self):
0897         return True
0898 
0899     def OnSelectAll(self, _):
0900         self.table.SelectAll()
0901 
0902     def OnCellSelect(self, event):
0903         event.Skip()
0904         row=event.GetRow()
0905         self.SetPreview(self._data[self.dt.rowkeys[row]]) # bad breaking of abstraction referencing dt!
0906 
0907     def OnPreviewDClick(self, _):
0908         self.EditEntries(self.table.GetGridCursorRow(), self.table.GetGridCursorCol())
0909 
0910     def OnCellDClick(self, event):
0911         self.EditEntries(event.GetRow(), event.GetCol())
0912 
0913     def OnCellRightClick(self, evt):
0914         if not self.read_only or not self.GetSelectedRowKeys():
0915             return
0916         self.table.PopupMenu(self.context_menu, evt.GetPosition())
0917 
0918     def OnSetToCurrent(self, _):
0919         r={}
0920         for k in self.GetSelectedRowKeys():
0921             r[k]=self._data[k]
0922         if r:
0923             dict={}
0924             self.getfromfs(dict)
0925             dict['phonebook'].update(r)
0926             c=[e for e in self.categories if e not in dict['categories']]
0927             dict['categories']+=c
0928             self._save_db(dict)
0929 
0930     def EditEntries(self, row, column):
0931         # Allow moving to next/prev entries
0932         key=self.dt.rowkeys[row]
0933         data=self._data[key]
0934         # can we get it to open on the correct field?
0935         datakey,dataindex=getdatainfo(self.GetColumns()[column], data)
0936         _keys=self.GetSelectedRowKeys()
0937         if datakey in ('categories', 'ringtones', 'wallpapers') and \
0938            len(_keys)>1 and not self.read_only:
0939             # Edit a single field for all seleced cells
0940             with guihelper.WXDialogWrapper(phonebookentryeditor.SingleFieldEditor(self, datakey),
0941                                            True) as (dlg, retcode):
0942                 if retcode==wx.ID_OK:
0943                     _data=dlg.GetData()
0944                     if _data:
0945                         for r in _keys:
0946                             self._data[r][datakey]=_data
0947                     else:
0948                         for r in _keys:
0949                             del self._data[r][datakey]
0950                     self.SetPreview(self._data[_keys[0]])
0951                     self.dt.OnDataUpdated()
0952                     self.modified=True
0953         else:
0954             with guihelper.WXDialogWrapper(phonebookentryeditor.Editor(self, data,
0955                                                                        factory=phonebookobjectfactory,
0956                                                                        keytoopenon=datakey,
0957                                                                        dataindex=dataindex,
0958                                                                        readonly=self.read_only,
0959                                                                        datakey=key,
0960                                                                        movement=True),
0961                                            True) as (dlg, retcode):
0962                 if retcode==wx.ID_OK:
0963                     self.SaveData(dlg.GetData(), dlg.GetDataKey())
0964 
0965     def SaveData(self, data, key):
0966         self._data[key]=data
0967         self.dt.OnDataUpdated()
0968         self.SetPreview(data)
0969         self.modified=True
0970         
0971     def EditEntry(self, row, column):
0972         key=self.dt.rowkeys[row]
0973         data=self._data[key]
0974         # can we get it to open on the correct field?
0975         datakey,dataindex=getdatainfo(self.GetColumns()[column], data)
0976         with guihelper.WXDialogWrapper(phonebookentryeditor.Editor(self, data,
0977                                                                    factory=phonebookobjectfactory,
0978                                                                    keytoopenon=datakey,
0979                                                                    dataindex=dataindex,
0980                                                                    readonly=self.read_only),
0981                                        True) as (dlg, retcode):
0982             if retcode==wx.ID_OK:
0983                 data=dlg.GetData()
0984                 self._data[key]=data
0985                 self.dt.OnDataUpdated()
0986                 self.SetPreview(data)
0987                 self.modified=True
0988 
0989     def GetNextEntry(self, next=True):
0990         # return the data for the next item on the list
0991         _sel_rows=self.GetSelectedRows()
0992         if not _sel_rows:
0993             return None
0994         try:
0995             row=_sel_rows[0]
0996             if next:
0997                 _new_row=row+1
0998             else:
0999                 _new_row=row-1
1000             _num_rows=self.table.GetNumberRows()
1001             if _new_row>=_num_rows:
1002                 _new_row=0
1003             elif _new_row<0:
1004                 _new_row=_num_rows-1
1005             self.table.SetGridCursor(_new_row, self.table.GetGridCursorCol())
1006             self.table.SelectRow(_new_row)
1007             _key=self.dt.rowkeys[_new_row]
1008             return (_key,self._data[_key])
1009         except:
1010             if __debug__:
1011                 raise
1012             return None
1013 
1014     def GetDeleteInfo(self):
1015         return guihelper.ART_DEL_CONTACT, "Delete Contact"
1016 
1017     def GetAddInfo(self):
1018         return guihelper.ART_ADD_CONTACT, "Add Contact"
1019 
1020     def CanAdd(self):
1021         if self.read_only:
1022             return False
1023         return True
1024 
1025     def OnAdd(self, _):
1026         if self.read_only:
1027             return
1028         with guihelper.WXDialogWrapper(phonebookentryeditor.Editor(self, {'names': [{'full': 'New Entry'}]}, keytoopenon="names", dataindex=0),
1029                                        True) as (dlg, retcode):
1030             if retcode==wx.ID_OK:
1031                 data=phonebookobjectfactory.newdataobject(dlg.GetData())
1032                 data.EnsureBitPimSerial()
1033                 while True:
1034                     key=int(time.time())
1035                     if key in self._data:
1036                         continue
1037                     break
1038                 self._data[key]=data
1039                 self.dt.OnDataUpdated()
1040                 self.SetPreview(data)
1041                 self.modified=True
1042 
1043     def GetSelectedRows(self):
1044         rows=[]
1045         # if there is no data, there can't be any selected rows
1046         if len(self._data)==0:
1047             return rows
1048         gcr=self.table.GetGridCursorRow()
1049         set1=self.table.GetSelectionBlockTopLeft()
1050         set2=self.table.GetSelectionBlockBottomRight()
1051         if len(set1):
1052             assert len(set1)==len(set2)
1053             for i in range(len(set1)):
1054                 for row in range(set1[i][0], set2[i][0]+1): # range in wx is inclusive of last element
1055                     if row not in rows:
1056                         rows.append(row)
1057         else:
1058             if gcr>=0:
1059                 rows.append(gcr)
1060 
1061         return rows
1062 
1063     def GetSelectedRowKeys(self):
1064         return [self.dt.rowkeys[r] for r in self.GetSelectedRows()]
1065 
1066     def CanDelete(self):
1067         if self.read_only:
1068             return False
1069         # there always seems to be something selected in the phonebook, so 
1070         # there is no point testing for number of items, it just burns cycles
1071         return True
1072 
1073     def OnDelete(self,_):
1074         if self.read_only:
1075             return
1076         for r in self.GetSelectedRowKeys():
1077             del self._data[r]
1078         self.table.ClearSelection()
1079         self.dt.OnDataUpdated()
1080         self.modified=True
1081 
1082     def SetPreview(self, entry):
1083         self.preview.ShowEntry(entry)
1084 
1085     def CanPrint(self):
1086         return True
1087 
1088     def OnPrintDialog(self, mainwindow, config):
1089         with guihelper.WXDialogWrapper(PhonebookPrintDialog(self, mainwindow, config),
1090                                        True):
1091             pass
1092 
1093     def getdata(self, dict):
1094         dict['phonebook']=self._data.copy()
1095         dict['categories']=self.categories[:]
1096         return dict
1097 
1098     def DeleteBySerial(self, bpserial):
1099         for k in self._data:
1100             entry=self._data[k]
1101             for serial in entry['serials']:
1102                 if serial==bpserial:
1103                     del self._data[k]
1104                     self.dt.OnDataUpdated()
1105                     self.modified=True
1106                     return
1107         raise ValueError("No such entry with serial "+`bpserial`)
1108 
1109     def UpdateSerial(self, bpserial, otherserial):
1110         try:
1111             for k in self._data:
1112                 entry=self._data[k]
1113                 for serial in entry['serials']:
1114                     if serial==bpserial:
1115                         # this is the entry we have been looking for
1116                         for i,serial in enumerate(entry['serials']):
1117                             if serial["sourcetype"]==otherserial["sourcetype"]:
1118                                 if otherserial.has_key("sourceuniqueid") and \
1119                                    serial["sourceuniqueid"]==otherserial["sourceuniqueid"]:
1120                                     # replace
1121                                     entry['serials'][i]=otherserial
1122                                     return
1123                                 elif not otherserial.has_key("sourceuniqueid"):
1124                                     entry['serials'][i]=otherserial
1125                                     return
1126                         entry['serials'].append(otherserial)
1127                         return
1128             raise ValueError("No such entry with serial "+`bpserial`)
1129         finally:
1130             self.modified=True
1131             
1132 
1133     def versionupgrade(self, dict, version):
1134         """Upgrade old data format read from disk
1135 
1136         @param dict:  The dict that was read in
1137         @param version: version number of the data on disk
1138         """
1139 
1140         # version 0 to 1 upgrade
1141         if version==0:
1142             version=1  # they are the same
1143 
1144         # 1 to 2 etc
1145         if version==1:
1146             wx.MessageBox("BitPim can't upgrade your old phone data stored on disk, and has discarded it.  Please re-read your phonebook from the phone.  If you downgrade, please delete the phonebook directory in the BitPim data directory first", "Phonebook file format not supported", wx.OK|wx.ICON_EXCLAMATION)
1147             version=2
1148             dict['result']['phonebook']={}
1149             dict['result']['categories']=[]
1150             
1151     def clear(self):
1152         self._data={}
1153         self.dt.OnDataUpdated()
1154 
1155     def getfromfs(self, dict, timestamp=None):
1156         self.thedir=self.mainwindow.phonebookpath
1157         if os.path.exists(os.path.join(self.thedir, "index.idx")):
1158             d={'result': {'phonebook': {}, 'categories': []}}
1159             common.readversionedindexfile(os.path.join(self.thedir, "index.idx"), d, self.versionupgrade, self.CURRENTFILEVERSION)
1160             pb=d['result']['phonebook']
1161             database.ensurerecordtype(pb, phonebookobjectfactory)
1162             pb=database.extractbitpimserials(pb)
1163             self.mainwindow.database.savemajordict("phonebook", pb)
1164             self.mainwindow.database.savelist("categories", d['result']['categories'])
1165             # now that save is succesful, move file out of the way
1166             os.rename(os.path.join(self.thedir, "index.idx"), os.path.join(self.thedir, "index-is-now-in-database.bak"))
1167         # read info from the database
1168         dict['phonebook']=self.mainwindow.database.getmajordictvalues(
1169             "phonebook", phonebookobjectfactory, at_time=timestamp)
1170         dict['categories']=self.mainwindow.database.loadlist("categories")
1171             
1172 
1173     def _updatecount(self):
1174         # Update the count of contatcs
1175         self.contactcount_label.SetLabel('%(count)d'%{ 'count': len(self._data) })
1176 
1177     def populate(self, dict, savetodb=True):
1178         if self.read_only and savetodb:
1179             wx.MessageBox('You are viewing historical data which cannot be changed or saved',
1180                              'Cannot Save Phonebook Data',
1181                              style=wx.OK|wx.ICON_ERROR)
1182             return            
1183         self.clear()
1184         pubsub.publish(pubsub.MERGE_CATEGORIES, dict['categories'])
1185         pb=dict['phonebook']
1186         cats=[]
1187         for i in pb:
1188             for cat in pb[i].get('categories', []):
1189                 cats.append(cat['category'])
1190         pubsub.publish(pubsub.MERGE_CATEGORIES, cats)                
1191         k=pb.keys()
1192         k.sort()
1193         self.clear()
1194         self._data=pb.copy()
1195         self.dt.OnDataUpdated()
1196         self.modified=savetodb
1197         self._updatecount()
1198 
1199     def _save_db(self, dict):
1200         self.mainwindow.database.savemajordict("phonebook", database.extractbitpimserials(dict["phonebook"]))
1201         self.mainwindow.database.savelist("categories", dict["categories"])
1202         
1203     def populatefs(self, dict):
1204         if self.read_only:
1205             wx.MessageBox('You are viewing historical data which cannot be changed or saved',
1206                              'Cannot Save Phonebook Data',
1207                              style=wx.OK|wx.ICON_ERROR)
1208         else:
1209             self._save_db(dict)
1210             self._updatecount()
1211         return dict
1212 
1213     def _ensure_unicode(self, data):
1214         # convert and ensure unicode fields
1215         for _key,_entry in data.items():
1216             for _field_key,_field_value in _entry.items():
1217                 if _field_key=='names':
1218                     for _idx,_item in enumerate(_field_value):
1219                         for _subkey, _value in _item.items():
1220                             if isinstance(_value, str):
1221                                 _item[_subkey]=_value.decode('ascii', 'ignore')
1222 
1223     def importdata(self, importdata, categoriesinfo=[], merge=True):
1224         if self.read_only:
1225             wx.MessageBox('You are viewing historical data which cannot be changed or saved',
1226                           'Cannot Save Phonebook Data',
1227                           style=wx.OK|wx.ICON_ERROR)
1228             return
1229         if merge:
1230             d=self._data
1231         else:
1232             d={}
1233         normalise_data(importdata)
1234         self._ensure_unicode(importdata)
1235         with guihelper.WXDialogWrapper(ImportDialog(self, d, importdata),
1236                                        True) as (dlg, retcode):
1237             guiwidgets.save_size("PhoneImportMergeDialog", dlg.GetRect())
1238             if retcode==wx.ID_OK:
1239                 result=dlg.resultdata
1240                 if result is not None:
1241                     d={}
1242                     database.ensurerecordtype(result, phonebookobjectfactory)
1243                     database.ensurebitpimserials(result)
1244                     d['phonebook']=result
1245                     d['categories']=categoriesinfo
1246                     self.populatefs(d)
1247                     self.populate(d, False)
1248     
1249     def converttophone(self, data):
1250         self.error_log.ClearMessages()
1251         self.mainwindow.phoneprofile.convertphonebooktophone(self, data)
1252         if self.error_log.MsgCount():
1253             self.error_log.ShowMessages()
1254         return
1255 
1256     ###
1257     ###  The methods from here on are passed as the 'helper' to
1258     ###  convertphonebooktophone in the phone profiles.  One
1259     ###  day they may move to a seperate class.
1260     ###
1261 
1262     def add_error_message(self, msg, priority=99):
1263         self.error_log.AddMessage(msg, priority)
1264 
1265     def log(self, msg):
1266         self.mainwindow.log(msg)
1267 
1268     class ConversionFailed(Exception):
1269         pass
1270 
1271     def _getentries(self, list, min, max, name):
1272         candidates=[]
1273         for i in list:
1274             # ::TODO:: possibly ensure that a key appears in each i
1275             candidates.append(i)
1276         if len(candidates)<min:
1277             # ::TODO:: log this
1278             raise self.ConversionFailed("Too few %s.  Need at least %d but there were only %d" % (name,min,len(candidates)))
1279         if len(candidates)>max:
1280             # ::TODO:: mention this to user
1281             candidates=candidates[:max]
1282         return candidates
1283 
1284     def _getfield(self,list,name):
1285         res=[]
1286         for i in list:
1287             res.append(i[name])
1288         return res
1289 
1290     def _truncatefields(self, list, truncateat):
1291         if truncateat is None:
1292             return list
1293         res=[]
1294         for i in list:
1295             if len(i)>truncateat:
1296                 # ::TODO:: log truncation
1297                 res.append(i[:truncateat])
1298             else:
1299                 res.append(i)
1300         return res
1301 
1302     def _findfirst(self, candidates, required, key, default):
1303         """Find first match in candidates that meets required and return value of key
1304 
1305         @param candidates: list of dictionaries to search through
1306         @param required: a dict of what key/value pairs must exist in an entry
1307         @param key: for a matching entry, which key's value to return
1308         @param default: what value to return if there is no match
1309         """
1310         for dict in candidates:
1311             ok=True
1312             for k in required:
1313                 if dict[k]!=required[k]:
1314                    ok=False
1315                    break # really want break 2
1316             if not ok:
1317                 continue
1318             return dict.get(key, default)
1319         return default
1320 
1321     def getfullname(self, names, min, max, truncateat=None):
1322         "Return at least min and at most max fullnames from the names list"
1323         # secret lastnamefirst setting
1324         if wx.GetApp().config.ReadInt("lastnamefirst", False):
1325             n=[nameparser.formatsimplelastfirst(nn) for nn in names]
1326         else:
1327             n=[nameparser.formatsimplename(nn) for nn in names]
1328         if len(n)<min:
1329             raise self.ConversionFailed("Too few names.  Need at least %d but there were only %d" % (min, len(n)))
1330         if len(n)>max:
1331             n=n[:max]
1332             # ::TODO:: mention this
1333         return self._truncatefields(n, truncateat)
1334 
1335     def getcategory(self, categories, min, max, truncateat=None):
1336         "Return at least min and at most max categories from the categories list"
1337         return self._truncatefields(self._getfield(self._getentries(categories, min, max, "categories"), "category"), truncateat)
1338 
1339     def getemails(self, emails, min, max, truncateat=None):
1340         "Return at least min and at most max emails from the emails list"
1341         return self._truncatefields(self._getfield(self._getentries(emails, min, max, "emails"), "email"), truncateat)
1342 
1343     def geturls(self, urls, min, max, truncateat=None):
1344         "Return at least min and at most max urls from the urls list"
1345         return self._truncatefields(self._getfield(self._getentries(urls, min, max, "urls"), "url"), truncateat)
1346         
1347 
1348     def getmemos(self, memos, min, max, truncateat=None):
1349         "Return at least min and at most max memos from the memos list"
1350         return self._truncatefields(self._getfield(self._getentries(memos, min, max, "memos"), "memo"), truncateat)
1351 
1352     def getnumbers(self, numbers, min, max):
1353         "Return at least min and at most max numbers from the numbers list"
1354         return self._getentries(numbers, min, max, "numbers")
1355 
1356     def getnumber(self, numbers, type, count=1, default=""):
1357         """Returns phone numbers of the type
1358 
1359         @param numbers: The list of numbers
1360         @param type: The type, such as cell, home, office
1361         @param count: Which number to return (eg with type=home, count=2 the second
1362                     home number is returned)
1363         @param default: What is returned if there is no such number"""
1364         for n in numbers:
1365             if n['type']==type:
1366                 if count==1:
1367                     return n['number']
1368                 count-=1
1369         return default
1370 
1371     def getserial(self, serials, sourcetype, id, key, default):
1372         "Gets a serial if it exists"
1373         return self._findfirst(serials, {'sourcetype': sourcetype, 'sourceuniqueid': id}, key, default)
1374         
1375     def getringtone(self, ringtones, use, default):
1376         "Gets a ringtone of type use"
1377         return self._findfirst(ringtones, {'use': use}, 'ringtone', default)
1378 
1379     def getwallpaper(self, wallpapers, use, default):
1380         "Gets a wallpaper of type use"
1381         return self._findfirst(wallpapers, {'use': use}, 'wallpaper', default)
1382 
1383     def getwallpaperindex(self, wallpapers, use, default):
1384         "Gets a wallpaper index of type use"
1385         return self._findfirst(wallpapers, {'use': use}, 'index', default)
1386 
1387     def getflag(self, flags, name, default):
1388         "Gets value of flag named name"
1389         for i in flags:
1390             if i.has_key(name):
1391                 return i[name]
1392         return default
1393 
1394     def getmostpopularcategories(self, howmany, entries, reserved=[], truncateat=None, padnames=[]):
1395         """Returns the most popular categories
1396 
1397         @param howmany:  How many to return, including the reserved ones
1398         @param entries:  A dict of the entries
1399         @param reserved: A list of reserved entries (ie must be present, no matter
1400                          how popular)
1401         @param truncateat: How long to truncate the category names at
1402         @param padnames: if the list is less than howmany long, then add these on the end providing
1403                          they are not already in the list
1404         @return: A list of the group names.  The list starts with the members of
1405                reserved followed by the most popular groups
1406         """
1407         # build a histogram
1408         freq={}
1409         for entry in entries:
1410             e=entries[entry]
1411             for cat in e.get('categories', []):
1412                n=cat['category']
1413                if truncateat: n=n[:truncateat] # truncate
1414                freq[n]=1+freq.get(n,0)
1415         # sort
1416         freq=[(count,value) for value,count in freq.items()]
1417         freq.sort()
1418         freq.reverse() # most popular first
1419         # build a new list
1420         newl=reserved[:]
1421         for _, group in freq:
1422             if len(newl)==howmany:
1423                 break
1424             if group not in newl:
1425                 newl.append(group)
1426         # pad list out
1427         for p in padnames:
1428             if len(newl)==howmany:
1429                 break
1430             if p not in newl:
1431                 newl.append(p)
1432                 
1433         return newl
1434 
1435     def makeone(self, list, default):
1436         "Returns one item long list"
1437         if len(list)==0:
1438             return default
1439         assert len(list)==1
1440         return list[0]   
1441 
1442     def filllist(self, list, numitems, blank):
1443         "makes list numitems long appending blank to get there"
1444         l=list[:]
1445         for dummy in range(len(l),numitems):
1446             l.append(blank)
1447         return l
1448 
1449 
1450 class ImportCellRenderer(wx.grid.PyGridCellRenderer):
1451     SCALE=0.8
1452 
1453     COLOURS=["HONEYDEW", "WHITE", "LEMON CHIFFON", "ROSYBROWN1"]
1454     
1455     def __init__(self, table, grid):
1456         wx.grid.PyGridCellRenderer.__init__(self)
1457         self.calc=False
1458         self.table=table
1459 
1460     def _calcattrs(self):
1461         grid=self.table.GetView()
1462         self.font=grid.GetDefaultCellFont()
1463         self.facename=self.font.GetFaceName()
1464         self.facesize=self.font.GetPointSize()
1465         self.textcolour=grid.GetDefaultCellTextColour()
1466         self.brushes=[wx.Brush(wx.NamedColour(c)) for c in self.COLOURS]
1467         self.pens=[wx.Pen(wx.NamedColour(c),1 , wx.SOLID) for c in self.COLOURS]
1468         self.selbrush=wx.Brush(grid.GetSelectionBackground(), wx.SOLID)
1469         self.selpen=wx.Pen(grid.GetSelectionBackground(), 1, wx.SOLID)
1470         self.selfg=grid.GetSelectionForeground()
1471         self.calc=True
1472         
1473     def Draw(self, grid, attr, dc, rect, row, col, isSelected):
1474         if not self.calc: self._calcattrs()
1475 
1476         rowtype=self.table.GetRowType(row)
1477         dc.SetClippingRect(rect)
1478 
1479         # clear the background
1480         dc.SetBackgroundMode(wx.SOLID)
1481         if isSelected:
1482             dc.SetBrush(self.selbrush)
1483             dc.SetPen(self.selpen)
1484             colour=self.selfg
1485         else:
1486             dc.SetBrush(self.brushes[rowtype])
1487             dc.SetPen(self.pens[rowtype])
1488             colour=self.textcolour
1489 
1490         dc.DrawRectangle(rect.x, rect.y, rect.width, rect.height)
1491 
1492         dc.SetBackgroundMode(wx.TRANSPARENT)
1493         dc.SetFont(self.font)
1494 
1495         text = grid.GetTable().GetHtmlCellValue(row, col, colour)
1496         if len(text):
1497             bphtml.drawhtml(dc,
1498                             wx.Rect(rect.x+2, rect.y+1, rect.width-4, rect.height-2),
1499                             text, font=self.facename, size=self.facesize)
1500         dc.DestroyClippingRegion()
1501 
1502     def GetBestSize(self, grid, attr, dc, row, col):
1503         if not self.calc: self._calcattrs()
1504         text = grid.GetTable().GetHtmlCellValue(row, col)
1505         if not len(text): return (5,5)
1506         return bphtml.getbestsize(dc, text, font=self.facename, size=self.facesize)
1507 
1508     def Clone(self):
1509         return ImportCellRenderer()
1510 
1511 
1512 class ImportDataTable(wx.grid.PyGridTableBase):
1513     ADDED=0
1514     UNALTERED=1
1515     CHANGED=2
1516     DELETED=3
1517 
1518     htmltemplate=["Not set - "+`i` for i in range(15)]
1519 
1520     def __init__(self, widget):
1521         self.main=widget
1522         self.rowkeys=[]
1523         wx.grid.PyGridTableBase.__init__(self)
1524         self.columns=['Confidence']+ImportColumns
1525 
1526     def GetRowData(self, row):
1527         """Returns a 4 part tuple as defined in ImportDialog.rowdata
1528         for the numbered row"""
1529         return self.main.rowdata[self.rowkeys[row]]
1530 
1531     def GetColLabelValue(self, col):
1532         "Returns the label for the numbered column"
1533         return self.columns[col]
1534 
1535     def IsEmptyCell(self, row, col):
1536         return False
1537 
1538     def GetNumberCols(self):
1539         return len(self.columns)
1540 
1541     def GetNumberRows(self):
1542         return len(self.rowkeys)
1543 
1544     def GetRowType(self, row):
1545         """Returns what type the row is from DELETED, CHANGED, ADDED and UNALTERED"""
1546         row=self.GetRowData(row)
1547         if row[3] is None:
1548             return self.DELETED
1549         if row[1] is not None and row[2] is not None:
1550             return self.CHANGED
1551         if row[1] is not None and row[2] is None:
1552             return self.ADDED
1553         return self.UNALTERED
1554 
1555     def GetValueWithNamedColumn(self, row, columnname):
1556         row=self.main.rowdata[self.rowkeys[row]]
1557         if columnname=='Confidence':
1558             return row[0]
1559 
1560         for i,ptr in (3,self.main.resultdata), (1,self.main.importdata), (2, self.main.existingdata):
1561             if row[i] is not None:
1562                 return getdata(columnname, ptr[row[i]], "")
1563         assert False, "Can't get here"
1564         return ""
1565 
1566     def ShouldColumnBeShown(self, columnname, row):
1567         confidence, importedkey, existingkey,  resultkey=self.GetRowData(row)
1568         if columnname=="Confidence": return True
1569         return (resultkey is not None and getdata(columnname, self.main.resultdata[resultkey], None) is not None) \
1570                or (existingkey is not None and getdata(columnname, self.main.existingdata[existingkey], None) is not None) \
1571                or (importedkey is not None and getdata(columnname, self.main.importdata[importedkey], None) is not None)
1572 
1573     def GetHtmlCellValue(self, row, col, colour=None):
1574         try:
1575             row=self.GetRowData(row)
1576         except:
1577             print "bad row", row
1578             return "&gt;error&lt;"
1579 
1580         if colour is None:
1581             colour="#000000" # black
1582         else:
1583             colour="#%02X%02X%02X" % (colour.Red(), colour.Green(), colour.Blue())
1584 
1585         if self.columns[col]=='Confidence':
1586             # row[0] could be a zero length string or an integer
1587             if row[0]=="": return ""
1588             return '<font color="%s">%d</font>' % (colour, row[0])
1589 
1590         # Get values - note default of None
1591         imported,existing,result=None,None,None
1592         if row[1] is not None:
1593             imported=getdata(self.columns[col], self.main.importdata[row[1]], None)
1594             if imported is not None: imported=common.strorunicode(imported)
1595         if row[2] is not None:
1596             existing=getdata(self.columns[col], self.main.existingdata[row[2]], None)
1597             if existing is not None: existing=common.strorunicode(existing)
1598         if row[3] is not None:
1599             result=getdata(self.columns[col], self.main.resultdata[row[3]], None)
1600             if result is not None: result=common.strorunicode(result)
1601 
1602         # The following code looks at the combinations of imported,
1603         # existing and result with them being None and/or equalling
1604         # each other.  Remember that the user could have
1605         # editted/deleted an entry so the result may not match either
1606         # the imported or existing value.  Each combination points to
1607         # an index in the templates table.  Assertions are used
1608         # extensively to ensure the logic is correct
1609 
1610         if imported is None and existing is None and result is None:
1611             return ""  # idx=9 - shortcut
1612 
1613         # matching function for this column
1614         matchfn=lambda x,y: x==y
1615 
1616         # if the result field is missing then value was deleted
1617         if result is None:
1618             # one of them must be present otherwise idx=9 above would have matched
1619             assert  imported is not None or existing is not None
1620             if imported is not None and existing is not None:
1621                 if matchfn(imported, existing):
1622                     idx=14
1623                 else:
1624                     idx=13
1625             else:
1626                 if imported is None:
1627                     assert existing is not None
1628                     idx=11
1629                 else:
1630                     assert existing is None
1631                     idx=12
1632             
1633         else:
1634             if imported is None and existing is None:
1635                 idx=10
1636             else:
1637                 # we have a result - the first 8 entries need the following
1638                 # comparisons
1639                 if imported is not None:
1640                     imported_eq_result= matchfn(imported,result)
1641                 if existing is not None:
1642                     existing_eq_result= matchfn(existing,result)
1643 
1644                 # a table of all possible combinations of imported/exporting
1645                 # being None and imported_eq_result/existing_eq_result
1646                 if      imported is None          and    existing_eq_result:
1647                     idx=0
1648                 elif    imported is None          and    not existing_eq_result:
1649                     idx=1
1650                 elif    imported_eq_result        and    existing is None:
1651                     idx=2
1652                 elif    not imported_eq_result    and    existing is None:
1653                     idx=3
1654                 elif    imported_eq_result        and    existing_eq_result:
1655                     idx=4
1656                 elif    imported_eq_result        and    not existing_eq_result:
1657                     idx=5
1658                 elif    not imported_eq_result    and    existing_eq_result:
1659                     idx=6
1660                 elif    not imported_eq_result    and    not existing_eq_result:
1661                     # neither imported or existing are the same as result
1662                     # are they the same as each other?
1663                     if matchfn(imported, existing):
1664                         idx=7
1665                     else:
1666                         idx=8
1667                 else:
1668                     assert False, "This is unpossible!"
1669                     return "FAILED"
1670 
1671         if False: # set to true to debug this
1672             return `idx`+" "+self.htmltemplate[idx] % { 'imported': _htmlfixup(imported),
1673                                           'existing': _htmlfixup(existing),
1674                                           'result': _htmlfixup(result),
1675                                           'colour': colour}
1676 
1677 
1678         return self.htmltemplate[idx] % { 'imported': _htmlfixup(imported),
1679                                           'existing': _htmlfixup(existing),
1680                                           'result': _htmlfixup(result),
1681                                           'colour': colour}
1682 
1683     @guihelper.BusyWrapper
1684     def OnDataUpdated(self):
1685         # update row keys
1686         newkeys=self.main.rowdata.keys()
1687         oldrows=self.rowkeys
1688         # rowkeys is kept in the same order as oldrows
1689         self.rowkeys=[k for k in oldrows if k in newkeys]+[k for k in newkeys if k not in oldrows]
1690         # now remove the ones that don't match checkboxes
1691         self.rowkeys=[self.rowkeys[n] for n in range(len(self.rowkeys)) if self.GetRowType(n) in self.main.show]
1692         # work out which columns we actually need
1693         colsavail=ImportColumns
1694         colsused=[]
1695         for row in range(len(self.rowkeys)):
1696             can=[] # cols available now
1697             for col in colsavail:
1698                 if self.ShouldColumnBeShown(col, row):
1699                     colsused.append(col)
1700                 else:
1701                     can.append(col)
1702             colsavail=can
1703         # colsused won't be in right order
1704         colsused=[c for c in ImportColumns if c in colsused]
1705         colsused=["Confidence"]+colsused
1706         lo=len(self.columns)
1707         ln=len(colsused)
1708 
1709         try:
1710             sortcolumn=self.columns[self.main.sortedColumn]
1711         except IndexError:
1712             sortcolumn=0
1713 
1714         # update columns
1715         self.columns=colsused
1716         if ln>lo:
1717             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_COLS_APPENDED, ln-lo)
1718         elif lo>ln:
1719             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_COLS_DELETED, 0, lo-ln)
1720         else:
1721             msg=None
1722         if msg is not None:
1723             self.GetView().ProcessTableMessage(msg)
1724 
1725         # do sorting
1726         if sortcolumn not in self.columns:
1727             sortcolumn=1
1728         else:
1729             sortcolumn=self.columns.index(sortcolumn)
1730 
1731         # we sort on lower case value, but note that not all columns are strings
1732         items=[]
1733         for row in range(len(self.rowkeys)):
1734             v=self.GetValue(row,sortcolumn)
1735             try:
1736                 items.append((v.lower(), row))
1737             except:
1738                 items.append((v, row))
1739             
1740         items.sort()
1741         if self.main.sortedColumnDescending:
1742             items.reverse()
1743         self.rowkeys=[self.rowkeys[n] for _,n in items]
1744 
1745         # update rows
1746         lo=len(oldrows)
1747         ln=len(self.rowkeys)
1748         if ln>lo:
1749             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_ROWS_APPENDED, ln-lo)
1750         elif lo>ln:
1751             msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_NOTIFY_ROWS_DELETED, 0, lo-ln)
1752         else:
1753             msg=None
1754         if msg is not None:
1755             self.GetView().ProcessTableMessage(msg)
1756         msg=wx.grid.GridTableMessage(self, wx.grid.GRIDTABLE_REQUEST_VIEW_GET_VALUES)
1757         self.GetView().ProcessTableMessage(msg)
1758         self.GetView().ClearSelection()
1759         self.main.OnCellSelect()
1760         self.GetView().Refresh()
1761 
1762 def _htmlfixup(txt):
1763     if txt is None: return ""
1764     return txt.replace("&", "&amp;").replace("<", "&gt;").replace(">", "&lt;") \
1765            .replace("\r\n", "<br>").replace("\r", "<br>").replace("\n", "<br>")
1766 
1767 def workaroundyetanotherwxpythonbug(method, *args):
1768     # grrr
1769     try:
1770         return method(*args)
1771     except TypeError:
1772         print "swallowed a type error in workaroundyetanotherwxpythonbug"
1773         pass
1774 
1775 ###
1776 ### 0 thru 8 inclusive have a result present
1777 ###
1778 
1779 #  0 - imported is None,  existing equals result
1780 ImportDataTable.htmltemplate[0]='<font color="%(colour)s">%(result)s</font>'
1781 #  1 - imported is None,  existing not equal result
1782 ImportDataTable.htmltemplate[1]='<font color="%(colour)s"><strike>%(result)s</strike><br><b><font size=-1>Existing</font></b> %(existing)s</font>'
1783 #  2 - imported equals result,  existing is None
1784 ImportDataTable.htmltemplate[2]='<font color="%(colour)s"><strike>%(result)s</strike></font>'
1785 #  3 - imported not equal result,  existing is None
1786 ImportDataTable.htmltemplate[3]='<font color="%(colour)s"><strike>%(result)s</strike><br><b><font size=-1>Imported</font></b> %(imported)s</font>'
1787 #  4 - imported equals result, existing equals result
1788 ImportDataTable.htmltemplate[4]=ImportDataTable.htmltemplate[0]  # just display result
1789 #  5 - imported equals result, existing not equals result
1790 ImportDataTable.htmltemplate[5]='<font color="%(colour)s"><strike><font color="#00aa00">%(result)s</font></strike><br><b><font size=-1>Existing</font></b> %(existing)s</font>'
1791 #  6 - imported not equal result, existing equals result
1792 ImportDataTable.htmltemplate[6]='<font color="%(colour)s">%(result)s<br><b><font size=-1>Imported</font></b> %(imported)s</font>'
1793 #  7 - imported not equal result, existing not equal result, imported equals existing
1794 ImportDataTable.htmltemplate[7]='<font color="%(colour)s"><strike>%(result)s</strike><br><b><font size=-1>Imported/Existing</font></b> %(imported)s</font>'
1795 #  8 - imported not equal result, existing not equal result, imported not equals existing
1796 ImportDataTable.htmltemplate[8]='<font color="%(colour)s"><strike>%(result)s</strike><br><b><font size=-1>Imported</font></b> %(imported)s<br><b><font size=-1>Existing</font></b> %(existing)s</font>'
1797 
1798 ###
1799 ### Two special cases
1800 ###
1801 
1802 #  9 - imported, existing, result are all None
1803 ImportDataTable.htmltemplate[9]=""
1804 
1805 #  10 - imported, existing are None and result is present
1806 ImportDataTable.htmltemplate[10]='<font color="%(colour)s"><strike>%(result)s</strike></b></font>'
1807 
1808 ###
1809 ### From 10 onwards, there is no result field, but one or both of
1810 ### imported/existing are present which means the user deleted the
1811 ### resulting value
1812 ###
1813 
1814 # 11 - imported is None and existing is present
1815 ImportDataTable.htmltemplate[11]='<font color="#aa0000">%(existing)s</font>'
1816 # 12 - imported is present and existing is None
1817 ImportDataTable.htmltemplate[12]='<font color="#aa0000"><font size=-1>%(imported)s</font></font>' # slightly smaller
1818 # 13 - imported != existing
1819 ImportDataTable.htmltemplate[13]='<font color="%(colour)s"><b><font size=-1>Existing</font></b> <font color="#aa0000">%(existing)s</font><br><b><font size=-1>Imported</font></b> <font color="#888888">%(imported)s</font></font>'
1820 # 14 - imported equals existing
1821 ImportDataTable.htmltemplate[14]='<font color="#aa0000">%(existing)s</font>'
1822 
1823 
1824 class ImportDialog(wx.Dialog):
1825     "The dialog for mixing new (imported) data with existing data"
1826 
1827 
1828     def __init__(self, parent, existingdata, importdata):
1829         wx.Dialog.__init__(self, parent, id=-1, title="Import Phonebook data", style=wx.CAPTION|
1830              wx.SYSTEM_MENU|wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER)
1831 
1832         # the data already in the phonebook
1833         self.existingdata=existingdata
1834         # the data we are importing
1835         self.importdata=importdata
1836         # the resulting data
1837         self.resultdata={}
1838         # each row to display showing what happened, with ids pointing into above data
1839         # rowdata[0]=confidence
1840         # rowdata[1]=importdatakey
1841         # rowdata[2]=existingdatakey
1842         # rowdata[3]=resultdatakey
1843         self.rowdata={}
1844 
1845         vbs=wx.BoxSizer(wx.VERTICAL)
1846         
1847         bg=self.GetBackgroundColour()
1848         w=wx.html.HtmlWindow(self, -1, size=wx.Size(600,50), style=wx.html.HW_SCROLLBAR_NEVER)
1849         w.SetPage('<html><body BGCOLOR="#%02X%02X%02X">Your data is being imported and BitPim is showing what will happen below so you can confirm its actions.</body></html>' % (bg.Red(), bg.Green(), bg.Blue()))
1850         vbs.Add(w, 0, wx.EXPAND|wx.ALL, 5)
1851 
1852         hbs=wx.BoxSizer(wx.HORIZONTAL)
1853         hbs.Add(wx.StaticText(self, -1, "Show entries"), 0, wx.EXPAND|wx.ALL,3)
1854 
1855         self.cbunaltered=wx.CheckBox(self, wx.NewId(), "Unaltered")
1856         self.cbadded=wx.CheckBox(self, wx.NewId(), "Added")
1857         self.cbchanged=wx.CheckBox(self, wx.NewId(), "Merged")
1858         self.cbdeleted=wx.CheckBox(self, wx.NewId(), "Deleted")
1859         wx.EVT_CHECKBOX(self, self.cbunaltered.GetId(), self.OnCheckbox)
1860         wx.EVT_CHECKBOX(self, self.cbadded.GetId(), self.OnCheckbox)
1861         wx.EVT_CHECKBOX(self, self.cbchanged.GetId(), self.OnCheckbox)
1862         wx.EVT_CHECKBOX(self, self.cbdeleted.GetId(), self.OnCheckbox)
1863 
1864         for i in self.cbunaltered, self.cbadded, self.cbchanged, self.cbdeleted:
1865             i.SetValue(True)
1866             hbs.Add(i, 0, wx.ALIGN_CENTRE|wx.LEFT|wx.RIGHT, 7)
1867 
1868         t=ImportDataTable
1869         self.show=[t.ADDED, t.UNALTERED, t.CHANGED, t.DELETED]
1870 
1871         hbs.Add(wx.StaticText(self, -1, " "), 0, wx.EXPAND|wx.LEFT, 10)
1872 
1873         vbs.Add(hbs, 0, wx.EXPAND|wx.ALL, 5)
1874 
1875         splitter=wx.SplitterWindow(self,-1, style=wx.SP_3D|wx.SP_LIVE_UPDATE)
1876         splitter.SetMinimumPaneSize(20)
1877 
1878         self.grid=wx.grid.Grid(splitter, wx.NewId())
1879         self.table=ImportDataTable(self)
1880         
1881         # this is a work around for various wxPython/wxWidgets bugs
1882         cr=ImportCellRenderer(self.table, self.grid)
1883         cr.IncRef()  # wxPython bug
1884         self.grid.RegisterDataType("string", cr, None) # wxWidgets bug - it uses the string renderer rather than DefaultCellRenderer
1885 
1886         self.grid.SetTable(self.table, False, wx.grid.Grid.wxGridSelectRows)
1887         self.grid.SetSelectionMode(wx.grid.Grid.wxGridSelectRows)
1888         self.grid.SetRowLabelSize(0)
1889         self.grid.EnableDragRowSize(True)
1890         self.grid.EnableEditing(False)
1891         self.grid.SetMargins(1,0)
1892         self.grid.EnableGridLines(False)
1893 
1894         wx.grid.EVT_GRID_CELL_RIGHT_CLICK(self.grid, self.OnRightGridClick)
1895         wx.grid.EVT_GRID_SELECT_CELL(self.grid, self.OnCellSelect)
1896         wx.grid.EVT_GRID_CELL_LEFT_DCLICK(self.grid, self.OnCellDClick)
1897         wx.EVT_PAINT(self.grid.GetGridColLabelWindow(), self.OnColumnHeaderPaint)
1898         wx.grid.EVT_GRID_LABEL_LEFT_CLICK(self.grid, self.OnGridLabelLeftClick)
1899         wx.grid.EVT_GRID_LABEL_LEFT_DCLICK(self.grid, self.OnGridLabelLeftClick)
1900 
1901         self.resultpreview=PhoneEntryDetailsView(splitter, -1, "styles.xy", "pblayout.xy")
1902 
1903         splitter.SplitVertically(self.grid, self.resultpreview)
1904 
1905         vbs.Add(splitter, 1, wx.EXPAND|wx.ALL,5)
1906         vbs.Add(wx.StaticLine(self, -1, style=wx.LI_HORIZONTAL), 0, wx.EXPAND|wx.ALL, 5)
1907 
1908         vbs.Add(self.CreateButtonSizer(wx.OK|wx.CANCEL|wx.HELP), 0, wx.ALIGN_CENTRE|wx.ALL, 5)
1909 
1910         self.SetSizer(vbs)
1911         self.SetAutoLayout(True)
1912 
1913         self.config = parent.mainwindow.config
1914         guiwidgets.set_size("PhoneImportMergeDialog", self, screenpct=95,  aspect=1.10)
1915 
1916         self.MakeMenus()
1917 
1918         self.sortedColumn=1
1919         self.sortedColumnDescending=False
1920 
1921         wx.EVT_BUTTON(self, wx.ID_HELP, lambda _: wx.GetApp().displayhelpid(helpids.ID_DLG_PBMERGEENTRIES))
1922 
1923         # the splitter which adamantly insists it is 20 pixels wide no
1924         # matter how hard you try to convince it otherwise. so we force it
1925         self.splitter=splitter
1926         wx.CallAfter(self._setthedamnsplittersizeinsteadofbeingsostupid_thewindowisnot20pixelswide_isetthesizenolessthan3times_argggh)
1927 
1928         wx.CallAfter(self.DoMerge)
1929 
1930 
1931     def _setthedamnsplittersizeinsteadofbeingsostupid_thewindowisnot20pixelswide_isetthesizenolessthan3times_argggh(self):
1932         splitter=self.splitter
1933         w,_=splitter.GetSize()
1934         splitter.SetSashPosition(max(w/2, w-200))
1935 
1936     # ::TODO:: this method and the copy earlier should be merged into a single mixin
1937     def OnColumnHeaderPaint(self, evt):
1938         w = self.grid.GetGridColLabelWindow()
1939         dc = wx.PaintDC(w)
1940         font = dc.GetFont()
1941         dc.SetTextForeground(wx.BLACK)
1942         
1943         # For each column, draw it's rectangle, it's column name,
1944         # and it's sort indicator, if appropriate:
1945         totColSize = -self.grid.GetViewStart()[0]*self.grid.GetScrollPixelsPerUnit()[0]
1946         for col in range(self.grid.GetNumberCols()):
1947             dc.SetBrush(wx.Brush("WHEAT", wx.TRANSPARENT))
1948             colSize = self.grid.GetColSize(col)
1949             rect = (totColSize,0,colSize,32)
1950             # note abuse of bool to be integer 0/1
1951             dc.DrawRectangle(rect[0] - (col!=0), rect[1], rect[2] + (col!=0), rect[3])
1952             totColSize += colSize
1953             
1954             if col == self.sortedColumn:
1955                 font.SetWeight(wx.BOLD)
1956                 # draw a triangle, pointed up or down, at the
1957                 # top left of the column.
1958                 left = rect[0] + 3
1959                 top = rect[1] + 3
1960                 
1961                 dc.SetBrush(wx.Brush("WHEAT", wx.SOLID))
1962                 if self.sortedColumnDescending:
1963                     dc.DrawPolygon([(left,top), (left+6,top), (left+3,top+4)])
1964                 else:
1965                     dc.DrawPolygon([(left+3,top), (left+6, top+4), (left, top+4)])
1966             else:
1967                 font.SetWeight(wx.NORMAL)
1968 
1969             dc.SetFont(font)
1970             dc.DrawLabel("%s" % self.grid.GetTable().GetColLabelValue(col),
1971                      rect, wx.ALIGN_CENTER | wx.ALIGN_TOP)
1972 
1973     def OnGridLabelLeftClick(self, evt):
1974         col=evt.GetCol()
1975         if col==self.sortedColumn:
1976             self.sortedColumnDescending=not self.sortedColumnDescending
1977         else:
1978             self.sortedColumn=col
1979             self.sortedColumnDescending=False
1980         self.table.OnDataUpdated()
1981 
1982     def OnCheckbox(self, _):
1983         t=ImportDataTable
1984         vclist=((t.ADDED, self.cbadded), (t.UNALTERED, self.cbunaltered),
1985                 (t.CHANGED, self.cbchanged), (t.DELETED, self.cbdeleted))
1986         self.show=[v for v,c in vclist if c.GetValue()]
1987         if len(self.show)==0:
1988             for v,c in vclist:
1989                 self.show.append(v)
1990                 c.SetValue(True)
1991         self.table.OnDataUpdated()
1992 
1993     @guihelper.BusyWrapper
1994     def DoMerge(self):
1995         if len(self.existingdata)*len(self.importdata)>200:
1996             progdlg=wx.ProgressDialog("Merging entries", "BitPim is merging the new information into the existing information",
1997                                       len(self.existingdata), parent=self, style=wx.PD_APP_MODAL|wx.PD_CAN_ABORT|wx.PD_REMAINING_TIME)
1998         else:
1999             progdlg=None
2000         try:
2001             self._DoMerge(progdlg)
2002         finally:
2003             if progdlg:
2004                 progdlg.Destroy()
2005             del progdlg
2006 
2007     def _DoMerge(self, progdlg):
2008         """Merges all the importdata with existing data
2009 
2010         This can take quite a while!
2011         """
2012 
2013         # We go to great lengths to ensure that a copy of the import
2014         # and existing data is passed on to the routines we call and
2015         # data structures being built.  Originally the code expected
2016         # the called routines to make copies of the data they were
2017         # copying/modifying, but it proved too error prone and often
2018         # ended up modifying the original/import data passed in.  That
2019         # had the terrible side effect of meaning that your original
2020         # data got modified even if you pressed cancel!
2021 
2022         count=0
2023         row={}
2024         results={}
2025 
2026         em=EntryMatcher(self.existingdata, self.importdata)
2027         usedimportkeys=[]
2028         for progress,existingid in enumerate(self.existingdata.keys()):
2029             if progdlg:
2030                 if not progdlg.Update(progress):
2031                     # user cancelled
2032                     wx.CallAfter(self.EndModal, wx.ID_CANCEL)
2033                     return
2034             # does it match any imported  entry
2035             merged=False
2036             for confidence, importid in em.bestmatches(existingid, limit=1):
2037                 if confidence>90:
2038                     if importid in usedimportkeys:
2039                         # someone else already used this import, lets find out who was the better match
2040                         for i in row:
2041                             if row[i][1]==importid:
2042                                 break
2043                         if confidence<row[i][0]:
2044                             break # they beat us so this existing passed on an importmatch
2045                         # we beat the other existing - undo their merge
2046                         assert i==row[i][3]
2047                         row[i]=("", None, row[i][2], row[i][3])
2048                         results[i]=copy.deepcopy(self.existingdata[row[i][2]])
2049                         
2050                     results[count]=self.MergeEntries(copy.deepcopy(self.existingdata[existingid]),
2051                                                      copy.deepcopy(self.importdata[importid]))
2052                     row[count]=(confidence, importid, existingid, count)
2053                     # update counters etc
2054                     count+=1
2055                     usedimportkeys.append(importid)
2056                     merged=True
2057                     break # we are happy with this match
2058             if not merged:
2059                 results[count]=copy.deepcopy(self.existingdata[existingid])
2060                 row[count]=("", None, existingid, count)
2061                 count+=1
2062 
2063         # which imports went unused?
2064         for importid in self.importdata:
2065             if importid in usedimportkeys: continue
2066             results[count]=copy.deepcopy(self.importdata[importid])
2067             row[count]=("", importid, None, count)
2068             count+=1
2069 
2070         # scan thru the merged ones, and see if anything actually changed
2071         for r in row:
2072             _, importid, existingid, resid=row[r]
2073             if importid is not None and existingid is not None:
2074                 checkresult=copy.deepcopy(results[resid])
2075                 checkexisting=copy.deepcopy(self.existingdata[existingid])
2076                 # we don't care about serials changing ...
2077                 if "serials" in checkresult: del checkresult["serials"]
2078                 if "serials" in checkexisting: del checkexisting["serials"]
2079                 
2080                 # another sort of false positive is if the name field
2081                 # has "full" defined in existing and "first", "last"
2082                 # in import, and the result ends up with "full",
2083                 # "first" and "last" which looks different than
2084                 # existing so it shows up us a change in the UI
2085                 # despite the fact that it hasn't really changed if
2086                 # full and first/last are consistent with each other.
2087                 # Currently we just ignore this situation.
2088                 
2089                 if checkresult == checkexisting:
2090                     # lets pretend there was no import
2091                     row[r]=("", None, existingid, resid)
2092 
2093         self.rowdata=row
2094         self.resultdata=results
2095         self.table.OnDataUpdated()
2096 
2097     def MergeEntries(self, originalentry, importentry):
2098         "Take an original and a merge entry and join them together return a dict of the result"
2099         o=originalentry
2100         i=importentry
2101         result={}
2102         # Get the intersection.  Anything not in this is not controversial
2103         intersect=dictintersection(o,i)
2104         for dict in i,o:
2105             for k in dict.keys():
2106                 if k not in intersect:
2107                     result[k]=dict[k][:]
2108         # now only deal with keys in both
2109         for key in intersect:
2110             if key=="names":
2111                 # we ignore anything except the first name.  fields in existing take precedence
2112                 r=i["names"][0]
2113                 for k in o["names"][0]:
2114                     r[k]=o["names"][0][k]
2115                 result["names"]=[r]
2116             elif key=="numbers":
2117                 result['numbers']=mergenumberlists(o['numbers'], i['numbers'])
2118             elif key=="urls":
2119                 result['urls']=mergefields(o['urls'], i['urls'], 'url', cleaner=cleanurl)
2120             elif key=="emails":
2121                 result['emails']=mergefields(o['emails'], i['emails'], 'email', cleaner=cleanemail)
2122             else:
2123                 result[key]=common.list_union(o[key], i[key])
2124 
2125         return result
2126         
2127     def OnCellSelect(self, event=None):
2128         if event is not None:
2129             event.Skip()
2130             row=self.table.GetRowData(event.GetRow())
2131         else:
2132             gcr=self.grid.GetGridCursorRow()
2133             if gcr>=0 and gcr<self.grid.GetNumberRows():
2134                 row=self.table.GetRowData(gcr)
2135             else: # table is empty
2136                 row=None,None,None,None
2137                 
2138         confidence,importid,existingid,resultid=row
2139         if resultid is not None:
2140             self.resultpreview.ShowEntry(self.resultdata[resultid])
2141         else:
2142             self.resultpreview.ShowEntry({})
2143 
2144     # menu and right click handling
2145 
2146     ID_EDIT_ITEM=wx.NewId()
2147     ID_REVERT_TO_IMPORTED=wx.NewId()
2148     ID_REVERT_TO_EXISTING=wx.NewId()
2149     ID_CLEAR_FIELD=wx.NewId()
2150     ID_IMPORTED_MISMATCH=wx.NewId()
2151     
2152     def MakeMenus(self):
2153         menu=wx.Menu()
2154         menu.Append(self.ID_EDIT_ITEM, "Edit...")
2155         menu.Append(self.ID_REVERT_TO_EXISTING, "Revert field to existing value")
2156         menu.Append(self.ID_REVERT_TO_IMPORTED, "Revert field to imported value")
2157         menu.Append(self.ID_CLEAR_FIELD, "Clear field")
2158         menu.AppendSeparator()
2159         menu.Append(self.ID_IMPORTED_MISMATCH, "Imported entry mismatch...")
2160         self.menu=menu
2161 
2162         wx.EVT_MENU(menu, self.ID_EDIT_ITEM, self.OnEditItem)
2163         wx.EVT_MENU(menu, self.ID_REVERT_TO_EXISTING, self.OnRevertFieldToExisting)
2164         wx.EVT_MENU(menu, self.ID_REVERT_TO_IMPORTED, self.OnRevertFieldToImported)
2165         wx.EVT_MENU(menu, self.ID_CLEAR_FIELD, self.OnClearField)
2166         wx.EVT_MENU(menu, self.ID_IMPORTED_MISMATCH, self.OnImportedMismatch)
2167 
2168     def OnRightGridClick(self, event):
2169         row,col=event.GetRow(), event.GetCol()
2170         self.grid.SetGridCursor(row,col)
2171         self.grid.ClearSelection()
2172         # enable/disable stuff in the menu
2173         columnname=self.table.GetColLabelValue(col)
2174         _, importkey, existingkey, resultkey=self.table.GetRowData(row)
2175 
2176 
2177         if columnname=="Confidence":
2178             self.menu.Enable(self.ID_REVERT_TO_EXISTING, False)
2179             self.menu.Enable(self.ID_REVERT_TO_IMPORTED, False)
2180             self.menu.Enable(self.ID_CLEAR_FIELD, False)
2181         else:
2182             resultvalue=None
2183             if resultkey is not None:
2184                 resultvalue=getdata(columnname, self.resultdata[resultkey], None)
2185 
2186             self.menu.Enable(self.ID_REVERT_TO_EXISTING, existingkey is not None 
2187                                  and getdata(columnname, self.existingdata[existingkey], None)!= resultvalue)
2188             self.menu.Enable(self.ID_REVERT_TO_IMPORTED, importkey is not None
2189                              and getdata(columnname, self.importdata[importkey], None) != resultvalue)
2190             self.menu.Enable(self.ID_CLEAR_FIELD, True)
2191 
2192         self.menu.Enable(self.ID_IMPORTED_MISMATCH, importkey is not None)
2193         # pop it up
2194         pos=event.GetPosition()
2195         self.grid.PopupMenu(self.menu, pos)
2196 
2197     def OnEditItem(self,_):
2198         self.EditEntry(self.grid.GetGridCursorRow(), self.grid.GetGridCursorCol())
2199 
2200     def OnRevertFieldToExisting(self, _):
2201         row,col=self.grid.GetGridCursorRow(), self.grid.GetGridCursorCol()
2202         columnname=self.table.GetColLabelValue(col)
2203         row=self.table.GetRowData(row)
2204         reskey,resindex=getdatainfo(columnname, self.resultdata[row[3]])
2205         exkey,exindex=getdatainfo(columnname, self.existingdata[row[2]])
2206         if exindex is None:
2207             # actually need to clear the field
2208             self.OnClearField(None)
2209             return
2210         if resindex is None:
2211             self.resultdata[row[3]][reskey].append(copy.deepcopy(self.existingdata[row[2]][exkey][exindex]))
2212         elif resindex<0:
2213             self.resultdata[row[3]][reskey]=copy.deepcopy(self.existingdata[row[2]][exkey])
2214         else:
2215             self.resultdata[row[3]][reskey][resindex]=copy.deepcopy(self.existingdata[row[2]][exkey][exindex])
2216         self.table.OnDataUpdated()
2217 
2218     def OnRevertFieldToImported(self, _):
2219         row,col=self.grid.GetGridCursorRow(), self.grid.GetGridCursorCol()
2220         columnname=self.table.GetColLabelValue(col)
2221         row=self.table.GetRowData(row)
2222         reskey,resindex=getdatainfo(columnname, self.resultdata[row[3]])
2223         imkey,imindex=getdatainfo(columnname, self.importdata[row[1]])
2224         assert imindex is not None
2225         if resindex is None:
2226              self.resultdata[row[3]][reskey].append(copy.deepcopy(self.importdata[row[1]][imkey][imindex]))
2227         elif resindex<0:
2228             self.resultdata[row[3]][reskey]=copy.deepcopy(self.importdata[row[1]][imkey])
2229         else:
2230             self.resultdata[row[3]][reskey][resindex]=copy.deepcopy(self.importdata[row[1]][imkey][imindex])
2231         self.table.OnDataUpdated()
2232 
2233     def OnClearField(self, _):
2234         row,col=self.grid.GetGridCursorRow(), self.grid.GetGridCursorCol()
2235         columnname=self.table.GetColLabelValue(col)
2236         row=self.table.GetRowData(row)
2237         reskey,resindex=getdatainfo(columnname, self.resultdata[row[3]])
2238         assert resindex is not None
2239         if resindex<0:
2240             del self.resultdata[row[3]][reskey]
2241         else:
2242             del self.resultdata[row[3]][reskey][resindex]
2243         self.table.OnDataUpdated()
2244 
2245     def OnImportedMismatch(self,_):
2246         # what are we currently matching
2247         row=self.grid.GetGridCursorRow()
2248         _,ourimportkey,existingmatchkey,resultkey=self.table.GetRowData(row)
2249         match=None
2250         # what are the choices
2251         choices=[]
2252         for row in range(self.table.GetNumberRows()):
2253             _,_,existingkey,_=self.table.GetRowData(row)
2254             if existingkey is not None:
2255                 if existingmatchkey==existingkey:
2256                     match=len(choices)
2257                 choices.append( (getdata("Name", self.existingdata[existingkey], "<blank>"), existingkey) )
2258         
2259         with guihelper.WXDialogWrapper(ImportedEntryMatchDialog(self, choices, match),
2260                                        True) as (dlg, retcode):
2261             if retcode==wx.ID_OK:
2262                 confidence,importkey,existingkey,resultkey=self.table.GetRowData(self.grid.GetGridCursorRow())
2263                 assert importkey is not None
2264                 match=dlg.GetMatch()
2265                 if match is None:
2266                     # new entry
2267                     if existingkey is None:
2268                         wx.MessageBox("It is already a new entry!", wx.OK|wx.ICON_EXCLAMATION)
2269                         return
2270                     # make a new entry
2271                     for rowdatakey in xrange(100000):
2272                         if rowdatakey not in self.rowdata:
2273                             for resultdatakey in xrange(100000):
2274                                 if resultdatakey not in self.resultdata:
2275                                     self.rowdata[rowdatakey]=("", importkey, None, resultdatakey)
2276                                     self.resultdata[resultdatakey]=copy.deepcopy(self.importdata[importkey])
2277                                     # revert original one back
2278                                     self.resultdata[resultkey]=copy.deepcopy(self.existingdata[existingkey])
2279                                     self.rowdata[self.table.rowkeys[self.grid.GetGridCursorRow()]]=("", None, existingkey, resultkey)
2280                                     self.table.OnDataUpdated()
2281                                     return
2282                     assert False, "You really can't get here!"
2283                 # match an existing entry
2284                 ekey=choices[match][1]
2285                 if ekey==existingkey:
2286                     wx.MessageBox("That is already the entry matched!", wx.OK|wx.ICON_EXCLAMATION)
2287                     return
2288                 # find new match
2289                 for r in range(self.table.GetNumberRows()):
2290                     if r==self.grid.GetGridCursorRow(): continue
2291                     confidence,importkey,existingkey,resultkey=self.table.GetRowData(r)
2292                     if existingkey==ekey:
2293                         if importkey is not None:
2294                             wx.MessageBox("The new match already has an imported entry matching it!", "Already matched", wx.OK|wx.ICON_EXCLAMATION, self)
2295                             return
2296                         # clear out old match
2297                         del self.rowdata[self.table.rowkeys[self.grid.GetGridCursorRow()]]
2298                         # put in new one
2299                         self.rowdata[self.table.rowkeys[r]]=(confidence, ourimportkey, ekey, resultkey)
2300                         self.resultdata[resultkey]=self.MergeEntries(
2301                             copy.deepcopy(self.existingdata[ekey]),
2302                             copy.deepcopy(self.importdata[ourimportkey]))
2303                         self.table.OnDataUpdated()
2304                         return
2305                 assert False, "Can't get here"
2306 
2307     def OnCellDClick(self, event):
2308         self.EditEntry(event.GetRow(), event.GetCol())
2309 
2310     def EditEntry(self, row, col=None):
2311         row=self.table.GetRowData(row)
2312         k=row[3]
2313         # if k is none then this entry has been deleted.  fix this ::TODO::
2314         assert k is not None
2315         data=self.resultdata[k]
2316         if col is not None:
2317             columnname=self.table.GetColLabelValue(col)
2318             if columnname=="Confidence":
2319                 columnname="Name"
2320         else:
2321             columnname="Name"
2322         datakey, dataindex=getdatainfo(columnname, data)
2323         with guihelper.WXDialogWrapper(phonebookentryeditor.Editor(self, data, keytoopenon=datakey, dataindex=dataindex),
2324                                        True) as (dlg, retcode):
2325             if retcode==wx.ID_OK:
2326                 data=dlg.GetData()
2327                 self.resultdata[k]=data
2328                 self.table.OnDataUpdated()
2329 
2330 class ImportedEntryMatchDialog(wx.Dialog):
2331     "The dialog shown to select how an imported entry should match"
2332     
2333     def __init__(self, parent, choices, match):
2334         wx.Dialog.__init__(self, parent, id=-1, title="Select Import Entry Match", style=wx.CAPTION|
2335              wx.SYSTEM_MENU|wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER)
2336 
2337         self.choices=choices
2338         self.importdialog=parent
2339 
2340         vbs=wx.BoxSizer(wx.VERTICAL)
2341         hbs=wx.BoxSizer(wx.HORIZONTAL)
2342         self.matchexisting=wx.RadioButton(self, wx.NewId(), "Matches an existing entry below", style=wx.RB_GROUP)
2343         self.matchnew=wx.RadioButton(self, wx.NewId(), "Is a new entry")
2344         hbs.Add(self.matchexisting, wx.NewId(), wx.ALIGN_CENTRE|wx.ALL, 5)
2345         hbs.Add(self.matchnew, wx.NewId(), wx.ALIGN_CENTRE|wx.ALL, 5)
2346         vbs.Add(hbs, 0, wx.EXPAND|wx.ALL, 5)
2347 
2348         wx.EVT_RADIOBUTTON(self, self.matchexisting.GetId(), self.OnRBClicked)
2349         wx.EVT_RADIOBUTTON(self, self.matchnew.GetId(), self.OnRBClicked)
2350 
2351         splitter=wx.SplitterWindow(self, -1, style=wx.SP_3D|wx.SP_LIVE_UPDATE)
2352         self.nameslb=wx.ListBox(splitter, wx.NewId(), choices=[name for name,id in choices], style=wx.LB_SINGLE|wx.LB_NEEDED_SB)
2353         self.preview=PhoneEntryDetailsView(splitter, -1)
2354         splitter.SplitVertically(self.nameslb, self.preview)
2355         vbs.Add(splitter, 1, wx.EXPAND|wx.ALL, 5)
2356 
2357         vbs.Add(wx.StaticLine(self, -1, style=wx.LI_HORIZONTAL), 0, wx.EXPAND|wx.ALL, 5)
2358 
2359         vbs.Add(self.CreateButtonSizer(wx.OK|wx.CANCEL|wx.HELP), 0, wx.ALIGN_CENTRE|wx.ALL, 5)
2360 
2361         wx.EVT_LISTBOX(self, self.nameslb.GetId(), self.OnLbClicked)
2362         wx.EVT_LISTBOX_DCLICK(self, self.nameslb.GetId(), self.OnLbDClicked)
2363 
2364         # set values
2365         if match is None:
2366             self.matchexisting.SetValue(False)
2367             self.matchnew.SetValue(True)
2368             self.nameslb.Enable(False)
2369         else:
2370             self.matchexisting.SetValue(True)
2371             self.matchnew.SetValue(False)
2372             self.nameslb.Enable(True)
2373             self.nameslb.SetSelection(match)
2374             self.preview.ShowEntry(self.importdialog.existingdata[choices[match][1]])
2375 
2376         self.SetSizer(vbs)
2377         self.SetAutoLayout(True)
2378         guiwidgets.set_size("PhonebookImportEntryMatcher", self, screenpct=75, aspect=0.58)
2379 
2380         wx.EVT_MENU(self, wx.ID_OK, self.SaveSize)
2381         wx.EVT_MENU(self, wx.ID_CANCEL, self.SaveSize)
2382 
2383 
2384     def SaveSize(self, evt=None):
2385         if evt is not None:
2386             evt.Skip()
2387         guiwidgets.save_size("PhonebookImportEntryMatcher", self.GetRect())
2388 
2389     def OnRBClicked(self, _):
2390         self.nameslb.Enable(self.matchexisting.GetValue())
2391 
2392     def OnLbClicked(self,_=None):
2393         existingid=self.choices[self.nameslb.GetSelection()][1]
2394         self.preview.ShowEntry(self.importdialog.existingdata[existingid])
2395 
2396     def OnLbDClicked(self,_):
2397         self.OnLbClicked()
2398         self.SaveSize()
2399         self.EndModal(wx.ID_OK)
2400 
2401     def GetMatch(self):
2402         if self.matchnew.GetValue():
2403             return None # new entry
2404         return self.nameslb.GetSelection() 
2405 
2406 def dictintersection(one,two):
2407     return filter(two.has_key, one.keys())
2408 
2409 class EntryMatcher:
2410     "Implements matching phonebook entries"
2411 
2412     def __init__(self, sources, against):
2413         self.sources=sources
2414         self.against=against
2415 
2416     def bestmatches(self, sourceid, limit=5):
2417         """Gives best matches out of against list
2418 
2419         @return: list of tuples of (percent match, againstid)
2420         """
2421 
2422         res=[]
2423 
2424         source=self.sources[sourceid]
2425         for i in self.against:
2426             against=self.against[i]
2427 
2428             # now get keys source and against have in common
2429             intersect=dictintersection(source,against)
2430 
2431             # overall score for this match
2432             score=0
2433             count=0
2434             for key in intersect:
2435                 s=source[key]
2436                 a=against[key]
2437                 count+=1
2438                 if s==a:
2439                     score+=40*len(s)
2440                     continue
2441                 
2442                 if key=="names":
2443                     score+=comparenames(s,a)
2444                 elif key=="numbers":
2445                     score+=comparenumbers(s,a)
2446                 elif key=="urls":
2447                     score+=comparefields(s,a,"url")
2448                 elif key=="emails":
2449                     score+=comparefields(s,a,"email")
2450                 elif key=="addresses":
2451                     score+=compareallfields(s,a, ("company", "street", "street2", "city", "state", "postalcode", "country"))
2452                 else:
2453                     # ignore it
2454                     count-=1
2455 
2456             if count:
2457                 res.append( ( int(score*100/count), i ) )
2458 
2459         res.sort()
2460         res.reverse()
2461         if len(res)>limit:
2462             return res[:limit]
2463         return res
2464 
2465 def comparenames(s,a):
2466     "Give a score on two names"
2467     return (jarowinkler(nameparser.formatsimplename(s[0]), nameparser.formatsimplename(a[0]))-0.6)*10
2468 
2469 def cleanurl(url, mode="compare"):
2470     """Returns lowercase url with the "http://" prefix removed and in lower case
2471     
2472     @param mode: If the value is compare (default), it removes ""http://www.""
2473                  in preparation for comparing entries. Otherwise, if the value
2474                  is pb, the result is formatted for writing to the phonebook.
2475     """
2476     if mode == "compare":
2477         urlprefix=re.compile("^(http://)?(www.)?")
2478     else: urlprefix=re.compile("^(http://)?")
2479     
2480     return default_cleaner(re.sub(urlprefix, "", url).lower())
2481 
2482 def cleanemail(email, mode="compare"):
2483     """Returns lowercase email 
2484     """
2485     return default_cleaner(email.lower())
2486 
2487 
2488 nondigits=re.compile("[^0-9]")
2489 def cleannumber(num):
2490     "Returns num (a phone number) with all non-digits removed"
2491     return re.sub(nondigits, "", num)
2492 
2493 def comparenumbers(s,a):
2494     """Give a score on two phone numbers
2495 
2496     """
2497 
2498     ss=[cleannumber(x['number']) for x in s]
2499     aa=[cleannumber(x['number']) for x in a]
2500 
2501     candidates=[]
2502     for snum in ss:
2503         for anum in aa:
2504             candidates.append( (jarowinkler(snum, anum), snum, anum) )
2505 
2506     candidates.sort()
2507     candidates.reverse()
2508 
2509     if len(candidates)>3:
2510         candidates=candidates[:3]
2511 
2512     score=0
2513     # we now have 3 best matches
2514     for ratio,snum,anum in candidates:
2515         if ratio>0.9:
2516             score+=(ratio-0.9)*10
2517 
2518     return score
2519 
2520 def comparefields(s,a,valuekey,threshold=0.8,lookat=3):
2521     """Compares the valuekey field in source and against lists returning a score for closeness of match"""
2522     ss=[x[valuekey] for x in s if x.has_key(valuekey)]
2523     aa=[x[valuekey] for x in a if x.has_key(valuekey)]
2524 
2525     candidates=[]
2526     for sval in ss:
2527         for aval in aa:
2528             candidates.append( (jarowinkler(sval, aval), sval, aval) )
2529 
2530     candidates.sort()
2531     candidates.reverse()
2532 
2533     if len(candidates)>lookat:
2534         candidates=candidates[:lookat]
2535 
2536     score=0
2537     # we now have 3 best matches
2538     for ratio,sval,aval in candidates:
2539         if ratio>threshold:
2540             score+=(ratio-threshold)*10/(1-threshold)
2541 
2542     return score
2543     
2544 def compareallfields(s,a,fields,threshold=0.8,lookat=3):
2545     """Like comparefields, but for source and against lists where multiple keys have values in each item
2546 
2547     @param fields: This should be a list of keys from the entries that are in the order the human
2548                    would write them down."""
2549 
2550     # we do it in "write them down order" as that means individual values that move don't hurt the matching
2551     # much  (eg if the town was wrongly in address2 and then moved into town, the concatenated string would
2552     # still be the same and it would still be an appropriate match)
2553     args=[]
2554     for d in s,a:
2555         str=""
2556         list=[]
2557         for entry in d:
2558             for f in fields:
2559                 # we merge together the fields space separated in order to make one long string from the values
2560                 if entry.has_key(f):
2561                     str+=entry.get(f)+"  "
2562             list.append( {'value': str} )
2563         args.append( list )
2564     # and then give the result to comparefields
2565     args.extend( ['value', threshold, lookat] )
2566     return comparefields(*args)
2567 
2568 def mergenumberlists(orig, imp):
2569     """Return the results of merging two lists of numbers
2570 
2571     We compare the sanitised numbers (ie after punctuation etc is stripped
2572     out).  If they are the same, then the original is kept (since the number
2573     is the same, and the original most likely has the correct punctuation).
2574 
2575     Otherwise the imported entries overwrite the originals
2576     """
2577     # results start with existing entries
2578     res=[]
2579     res.extend(orig)
2580     # look through each imported number
2581     for i in imp:
2582         num=cleannumber(i['number'])
2583         found=False
2584         for r in res:
2585             if num==cleannumber(r['number']):
2586                 # an existing entry was matched so we stop
2587                 found=True
2588                 if i.has_key('speeddial'):
2589                     r['speeddial']=i['speeddial']
2590                 break
2591         if found:
2592             continue
2593 
2594         # we will be replacing one of the same type
2595         found=False
2596         for r in res:
2597             if i['type']==r['type']:
2598                 r['number']=i['number']
2599                 if i.has_key('speeddial'):
2600                     r['speeddial']=i['speeddial']
2601                 found=True
2602                 break
2603         if found:
2604             continue
2605         # ok, just add it on the end then
2606         res.append(i)
2607 
2608     return res
2609 
2610 # new jaro winkler implementation doesn't use '*' chars or similar mangling
2611 default_cleaner=lambda x: x
2612 
2613 def mergefields(orig, imp, field, threshold=0.88, cleaner=default_cleaner):
2614     """Return the results of merging two lists of fields
2615 
2616     We compare the fields. If they are the same, then the original is kept
2617     (since the name is the same, and the original most likely has the 
2618     correct punctuation).
2619 
2620     Otherwise the imported entries overwrite the originals
2621     """
2622     # results start with existing entries
2623     res=[]
2624     res.extend(orig)
2625     # look through each imported field
2626     for i in imp:
2627 
2628         impfield=cleaner(i[field])
2629         
2630         found=False
2631         for r in res:
2632             # if the imported entry is similar or the same as the
2633             # original entry, then we stop
2634             
2635             # add code for short or long lengths
2636             # since cell phones usually have less than 16-22 chars max per field
2637 
2638             resfield=cleaner(r[field])
2639 
2640             if (comparestrings(resfield, impfield) > threshold):
2641                 # an existing entry was matched so we stop
2642                 found=True
2643                 
2644                 # since new item matches, we don't need to replace the
2645                 # original value, but we should update the type of item
2646                 # to reflect the imported value
2647                 # for example home --> business
2648                 if i.has_key('type'):
2649                     r['type'] = i['type']
2650                 
2651                 # break out of original item loop
2652                 break
2653         
2654         # if we have found the item to be imported, we can move to the next one
2655         if found:
2656             continue
2657 
2658         # since there is no matching item, we will replace the existing item
2659         # if a matching type exists
2660         found=False
2661         for r in res:
2662             if (i.has_key('type') and r.has_key('type')):
2663                 if i['type']==r['type']:
2664                     # write the field entry in the way the phonebook expects it
2665                     r[field]=cleaner(i[field], "pb")
2666                     found=True
2667                     break
2668         if found:
2669             continue
2670         # add new item on the end if there no matching type
2671         # and write the field entry in the way the phonebook expects it
2672         i[field] = cleaner(i[field], "pb")
2673         res.append(i)
2674 
2675     return res
2676 
2677 import native.strings
2678 jarowinkler=native.strings.jarow
2679 
2680 def comparestrings(origfield, impfield):
2681     """ Compares two strings and returns the score using 
2682     winkler routine from Febrl (stringcmp.py)
2683     
2684     Return value is between 0.0 and 1.0, where 0.0 means no similarity
2685     whatsoever, and 1.0 means the strings match exactly."""
2686     return jarowinkler(origfield, impfield, 16)
2687 
2688 def normalise_data(entries):
2689     for k in entries:
2690         # we only know about phone numbers so far ...
2691         for n in entries[k].get("numbers", []):
2692             n["number"]=phonenumber.normalise(n["number"])
2693 
2694 class ColumnSelectorDialog(wx.Dialog):
2695     "The dialog for selecting what columns you want to view"
2696 
2697     ID_SHOW=wx.NewId()
2698     ID_AVAILABLE=wx.NewId()
2699     ID_UP=wx.NewId()
2700     ID_DOWN=wx.NewId()
2701     ID_ADD=wx.NewId()
2702     ID_REMOVE=wx.NewId()
2703     ID_DEFAULT=wx.NewId()
2704 
2705     def __init__(self, parent, config, phonewidget):
2706         wx.Dialog.__init__(self, parent, id=-1, title="Select Columns to view", style=wx.CAPTION|
2707                  wx.SYSTEM_MENU|wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER)
2708 
2709         self.config=config
2710         self.phonewidget=phonewidget
2711         hbs=wx.BoxSizer(wx.HORIZONTAL)
2712 
2713         # the show bit
2714         bs=wx.BoxSizer(wx.VERTICAL)
2715         bs.Add(wx.StaticText(self, -1, "Showing"), 0, wx.ALIGN_CENTRE|wx.ALL, 5)
2716         self.show=wx.ListBox(self, self.ID_SHOW, style=wx.LB_SINGLE|wx.LB_NEEDED_SB, size=(250, 300))
2717         bs.Add(self.show, 1, wx.EXPAND|wx.ALL, 5)
2718         hbs.Add(bs, 1, wx.EXPAND|wx.ALL, 5)
2719 
2720         # the column of buttons
2721         bs=wx.BoxSizer(wx.VERTICAL)
2722         self.up=wx.Button(self, self.ID_UP, "Move Up")
2723         self.down=wx.Button(self, self.ID_DOWN, "Move Down")
2724         self.add=wx.Button(self, self.ID_ADD, "Show")
2725         self.remove=wx.Button(self, self.ID_REMOVE, "Don't Show")
2726         self.default=wx.Button(self, self.ID_DEFAULT, "Default")
2727 
2728         for b in self.up, self.down, self.add, self.remove, self.default:
2729             bs.Add(b, 0, wx.ALL|wx.ALIGN_CENTRE, 10)
2730 
2731         hbs.Add(bs, 0, wx.ALL|wx.ALIGN_CENTRE, 5)
2732 
2733         # the available bit
2734         bs=wx.BoxSizer(wx.VERTICAL)
2735         bs.Add(wx.StaticText(self, -1, "Available"), 0, wx.ALIGN_CENTRE|wx.ALL, 5)
2736         self.available=wx.ListBox(self, self.ID_AVAILABLE, style=wx.LB_EXTENDED|wx.LB_NEEDED_SB, choices=AvailableColumns)
2737         bs.Add(self.available, 1, wx.EXPAND|wx.ALL, 5)
2738         hbs.Add(bs, 1, wx.EXPAND|wx.ALL, 5)
2739 
2740         # main layout
2741         vbs=wx.BoxSizer(wx.VERTICAL)
2742         vbs.Add(hbs, 1, wx.EXPAND|wx.ALL, 5)
2743         vbs.Add(wx.StaticLine(self, -1, style=wx.LI_HORIZONTAL), 0, wx.EXPAND|wx.ALL, 5)
2744         vbs.Add(self.CreateButtonSizer(wx.OK|wx.CANCEL|wx.HELP), 0, wx.ALIGN_CENTRE|wx.ALL, 5)
2745 
2746         self.SetSizer(vbs)
2747         vbs.Fit(self)
2748 
2749         # fill in current selection
2750         cur=self.config.Read("phonebookcolumns", "")
2751         if len(cur):
2752             cur=cur.split(",")
2753             # ensure they all exist
2754             cur=[c for c in cur if c in AvailableColumns]
2755         else:
2756             cur=DefaultColumns
2757         self.show.Set(cur)
2758 
2759         # buttons, events etc
2760         self.up.Disable()
2761         self.down.Disable()
2762         self.add.Disable()
2763         self.remove.Disable()
2764 
2765         wx.EVT_LISTBOX(self, self.ID_SHOW, self.OnShowClicked)
2766         wx.EVT_LISTBOX_DCLICK(self, self.ID_SHOW, self.OnShowClicked)
2767         wx.EVT_LISTBOX(self, self.ID_AVAILABLE, self.OnAvailableClicked)
2768         wx.EVT_LISTBOX_DCLICK(self, self.ID_AVAILABLE, self.OnAvailableDClicked)
2769 
2770         wx.EVT_BUTTON(self, self.ID_ADD, self.OnAdd)
2771         wx.EVT_BUTTON(self, self.ID_REMOVE, self.OnRemove)
2772         wx.EVT_BUTTON(self, self.ID_UP, self.OnUp)
2773         wx.EVT_BUTTON(self, self.ID_DOWN, self.OnDown)
2774         wx.EVT_BUTTON(self, self.ID_DEFAULT, self.OnDefault)
2775         wx.EVT_BUTTON(self, wx.ID_OK, self.OnOk)
2776 
2777     def OnShowClicked(self, _=None):
2778         self.up.Enable(self.show.GetSelection()>0)
2779         self.down.Enable(self.show.GetSelection()<self.show.GetCount()-1)
2780         self.remove.Enable(self.show.GetCount()>0)
2781         self.FindWindowById(wx.ID_OK).Enable(self.show.GetCount()>0)
2782 
2783     def OnAvailableClicked(self, _):
2784         self.add.Enable(True)
2785 
2786     def OnAvailableDClicked(self, _):
2787         self.OnAdd()
2788 
2789     def OnAdd(self, _=None):
2790         items=[AvailableColumns[i] for i in self.available.GetSelections()]
2791         for i in self.available.GetSelections():
2792             self.available.Deselect(i)
2793         self.add.Disable()
2794         it=self.show.GetSelection()
2795         if it>=0:
2796             self.show.Deselect(it)
2797             it+=1
2798         else:
2799             it=self.show.GetCount()
2800         self.show.InsertItems(items, it)
2801         self.remove.Disable()
2802         self.up.Disable()
2803         self.down.Disable()
2804         self.show.SetSelection(it)
2805         self.OnShowClicked()
2806 
2807     def OnRemove(self, _):
2808         it=self.show.GetSelection()
2809         assert it>=0
2810         self.show.Delete(it)
2811         if self.show.GetCount():
2812             if it==self.show.GetCount():
2813                 self.show.SetSelection(it-1)
2814             else:
2815                 self.show.SetSelection(it)
2816         self.OnShowClicked()
2817 
2818     def OnDefault(self,_):
2819         self.show.Set(DefaultColumns)
2820         self.show.SetSelection(0)
2821         self.OnShowClicked()
2822 
2823     def OnUp(self, _):
2824         it=self.show.GetSelection()
2825         assert it>=1
2826         self.show.InsertItems([self.show.GetString(it)], it-1)
2827         self.show.Delete(it+1)
2828         self.show.SetSelection(it-1)
2829         self.OnShowClicked()
2830 
2831     def OnDown(self, _):
2832         it=self.show.GetSelection()
2833         assert it<self.show.GetCount()-1
2834         self.show.InsertItems([self.show.GetString(it)], it+2)
2835         self.show.Delete(it)
2836         self.show.SetSelection(it+1)
2837         self.OnShowClicked()
2838 
2839     def OnOk(self, event):
2840         cur=[self.show.GetString(i) for i in range(self.show.GetCount())]
2841         self.config.Write("phonebookcolumns", ",".join(cur))
2842         self.config.Flush()
2843         self.phonewidget.SetColumns(cur)
2844         event.Skip()
2845 
2846 class PhonebookPrintDialog(wx.Dialog):
2847 
2848     ID_SELECTED=wx.NewId()
2849     ID_ALL=wx.NewId()
2850     ID_LAYOUT=wx.NewId()
2851     ID_STYLES=wx.NewId()
2852     ID_PRINT=wx.NewId()
2853     ID_PAGESETUP=wx.NewId()
2854     ID_PRINTPREVIEW=wx.NewId()
2855     ID_CLOSE=wx.ID_CANCEL
2856     ID_HELP=wx.NewId()
2857     ID_TEXTSCALE=wx.NewId()
2858     ID_SAVEASHTML=wx.NewId()
2859 
2860     textscales=[ (0.4, "Teeny"), (0.6, "Tiny"), (0.8, "Small"), (1.0, "Normal"), (1.2, "Large"), (1.4, "Ginormous") ]
2861     # we reverse the order so the slider seems more natural
2862     textscales.reverse()
2863 
2864     def __init__(self, phonewidget, mainwindow, config):
2865         wx.Dialog.__init__(self, mainwindow, id=-1, title="Print PhoneBook", style=wx.CAPTION|
2866                  wx.SYSTEM_MENU|wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER)
2867 
2868         self.config=config
2869         self.phonewidget=phonewidget
2870 
2871         # sort out available layouts and styles
2872         # first line is description
2873         self.layoutfiles={}
2874         for _file in guihelper.getresourcefiles("pbpl-*.xy"):
2875             with file(_file, 'rt') as f:
2876                 desc=f.readline().strip()
2877                 self.layoutfiles[desc]=f.read()
2878         self.stylefiles={}
2879         for _file in guihelper.getresourcefiles("pbps-*.xy"):
2880             with file(_file, 'rt') as f:
2881                 desc=f.readline().strip()
2882                 self.stylefiles[desc]=f.read()
2883 
2884         # Layouts
2885         vbs=wx.BoxSizer(wx.VERTICAL)  # main vertical sizer
2886 
2887         hbs=wx.BoxSizer(wx.HORIZONTAL) # first row
2888 
2889         numselected=len(phonewidget.GetSelectedRows())
2890         numtotal=len(phonewidget._data)
2891 
2892         # selection and scale
2893         vbs2=wx.BoxSizer(wx.VERTICAL)
2894         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Rows"), wx.VERTICAL)
2895         self.selected=wx.RadioButton(self, self.ID_SELECTED, "Selected (%d)" % (numselected,), style=wx.RB_GROUP)
2896         self.all=wx.RadioButton(self, self.ID_SELECTED, "All (%d)" % (numtotal,) )
2897         bs.Add(self.selected, 0, wx.EXPAND|wx.ALL, 2)
2898         bs.Add(self.all, 0, wx.EXPAND|wx.ALL, 2)
2899         self.selected.SetValue(numselected>1)
2900         self.all.SetValue(not (numselected>1))
2901         vbs2.Add(bs, 0, wx.EXPAND|wx.ALL, 2)
2902 
2903         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Text Scale"), wx.HORIZONTAL)
2904         for i in range(len(self.textscales)):
2905             if self.textscales[i][0]==1.0:
2906                 sv=i
2907                 break
2908         self.textscaleslider=wx.Slider(self, self.ID_TEXTSCALE, sv, 0, len(self.textscales)-1, style=wx.SL_VERTICAL|wx.SL_AUTOTICKS)
2909         self.scale=1
2910         bs.Add(self.textscaleslider, 0, wx.EXPAND|wx.ALL, 2)
2911         self.textscalelabel=wx.StaticText(self, -1, "Normal")
2912         bs.Add(self.textscalelabel, 0, wx.ALIGN_CENTRE)
2913         vbs2.Add(bs, 1, wx.EXPAND|wx.ALL, 2)
2914         hbs.Add(vbs2, 0, wx.EXPAND|wx.ALL, 2)
2915         
2916         # Sort
2917         self.sortkeyscb=[]
2918         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Sorting"), wx.VERTICAL)
2919         choices=["<None>"]+AvailableColumns
2920         for i in range(3):
2921             bs.Add(wx.StaticText(self, -1, ("Sort by", "Then")[i!=0]), 0, wx.EXPAND|wx.ALL, 2)
2922             self.sortkeyscb.append(wx.ComboBox(self, wx.NewId(), "<None>", choices=choices, style=wx.CB_READONLY))
2923             self.sortkeyscb[-1].SetSelection(0)
2924             bs.Add(self.sortkeyscb[-1], 0, wx.EXPAND|wx.ALL, 2)
2925         hbs.Add(bs, 0, wx.EXPAND|wx.ALL, 4)
2926 
2927         # Layout and style
2928         vbs2=wx.BoxSizer(wx.VERTICAL) # they are on top of each other
2929         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Layout"), wx.VERTICAL)
2930         k=self.layoutfiles.keys()
2931         k.sort()
2932         self.layout=wx.ListBox(self, self.ID_LAYOUT, style=wx.LB_SINGLE|wx.LB_NEEDED_SB|wx.LB_HSCROLL, choices=k, size=(150,-1))
2933         self.layout.SetSelection(0)
2934         bs.Add(self.layout, 1, wx.EXPAND|wx.ALL, 2)
2935         vbs2.Add(bs, 1, wx.EXPAND|wx.ALL, 2)
2936         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Styles"), wx.VERTICAL)
2937         k=self.stylefiles.keys()
2938         self.styles=wx.CheckListBox(self, self.ID_STYLES, choices=k)
2939         bs.Add(self.styles, 1, wx.EXPAND|wx.ALL, 2)
2940         vbs2.Add(bs, 1, wx.EXPAND|wx.ALL, 2)
2941         hbs.Add(vbs2, 1, wx.EXPAND|wx.ALL, 2)
2942 
2943         # Buttons
2944         vbs2=wx.BoxSizer(wx.VERTICAL)
2945         vbs2.Add(wx.Button(self, self.ID_PRINT, "Print"), 0, wx.EXPAND|wx.ALL, 2)
2946         vbs2.Add(wx.Button(self, self.ID_PAGESETUP, "Page Setup..."), 0, wx.EXPAND|wx.ALL, 2)
2947         vbs2.Add(wx.Button(self, self.ID_PRINTPREVIEW, "Print Preview"), 0, wx.EXPAND|wx.ALL, 2)
2948         vbs2.Add(wx.Button(self, self.ID_SAVEASHTML, "Save as HTML"), 0, wx.EXPAND|wx.ALL, 2)
2949         vbs2.Add(wx.Button(self, self.ID_CLOSE, "Close"), 0, wx.EXPAND|wx.ALL, 2)
2950         hbs.Add(vbs2, 0, wx.EXPAND|wx.ALL, 2)
2951 
2952         # wrap up top row
2953         vbs.Add(hbs, 1, wx.EXPAND|wx.ALL, 2)
2954 
2955         # bottom half - preview
2956         bs=wx.StaticBoxSizer(wx.StaticBox(self, -1, "Content Preview"), wx.VERTICAL)
2957         self.preview=bphtml.HTMLWindow(self, -1)
2958         bs.Add(self.preview, 1, wx.EXPAND|wx.ALL, 2)
2959 
2960         # wrap up bottom row
2961         vbs.Add(bs, 2, wx.EXPAND|wx.ALL, 2)
2962 
2963         self.SetSizer(vbs)
2964         vbs.Fit(self)
2965 
2966         # event handlers
2967         wx.EVT_BUTTON(self, self.ID_PRINTPREVIEW, self.OnPrintPreview)
2968         wx.EVT_BUTTON(self, self.ID_PRINT, self.OnPrint)
2969         wx.EVT_BUTTON(self, self.ID_PAGESETUP, self.OnPageSetup)
2970         wx.EVT_BUTTON(self, self.ID_SAVEASHTML, self.OnSaveHTML)
2971         wx.EVT_RADIOBUTTON(self, self.selected.GetId(), self.UpdateHtml)
2972         wx.EVT_RADIOBUTTON(self, self.all.GetId(), self.UpdateHtml)
2973         for i in self.sortkeyscb:
2974             wx.EVT_COMBOBOX(self, i.GetId(), self.UpdateHtml)
2975         wx.EVT_LISTBOX(self, self.layout.GetId(), self.UpdateHtml)
2976         wx.EVT_CHECKLISTBOX(self, self.styles.GetId(), self.UpdateHtml)
2977         wx.EVT_COMMAND_SCROLL(self, self.textscaleslider.GetId(), self.UpdateSlider)
2978         self.UpdateHtml()
2979 
2980     def UpdateSlider(self, evt):
2981         pos=evt.GetPosition()
2982         if self.textscales[pos][0]!=self.scale:
2983             self.scale=self.textscales[pos][0]
2984             self.textscalelabel.SetLabel(self.textscales[pos][1])
2985             self.preview.SetFontScale(self.scale)
2986 
2987     def UpdateHtml(self,_=None):
2988         wx.CallAfter(self._UpdateHtml)
2989 
2990     def _UpdateHtml(self):
2991         self.html=self.GetCurrentHTML()
2992         self.preview.SetPage(self.html)
2993 
2994     @guihelper.BusyWrapper
2995     def GetCurrentHTML(self):
2996         # Setup a nice environment pointing at this module
2997         vars={'phonebook': __import__(__name__) }
2998         # which data do we want?
2999         if self.all.GetValue():
3000             rowkeys=self.phonewidget._data.keys()
3001         else:
3002             rowkeys=self.phonewidget.GetSelectedRowKeys()
3003         # sort the data
3004         # we actually sort in reverse order of what the UI shows in order to get correct results
3005         for keycb in (-1, -2, -3):
3006             sortkey=self.sortkeyscb[keycb].GetValue()
3007             if sortkey=="<None>": continue
3008             # decorate
3009             l=[(getdata(sortkey, self.phonewidget._data[key]), key) for key in rowkeys]
3010             l.sort()
3011             # undecorate
3012             rowkeys=[key for val,key in l]
3013         # finish up vars
3014         vars['rowkeys']=rowkeys
3015         vars['currentcolumns']=self.phonewidget.GetColumns()
3016         vars['data']=self.phonewidget._data
3017         # Use xyaptu
3018         xcp=xyaptu.xcopier(None)
3019         xcp.setupxcopy(self.layoutfiles[self.layout.GetStringSelection()])
3020         html=xcp.xcopywithdns(vars)
3021         # apply styles
3022         sd={'styles': {}, '__builtins__': __builtins__ }
3023         for i in range(self.styles.GetCount()):
3024             if self.styles.IsChecked(i):
3025                 exec self.stylefiles[self.styles.GetString(i)] in sd,sd
3026         try:
3027             html=bphtml.applyhtmlstyles(html, sd['styles'])
3028         except:
3029             if __debug__:
3030                 with file("debug.html", "wt") as f:
3031                     f.write(html)
3032             raise
3033         return html
3034 
3035     def OnPrintPreview(self, _):
3036         wx.GetApp().htmlprinter.PreviewText(self.html, scale=self.scale)
3037 
3038     def OnPrint(self, _):
3039         wx.GetApp().htmlprinter.PrintText(self.html, scale=self.scale)
3040 
3041     def OnPrinterSetup(self, _):
3042         wx.GetApp().htmlprinter.PrinterSetup()
3043 
3044     def OnPageSetup(self, _):
3045         wx.GetApp().htmlprinter.PageSetup()
3046 
3047     def OnSaveHTML(self, _):
3048         with guihelper.WXDialogWrapper(wx.FileDialog(self, wildcard="Web Page (*.htm;*.html)|*.htm;*html",
3049                                                      style=wx.FD_SAVE|wx.FD_OVERWRITE_PROMPT),
3050                                        True) as (_dlg, _retcode):
3051             if _retcode==wx.ID_OK:
3052                 file(_dlg.GetPath(), 'wt').write(self.html)
3053 
3054 def htmlify(string):
3055     return common.strorunicode(string).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br/>")
3056 

Generated by PyXR 0.9.4