Changeset 2167

Show
Ignore:
Timestamp:
06/15/07 06:50:54 (1 year ago)
Author:
abaumann
Message:

Implement support for extended calendar format in new models (ticket #425)
This is untested (because I don't have the hardware) but probably also
breaks things on older models. It'll need revising.

Also some minor changes to XML-related code to correct for
inconsistencies with the vformat converters.

Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • plugins/moto-sync/motosync.py

    r2087 r2167  
    4444SUPPORTED_OBJTYPES = ['event', 'contact'] 
    4545 
    46 CAPABILITIES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
    47 <capabilities> 
    48     <contact> 
    49         <Address /> 
    50         <Birthday /> 
    51         <Categories /> 
    52         <EMail /> 
    53         <FormattedName /> 
    54         <Name /> 
    55         <Nickname /> 
    56         <Telephone /> 
    57     </contact> 
    58     <event> 
    59         <Alarm /> 
    60         <DateEnd /> 
    61         <DateStarted /> 
    62         <Duration /> 
    63         <ExceptionDateTime /> 
    64         <RecurrenceRule /> 
    65         <Summary /> 
    66     </event> 
    67 </capabilities> 
    68 """ 
    69  
    7046# my phone doesn't like it if you read more than this many entries at a time 
    7147ENTRIES_PER_READ = 15 
     
    8460 
    8561# repeat types in the calendar 
     62# XXX FIXME: check order of DATE/DAY for monthly and yearly, my values disagree 
    8663MOTO_REPEAT_NONE = 0 
    8764MOTO_REPEAT_DAILY = 1 
    8865MOTO_REPEAT_WEEKLY = 2 
    89 MOTO_REPEAT_MONTHLY_DATE = 3 
    90 MOTO_REPEAT_MONTHLY_DAY = 4 
    91 MOTO_REPEAT_YEARLY = 5 
     66MOTO_REPEAT_MONTHLY_DAY = 3 
     67MOTO_REPEAT_MONTHLY_DATE = 4 
     68MOTO_REPEAT_YEARLY_DAY = 5 
     69MOTO_REPEAT_YEARLY_DATE = 6 # extended calendar format only 
    9270 
    9371# features we require; these refer to the bits returned by the AT+MAID? command 
     
    10886    4: 'Fax', 
    10987    5: 'Pager', 
     88    8: 'Cellular', # shows up as "Mobile 2", seen on a V3c 
    11089    11: 'Voice' # shows up as "other", seen on a RAZR V3x 
    11190} 
     
    142121} 
    143122 
     123# mapping from phone's event type value to category strings 
     124MOTO_EVENT_TYPES = { 
     125    0: None, 
     126    1: "Private", 
     127    2: "Discussion", 
     128    3: "Meeting", 
     129    4: "Birthday", 
     130    5: "Anniversary", 
     131    6: "Telephone Call", 
     132    7: "Vacation", 
     133    8: "Holiday", 
     134    9: "Entertainment", 
     135    10: "Breakfast", 
     136    11: "Lunch", 
     137    12: "Dinner", 
     138    13: "Education", 
     139    14: "Travel", 
     140    15: "Party", 
     141} 
     142 
     143# reverse of above 
     144REVERSE_MOTO_EVENT_TYPES = {} 
     145for (k, v) in MOTO_EVENT_TYPES.items(): 
     146    if v: 
     147        REVERSE_MOTO_EVENT_TYPES[v.lower()] = k 
     148 
     149MOTO_EVENT_TYPE_NONE = 0 
     150 
    144151# if not one of the above, we use: 
    145152MOTO_CONTACT_DEFAULT = 2 # "Main" 
     
    159166# various fields use 255 as an invalid value 
    160167MOTO_INVALID = 255 
     168MOTO_INVALID_DATE = '00-00-2000' 
     169MOTO_INVALID_TIME = '00:00' 
    161170 
    162171# number of quick-dial entries in the phonebook; we avoid allocating these 
     
    205214    return ''.join([n.data for n in el.childNodes if n.nodeType == n.TEXT_NODE]) 
    206215 
    207 def appendXMLChild(doc, parent, tag, text): 
     216def appendXMLTag(doc, parent, tag, text): 
    208217    """Append a child tag with supplied text content to the parent node.""" 
    209218    if text and text != '': 
     
    211220        e.appendChild(doc.createTextNode(text.encode('utf8'))) 
    212221        parent.appendChild(e) 
     222 
     223def insertXMLNode(parent, newnode): 
     224    """Insert a new node into a parent's list of children, maintaining sort order.""" 
     225    for node in parent.childNodes: 
     226        if newnode.nodeName < node.nodeName: 
     227            parent.insertBefore(newnode, node) 
     228            return 
     229    parent.appendChild(newnode) 
     230 
     231def insertXMLTag(doc, parent, tag, text): 
     232    """Append a child tag with supplied text content to the parent node.""" 
     233    if text and text != '': 
     234        e = doc.createElement(tag) 
     235        e.appendChild(doc.createTextNode(text.encode('utf8'))) 
     236        insertXMLNode(parent, e) 
    213237 
    214238def parse_moto_time(datestr, timestr=None): 
     
    265289        return dt.strftime(format) 
    266290 
    267 def convert_rrule(rulenodes, exdates, exrules, eventdt): 
    268     """Process the recursion rules. Given lists of RecurrenceRule nodes, 
    269     exception dates, exception rules, and the event's date/time, 
    270     returns the motorola recurrence type and a list of exceptions. 
    271  
    272     The general approach we take is: if the recursion can be represented 
    273     by the phone's data structure, we convert it, otherwise we ignore the 
    274     rule completely (to avoid the event showing up at incorrect times). 
    275  
    276     FIXME: this code doesn't yet handle all the tricky parts of RRULE 
    277     specifications (such as BYSETPOS) 
    278     """ 
    279  
    280     # can't support multiple rules 
    281     if len(rulenodes) != 1: 
    282         return (MOTO_REPEAT_NONE, []) 
    283     rulenode = rulenodes[0] 
    284  
    285     # extract rule parts 
    286     freqstr = getXMLField(rulenode, 'Frequency').lower() 
     291def xml_rrule_to_dateutil(xmlnode, eventdt): 
     292    """Convert an rrule from OpenSync XML format to a dateutil.rrule object.""" 
     293 
     294    # convert frequency string 
     295    freqstr = getXMLField(xmlnode, 'Frequency').lower() 
    287296    if freqstr == 'daily': 
    288297        freq = rrule.DAILY 
     
    296305        assert(False, "invalid frequency %s" % freqstr) 
    297306 
    298     until = getXMLField(rulenode, 'Until') 
     307    until = getXMLField(xmlnode, 'Until') 
    299308    if until: 
    300309        until = parse_ical_time(until) 
    301     count = getXMLField(rulenode, 'Count') 
     310 
     311    count = getXMLField(xmlnode, 'Count') 
    302312    if count: 
    303313        count = int(count) 
    304     interval = getXMLField(rulenode, 'Interval') 
     314 
     315    interval = getXMLField(xmlnode, 'Interval') 
    305316    if interval: 
    306317        interval = int(interval) 
    307318 
    308     def getSet(name, convfunc=int): 
    309         """Internal convenience function, get a set of values from XML.""" 
    310         val = getXMLField(rulenode, name) 
    311         if not val: 
     319    def getFieldAsList(name, convfunc=int): 
     320        """Convenience function, get a list of comma-separated values from XML.""" 
     321        val = getXMLField(xmlnode, name) 
     322        if val: 
     323            return map(convfunc, val.split(',')) 
     324        else: 
    312325            return None 
    313         return set(map(convfunc, val.split(','))) 
    314  
    315     bymonth = getSet('ByMonth') 
    316     byweekno = getSet('ByWeekNo') 
    317     byyearday = getSet('ByYearDay') 
    318     bymonthday = getSet('ByMonthDay') 
    319     byday = getSet('ByDay', lambda s: s.upper()) 
    320  
    321     # fail the conversion if any of these are set 
    322     if (getXMLField(rulenode, 'ByHour') or getXMLField(rulenode, 'ByMinute') 
    323         or getXMLField(rulenode, 'BySecond') or getXMLField(rulenode, 'BySetPos') 
    324         or (interval is not None and interval != 1)): 
    325         return (MOTO_REPEAT_NONE, []) 
    326  
    327     # compute the day and week number that the event falls in 
    328     eventday = VCAL_DAYS[eventdt.weekday()] 
    329     if eventdt.day % 7 == 0: 
    330         eventweek = eventdt.day / 7 
    331     else: 
    332         eventweek = eventdt.day / 7 + 1 
    333  
    334     # some convenience variables for the tests below 
    335     byeventweekday = set(['%d%s' % (eventweek, eventday)]) 
    336     byalldays = set(VCAL_DAYS) 
    337     byallmonths = set(range(1, 12)) 
    338  
    339     # now test if the rule matches what we can represent 
    340     if (freq == rrule.DAILY and (not byday or byday == byalldays) 
    341         and not bymonthday and not byyearday and not byweekno 
    342         and not bymonth): 
    343         moto_type = MOTO_REPEAT_DAILY 
    344     elif (freq == rrule.WEEKLY and (not byday or byday == set([eventday])) 
    345             and not bymonthday and not byyearday and not byweekno and 
    346             not bymonth): 
    347         moto_type = MOTO_REPEAT_WEEKLY 
    348     elif (freq == rrule.MONTHLY and not byday 
    349             and (not bymonthday or bymonthday == set([eventdt.day])) 
    350             and not byyearday and not byweekno 
    351             and (not bymonth or bymonth == byallmonths)): 
    352         moto_type = MOTO_REPEAT_MONTHLY_DATE 
    353     elif (freq == rrule.MONTHLY and byday == byeventweekday and not bymonthday 
    354             and not byyearday and not byweekno 
    355             and (not bymonth or bymonth == byallmonths)): 
    356         moto_type = MOTO_REPEAT_MONTHLY_DAY 
    357     elif (freq == rrule.YEARLY and not byday 
    358             and (not bymonthday or bymonthday == set([eventdt.day])) 
    359             and not byyearday and not byweekno 
    360             and (not bymonth or bymonth == set([eventdt.month]))): 
    361         moto_type = MOTO_REPEAT_YEARLY 
    362     else: 
    363         return (MOTO_REPEAT_NONE, []) 
    364  
    365     # phew, looks like we have something the phone can represent 
    366     # now we need to work out the exceptions and see if this event still occurs 
    367  
    368     # convert byday strings to constants needed by rrule 
     326 
     327    bymonth = getFieldAsList('ByMonth') 
     328    byyearday = getFieldAsList('ByYearDay') 
     329    bymonthday = getFieldAsList('ByMonthDay') 
     330    byday = getFieldAsList('ByDay', lambda s: s.upper()) 
     331 
     332    # convert byday strings to the constants needed by rrule 
    369333    byweekday = [] 
    370334    for bydaystr in byday: 
     
    376340            byweekday.append(day) 
    377341 
     342    # done! 
     343    return rrule.rrule(freq, dtstart=eventdt, interval=interval, count=count, 
     344                       until=until, bymonth=bymonth, bymonthday=bymonthday, 
     345                       byweekday=byweekday, byyearday=byyearday) 
     346 
     347def moto_repeat_day_to_weekdays(repeat_day,  eventday): 
     348    """For a weekly repeat, return the list of days the event repeats on. 
     349 
     350    repeat_day is field 19 (repeat on day) in the extended event format 
     351    eventday is the 0-based weekday on which the event occurs 
     352 
     353    This is based on the following description: 
     354 
     355    The weekday of the event (the one that gets repeated of course) gets the 
     356    value 2^6 = 64, the following weekday gets the value 2^5, the after that one 
     357    gets 2^4, etc. 
     358    Field 19 contains the sum of the binary encoded weekdays. 
     359 
     360    For example: lets assume we have an event on Thursday, 04-12-2007 
     361    As the starting weekday is the day of the first event (Thursday), this day 
     362    gets encoded with 2^6, so the encoding for the whole week is as follows: 
     363    thursday 2^6 (1000000), friday 2^5 (0100000), saturday 2^4 (0010000), 
     364    sunday 2^3 (0001000), monday 2^2 (0000100), tuesday 2^1 (0000010), 
     365    wednesday 2^0 (0000001) 
     366    lets say we want to repeat that event every thursday and monday on a weekly basis. 
     367    Field 19 would then contain 2^6 + 2^2 = 68 or in binary form 1000100 
     368    """ 
     369 
     370    if repeat_day == 0: 
     371        return [] 
     372    else: 
     373        eventday = (eventday - 1) % 7 
     374        rest = moto_repeat_day_to_weekdays(repeat_day / 2, eventday) 
     375        if repeat_day % 2: 
     376            return rest + [eventday] 
     377        else: 
     378            return rest 
     379 
     380def moto_weekdays_to_repeat_day(weekdays, eventday): 
     381    """Returns the repeat_day value given a set of days on which a weekly event repeats.""" 
     382 
     383    return sum([2 ** (6 - (day - eventday) % 7) for day in weekdays]) 
     384 
     385def moto_repeat_day_to_monthday(repeat_day): 
     386    """For a monthly or yearly by day repeat, return the day the event repeats on. 
     387 
     388    repeat_day is field 19 (repeat on day) in the extended event format 
     389    returns a tuple (nth, day) 
     390    nth is the Nth time the day occurs in the month 
     391    day is the weekday number (0=monday) 
     392 
     393    This is based on the following description: 
     394 
     395    The weekdays get the following basis encodings : 
     396    sunday = 0 
     397    monday = 8 
     398    tuesday = 16 
     399    wednesday = 24 
     400    thursday = 32 
     401    friday = 40 
     402    saturday = 48 
     403 
     404    The above values are NEVER the value of field 19. Instead the field contains 
     405    a value computed as follows: 
     406    WEEKDAYCODE + 1 = first WEEKDAY of month 
     407    WEEKDAYCODE + 2 = second WEEKDAY of month 
     408    WEEKDAYCODE + 3 = third WEEKDAY of month 
     409    WEEKDAYCODE + 4 = fourth WEEKDAY of month 
     410    WEEKDAYCODE + 5 = last WEEKDAY of month 
     411    """ 
     412 
     413    daynum = (repeat_day / 8 - 1) % 7 
     414    nth = repeat_day % 8 
     415    if nth > 4: 
     416        nth = 4 - nth 
     417    return (nth, daynum) 
     418 
     419# reverse of the above function 
     420def moto_monthday_to_repeat_day(nth, daynum): 
     421    """For a monthly or yearly by day repeat, returns the repeat_day value given 
     422    the Nth time a day occurs in a montyh, and the day of the week number. 
     423    """ 
     424    if nth < 0: 
     425        nth = 4 - nth 
     426    return ((daynum + 1) % 7) * 8 + nth 
     427 
     428def xml_rrule_to_moto(rulenodes, exdates, exrules, eventdt): 
     429    """Process XML recursion rules, converting them to the closest-possible 
     430    matching recursion specifier supported by the phone. 
     431 
     432    Arguments: 
     433      rulenodes: list of RecurrenceRule nodes 
     434      exdates:   list of exception dates 
     435      exrules:   list of exception rules 
     436      eventdt:   event's date (and time if set) 
     437 
     438    Returns a dict containing the following fields: 
     439      repeat_type:  Motorola repeat type (always present) 
     440      repeat_every: repeat every Nth time (valid for the extended format only) 
     441      repeat_day:   bitfield showing which days/weeks to repeat on (extended) 
     442      repeat_end:   end date of the repeat (extended) 
     443      exceptions:   list of exceptions (always present) 
     444 
     445    The general approach we take is: if the recursion can be represented 
     446    by the phone's data structure, we convert it, otherwise we ignore the 
     447    rule completely (to avoid the event showing up at incorrect times) by 
     448    raising an UnsupportedDataError exception. 
     449    """ 
     450 
     451    # default return values 
     452    ret = {} 
     453    ret['repeat_type'] = MOTO_REPEAT_NONE 
     454    ret['repeat_every'] = 0 
     455    ret['repeat_day'] = 0 
     456    ret['repeat_end'] = None 
     457    ret['exceptions'] = [] 
     458 
     459    # can't support multiple rules 
     460    if len(rulenodes) != 1: 
     461        raise UnsupportedDataError('Unhandled recursion: too many rules') 
     462    rulenode = rulenodes[0] 
     463 
     464    freq = getXMLField(rulenode, 'Frequency').upper() 
     465 
     466    interval = getXMLField(rulenode, 'Interval') 
     467    if interval: 
     468        ret['repeat_every'] = int(interval) 
     469 
     470    def getSet(name, convfunc=int): 
     471        """Convenience function, get a comma-separated set of values from XML.""" 
     472        val = getXMLField(rulenode, name) 
     473        if not val: 
     474            return None 
     475        return set(map(convfunc, val.split(','))) 
     476 
     477    bymonth = getSet('ByMonth') 
     478    byyearday = getSet('ByYearDay') 
     479    bymonthday = getSet('ByMonthDay') 
     480    byday = getSet('ByDay', lambda s: s.upper()) 
     481 
     482    # convert byday strings to a list of tuples 
     483    # first tuple elem is the "Nth" part, second is the day number 
     484    # if no int part is present, the "Nth" value is 0 
     485    if byday: 
     486        byday_pairs = [(int(s[:-2] or "0"), VCAL_DAYS.index(s[-2:])) for s in byday] 
     487        if len(byday) == 1: 
     488            (byday_nth, byday_daynum) = byday_pairs[0] 
     489 
     490    # compute the day and week number that the event falls in 
     491    if eventdt.day % 7 == 0: 
     492        eventweek = eventdt.day / 7 
     493    else: 
     494        eventweek = eventdt.day / 7 + 1 
     495 
     496    # convenience variable for the tests below 
     497    byallmonths = set(range(1, 12)) 
     498 
     499    # now test if the rule matches what we can represent 
     500    if (freq == 'DAILY' and not byday and not bymonthday and not byyearday and not bymonth): 
     501        ret['repeat_type'] = MOTO_REPEAT_DAILY 
     502    elif (freq == 'WEEKLY' and not bymonthday and not byyearday and not bymonth): 
     503        ret['repeat_type'] = MOTO_REPEAT_WEEKLY 
     504        if byday: 
     505            # the Nth specifier doesn't make sense on a byday value in a weekly rule 
     506            # so we can ignore them 
     507            byday_nums = [daynum for (_, daynum) in byday_pairs] 
     508            if byday_nums != [eventdt.day]: 
     509                ret['repeat_day'] = moto_weekdays_to_repeat_day(byday_nums, eventdt.day) 
     510    elif (freq == 'MONTHLY' and not byday and (not bymonthday or bymonthday == set([eventdt.day])) 
     511            and not byyearday and (not bymonth or bymonth == byallmonths)): 
     512        ret['repeat_type'] = MOTO_REPEAT_MONTHLY_DATE 
     513    elif (freq == 'MONTHLY' and byday and len(byday) == 1 and not bymonthday 
     514            and not byyearday and (not bymonth or bymonth == byallmonths)): 
     515        ret['repeat_type'] = MOTO_REPEAT_MONTHLY_DAY 
     516        if not (byday_daynum == eventdt.weekday() and byday_nth == eventweek): 
     517            ret['repeat_day'] = moto_monthday_to_repeat_day(byday_nth, byday_daynum) 
     518    elif (freq == 'YEARLY' and not byday and (not bymonthday or bymonthday == set([eventdt.day])) 
     519            and not byyearday and (not bymonth or bymonth == set([eventdt.month]))): 
     520        ret['repeat_type'] = MOTO_REPEAT_YEARLY_DATE 
     521    elif (freq == 'YEARLY' and byday and len(byday == 1) 
     522            and (not bymonthday or bymonthday == set([eventdt.day])) 
     523            and not byyearday and (not bymonth or bymonth == set([eventdt.month]))): 
     524        ret['repeat_type'] = MOTO_REPEAT_YEARLY_DAY 
     525        if not (byday_daynum == eventdt.weekday() and byday_nth == eventweek): 
     526            ret['repeat_day'] = moto_monthday_to_repeat_day(byday_nth, byday_daynum) 
     527    else: 
     528        raise UnsupportedDataError('Unhandled recursion: cannot represent rule') 
     529 
     530    # phew, looks like we have something the phone can represent 
     531    # now we need to work out the exceptions and see if this event still occurs 
     532 
    378533    # create an rrule object for this rule, and an rrule set 
    379     ruleobj = rrule.rrule(freq, dtstart=eventdt, count=count, until=until, 
    380                           bymonth=list(bymonth), bymonthday=list(bymonthday), 
    381                           byweekday=byweekday, byyearday=list(byyearday), 
    382                           byweekno=list(byweekno)) 
    383534    ruleset = rrule.rruleset() 
    384     ruleset.rrule(ruleobj) 
    385  
    386     # are there any future occurrences of this event? 
     535    ruleset.rrule(xml_rrule_to_dateutil(rulenode, eventdt)) 
     536 
     537    # if the rule ends, get its last occurrence 
     538    if getXMLField(rulenode, 'Until') or getXMLField(rulenode, 'Count'): 
     539        ret['repeat_until'] = ruleset[-1] 
     540 
     541    # what events would happen if there were no exceptions? 
    387542    now = datetime.now() 
    388     if ruleset.after(now) == None: 
    389         return (MOTO_REPEAT_NONE, []) 
    390  
    391     # what events would happen if there were no exceptions? 
    392543    all_occurrences = ruleset.between(now, now + RRULE_FUTURE) 
    393544 
     
    397548            ruleset.exdate(dateutil.parser.parse(getXMLText(e))) 
    398549 
    399     # XXX FIXME: totally different rrule format 
    400     #for node in exrules: 
    401     #    ruleset.exrule(rrule.rrulestr(getXMLField(node, 'Content'))) 
     550    for node in exrules: 
     551        ruleset.exrule(xml_rrule_to_dateutil(node, eventdt)) 
    402552 
    403553    # are there any future occurrences of this event if we consider exceptions? 
    404     if ruleset.after(datetime.now()) == None: 
    405         return (MOTO_REPEAT_NONE, []
     554    if ruleset.after(now) == None: 
     555        raise UnsupportedDataError('Unhandled recursion: no future occurrence'
    406556 
    407557    # what events will happen with exceptions 
     
    409559 
    410560    # work out which events don't happen 
    411     exceptions = [] 
    412561    for num in range(all_occurrences): 
    413562        if all_occurrences[num] not in excepted_occurrences: 
    414             exceptions.append(num) 
    415  
    416     return (moto_type, exceptions) 
     563            ret['exceptions'].append(num) 
     564 
     565    return ret 
     566 
     567def moto_rrule_to_xml(doc, eventdt, repeat_type, exceptions, 
     568                      repeat_every=None, repeat_day=None, repeat_end=None): 
     569    """Convert a Motorola-format recurrence to the corresponding XML description. 
     570 
     571    Arguments correspond to the return values of xml_rrule_to_moto above. 
     572 
     573    Returns a list of XML node objects. 
     574    """ 
     575 
     576    # compute the week number that the event falls in 
     577    if eventdt.day % 7 == 0: 
     578        weeknum = eventdt.day / 7 
     579    else: 
     580        weeknum = eventdt.day / 7 + 1 
     581 
     582    e = doc.createElement('RecurrenceRule') 
     583    if repeat_type == MOTO_REPEAT_DAILY: 
     584        appendXMLTag(doc, e, 'Frequency', 'DAILY') 
     585        rule = rrule.rrule(rrule.DAILY, 
     586                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     587    elif repeat_type == MOTO_REPEAT_WEEKLY: 
     588        appendXMLTag(doc, e, 'Frequency', 'WEEKLY') 
     589        if repeat_day: 
     590            repeat_days = moto_repeat_day_to_weekdays(repeat_day, eventdt.weekday()).sort() 
     591            appendXMLTag(doc, e, 'ByDay', ','.join([VCAL_DAYS[n] for n in repeat_days])) 
     592        else: 
     593            repeat_days = [] 
     594        rule = rrule.rrule(rrule.WEEKLY, byweekday=repeat_days, 
     595                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     596    elif repeat_type == MOTO_REPEAT_MONTHLY_DATE: 
     597        appendXMLTag(doc, e, 'Frequency', 'MONTHLY') 
     598        appendXMLTag(doc, e, 'ByMonthDay', str(eventdt.day)) 
     599        rule = rrule.rrule(rrule.MONTHLY, bymonthday=eventdt.day, 
     600                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     601    elif repeat_type == MOTO_REPEAT_MONTHLY_DAY: 
     602        appendXMLTag(doc, e, 'Frequency', 'MONTHLY') 
     603        if repeat_day: 
     604            (repeat_nth, repeat_daynum) = moto_repeat_day_to_monthday(repeat_day) 
     605        else: # default to repeating on the same week/day as the event 
     606            repeat_nth = weeknum 
     607            repeat_daynum = eventdt.weekday() 
     608        appendXMLTag(doc, e, 'ByDay', '%d%s' % (repeat_nth, VCAL_DAYS[repeat_daynum])) 
     609        rule = rrule.rrule(rrule.MONTHLY, byweekday=rrule.weekdays[repeat_daynum](repeat_nth), 
     610                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     611    elif repeat_type == MOTO_REPEAT_YEARLY_DATE: 
     612        appendXMLTag(doc, e, 'Frequency', 'YEARLY') 
     613        appendXMLTag(doc, e, 'ByMonth', str(eventdt.month)) 
     614        appendXMLTag(doc, e, 'ByMonthDay', str(eventdt.day)) 
     615        rule = rrule.rrule(rrule.YEARLY, bymonth=eventdt.month, bymonthday=eventdt.day, 
     616                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     617    elif repeat_type == MOTO_REPEAT_YEARLY_DAY: 
     618        appendXMLTag(doc, e, 'Frequency', 'YEARLY') 
     619        appendXMLTag(doc, e, 'ByMonth', str(eventdt.month)) 
     620        if repeat_day: 
     621            (repeat_nth, repeat_daynum) = moto_repeat_day_to_monthday(repeat_day) 
     622        else: # default to repeating on the same week/day as the event 
     623            repeat_nth = weeknum 
     624            repeat_daynum = eventdt.weekday() 
     625        appendXMLTag(doc, e, 'ByDay', '%d%s' % (repeat_nth, VCAL_DAYS[repeat_daynum])) 
     626        rule = rrule.rrule(rrule.YEARLY, bymonth=eventdt.month, 
     627                           byweekday=rrule.weekdays[repeat_daynum](repeat_nth), 
     628                           dtstart=eventdt, interval=repeat_every, until=repeat_end) 
     629 
     630    if repeat_every: 
     631        appendXMLTag(doc, e, 'Interval', str(repeat_every)) 
     632 
     633    if repeat_end: 
     634        appendXMLTag(doc, e, 'Until', format_time(repeat_end, VCAL_DATETIME)) 
     635 
     636    if e.hasChildNodes(): 
     637        ret = [e] 
     638    else: 
     639        return [] 
     640 
     641    if exceptions != []: 
     642        # work out which dates the exceptions correspond to 
     643        e = doc.createElement('ExceptionDateTime') 
     644        e.setAttribute('Value', 'DATE') 
     645        for exnum in exceptions: 
     646            appendXMLTag(doc, e, 'Content', format_time(rule[exnum], VCAL_DATE)) 
     647        if e.hasChildNodes(): 
     648            ret.append(e) 
     649 
     650    return ret 
     651 
     652def xmlevent_to_moto_simple(node, event): 
     653    """Parse an XML event (node), setting the fields on a PhoneEvent object (event) 
     654 
     655    Only the common fields between PhoneEventSimple and Extended are handled. 
     656    Recursion-related fields are ignored. 
     657 
     658    This function raises UnsupportedDataError for: 
     659     * events before year 2000 (my phone doesn't allow them) 
     660    """ 
     661 
     662    def getField(tagname, subtag='Content'): 
     663        """utility function for the XML processing below""" 
     664        return getXMLField(node, tagname, subtag) 
     665 
     666    event.name = getField('Summary') 
     667 
     668    event.eventdt = parse_ical_time(getField('DateStarted')) 
     669    if event.eventdt.year < 2000: 
     670        raise UnsupportedDataError('Event is too old') 
     671 
     672    if node.getElementsByTagName('Duration') != []: 
     673        duration = node.getElementsByTagName('Duration')[0] 
     674        def toint(numstr): 
     675            """Convert a string to an integer, unless it's None, in which case return 0.""" 
     676            if numstr is None: 
     677                return 0 
     678            else: 
     679                return int(numstr) 
     680        weeks = toint(getXMLField(duration, 'Weeks')) 
     681        days = toint(getXMLField(duration, 'Days')) 
     682        hours = toint(getXMLField(duration, 'Hours')) 
     683        mins = toint(getXMLField(duration, 'Minutes')) 
     684        secs = toint(getXMLField(duration, 'Seconds')) 
     685        event.duration = timedelta(weeks * 7 + days, (hours * 60 + mins) * 60 + secs) 
     686    else: 
     687        endstr = getField('DateEnd') 
     688        if endstr is not None: 
     689            event.duration = parse_ical_time(endstr) - event.eventdt 
     690        else: 
     691            # no duration or end specified, assume whole-day or no duration 
     692            if isinstance(event.eventdt, date): 
     693                event.duration = timedelta(1) 
     694            else: 
     695                event.duration = timedelta(0) 
     696 
     697    # for some reason I don't understand, the phone only allows events 
     698    # longer than a day if the time flag is set. pander to this by forcing 
     699    # such events to start at midnight local time 
     700    if isinstance(event.eventdt, date) and event.duration > timedelta(1): 
     701        local_midnight = datetime_time(0, 0, 0, 0, dateutil.tz.tzlocal()) 
     702        event.eventdt = datetime.combine(event.eventdt, local_midnight) 
     703 
     704    triggerstr = getField('Alarm', 'AlarmTrigger') 
     705    if triggerstr is None: 
     706        event.alarmdt = None 
     707    else: 
     708        if triggerstr.startswith('-P') or triggerstr.startswith('P'): 
     709            offset = parse_ical_duration(triggerstr) 
     710            event.alarmdt = event.eventdt + offset 
     711        else: 
     712            event.alarmdt = parse_ical_time(triggerstr) 
    417713 
    418714 
     
    444740        self.contact_data_len = None 
    445741        self.contact_name_len = None 
     742        self.extended_events_supported = None 
    446743 
    447744    def connect(self): 
     
    488785        self.__do_cmd('AT+CSCS="UCS2"') 
    489786 
    490         (maxevs, numevs, namelen, max_except) = self.read_event_params() 
     787        (maxevs, numevs, namelen, max_except, extended) = self.read_event_params() 
    491788        self.max_events = maxevs 
    492789        self.num_events = numevs 
    493790        self.event_name_len = namelen 
    494791        self.event_max_exceptions = max_except 
     792        self.extended_events_supported = extended 
    495793 
    496794        (minpos, maxpos, contactlen, namelen) = self.read_contact_params() 
     
    562860                 length of title/name field, 
    563861                 maximum number of event exceptions 
     862                 true/false if extended event format is supported 
    564863        """ 
    565864        self.open_calendar() 
    566865        data = self.__do_cmd('AT+MDBR=?') # read event parameters 
    567         return self.__parse_results('MDBR', data)[0][:4] 
     866        results = self.__parse_results('MDBR', data)[0] 
     867        return results[:4] + [len(results) >= 10] 
    568868 
    569869    def read_events(self): 
     
    7431043 
    7441044    def __parse_results(self, restype, lines): 
    745         """extract results from a list of reply lines""" 
     1045        """Extract results of the specified type from a list of reply lines. 
     1046 
     1047        Returns a list of results, where each result is itself a list of values. 
     1048        String values have quote characters stripped, and are decoded from UCS2. 
     1049        Numeric values are converted to integers. 
     1050        Range values (eg: (0-10)) are converted to lists of integers. 
     1051        """ 
     1052        # FIXME: this code is fragile and too complex 
    7461053        ret = [] 
    7471054        prefix = '+' + restype + ': ' 
     
    8641171 
    8651172 
    866 class PhoneEvent(PhoneEntry): 
     1173class PhoneEventSimple(PhoneEntry): 
    8671174    """Class representing the events roughly as stored in the phone. 
    868     This class should not be instantiated directly, use one of PhoneEventMoto 
    869     or PhoneEventXML depending on the data format. 
     1175    This class should not be instantiated directly, use one of 
     1176    PhoneEventSimpleMoto or PhoneEventSimpleXML depending on the data format. 
    8701177 
    8711178    class members: 
     
    8851192        self.exceptions = [] 
    8861193 
     1194    @staticmethod 
     1195    def capabilities(): 
     1196        """Return the OpenSync XML capabilities supported by this object.""" 
     1197        caps = """ 
     1198            <event> 
     1199                <Alarm /> 
     1200                <DateEnd /> 
     1201                <DateStarted /> 
     1202                <Duration /> 
     1203                <ExceptionDateTime /> 
     1204                <RecurrenceRule /> 
     1205                <Summary /> 
     1206            </event> 
     1207        """ 
     1208        return caps 
     1209 
    8871210    def get_objtype(self): 
    8881211        """return the opensync object type string""" 
     
    9141237        """given an instance of PhoneComms, write this entry to the phone""" 
    9151238        self.__truncate_fields(comms) 
    916         comms.write_event(self.__to_moto(), self.exceptions) 
     1239        comms.write_event(self.to_moto(), self.exceptions) 
    9171240 
    9181241    def hash_data(self): 
    9191242        """return a list of entry data in a predictable format, for hashing""" 
    920         return self.__to_moto() 
     1243        return self.to_moto() 
    9211244 
    9221245    def __truncate_fields(self, comms): 
     
    9251248        self.exceptions = self.exceptions[:comms.event_max_exceptions] 
    9261249 
    927     def __to_moto(self): 
     1250    def to_moto(self): 
    9281251        """generate motorola event-data list""" 
    9291252        if isinstance(self.eventdt, datetime): 
     
    9341257            timeflag = 0 
    9351258            datestr = self.eventdt.strftime(PHONE_DATE) 
    936             timestr = '00:00' 
     1259            timestr = MOTO_INVALID_TIME 
    9371260        if self.alarmdt: 
    9381261            alarmflag = 1 
     
    9411264        else: 
    9421265            alarmflag = 0 
    943             alarmdatestr = '00-00-2000' 
    944             alarmtimestr = '00:00' 
     1266            alarmdatestr = MOTO_INVALID_DATE 
     1267            alarmtimestr = MOTO_INVALID_TIME 
    9451268 
    9461269        duration = int(self.duration.days) * 24 * 60 
    9471270        duration += int(self.duration.seconds) / 60 
    948         return (self.pos, self.name, timeflag, alarmflag, timestr, datestr, 
    949                 duration, alarmtimestr, alarmdatestr, self.repeat_type) 
    950  
    951     def to_xml(self): 
    952         """Returns OpenSync XML representation of this event.""" 
     1271        return [self.pos, self.name, timeflag, alarmflag, timestr, datestr, 
     1272                duration, alarmtimestr, alarmdatestr, self.repeat_type] 
     1273 
     1274    def to_xml(self, include_rrule=True): 
     1275        """Returns OpenSync XML representation of this event. 
     1276 
     1277        If include_rrule is False, any RecurrenceRule and Exception nodes are 
     1278        omitted. This is a hack for use by the PhoneEventExtended class only. 
     1279        """ 
    9531280        impl = xml.dom.minidom.getDOMImplementation() 
    9541281        doc = impl.createDocument(None, 'event', None) 
    9551282        top = doc.documentElement 
    9561283 
    957         # compute the week number that the event falls in 
    958         if self.eventdt.day % 7 == 0: 
    959             weeknum = self.eventdt.day / 7 
    960         else: 
    961             weeknum = self.eventdt.day / 7 + 1 
    962  
    9631284        if self.alarmdt: 
    9641285            alarm = doc.createElement('Alarm') 
    965             appendXMLChild(doc, alarm, 'AlarmAction', 'DISPLAY') 
     1286            appendXMLTag(doc, alarm, 'AlarmAction', 'DISPLAY') 
    9661287            e = doc.createElement('AlarmDescription') 
    967             appendXMLChild(doc, e, 'Content', self.name) 
     1288            appendXMLTag(doc, e, 'Content', self.name) 
    9681289            alarm.appendChild(e) 
    9691290            alarmtime = self.alarmdt.strftime(VCAL_DATETIME) 
    970             appendXMLChild(doc, alarm, 'AlarmTrigger', alarmtime) 
     1291            appendXMLTag(doc, alarm, 'AlarmTrigger', alarmtime) 
    9711292            top.appendChild(alarm) 
    9721293 
     
    9781299            dtend = endtime.strftime(VCAL_DATE) 
    9791300            e.setAttribute('Value', 'DATE') 
    980         appendXMLChild(doc, e, 'Content', dtend) 
     1301        appendXMLTag(doc, e, 'Content', dtend) 
    9811302        top.appendChild(e) 
    9821303 
     
    9871308            dtstart = self.eventdt.strftime(VCAL_DATE) 
    9881309            e.setAttribute('Value', 'DATE') 
    989         appendXMLChild(doc, e, 'Content', dtstart) 
     1310        appendXMLTag(doc, e, 'Content', dtstart) 
    9901311        top.appendChild(e) 
    9911312 
    992         if self.exceptions != []: 
    993             assert(self.repeat_type != MOTO_REPEAT_NONE) 
    994  
    995             # create an rrule object for this recurrence 
    996             if self.repeat_type == MOTO_REPEAT_MONTHLY_DATE: 
    997                 rule = rrule.rrule(rrule.MONTHLY, bymonthday=self.eventdt.day, 
    998                                    dtstart=self.eventdt) 
    999             elif self.repeat_type == MOTO_REPEAT_MONTHLY_DAY: 
    1000                 weekday = rrule.weekdays[self.eventdt.weekday()] 
    1001                 rule = rrule.rrule(rrule.MONTHLY, byweekday=weekday(+weeknum), 
    1002                                    dtstart=self.eventdt) 
    1003             else: 
    1004                 if self.repeat_type == MOTO_REPEAT_DAILY: 
    1005                     freq = rrule.DAILY 
    1006                 elif self.repeat_type == MOTO_REPEAT_WEEKLY: 
    1007                     freq = rrule.WEEKLY 
    1008                 elif self.repeat_type == MOTO_REPEAT_YEARLY: 
    1009                     freq = rrule.YEARLY 
    1010                 rule = rrule.rrule(freq, dtstart=self.eventdt) 
    1011  
    1012             # work out which dates the exceptions correspond to and 
    1013             # generate ExceptionDate nodes for them 
    1014             e = doc.createElement('ExceptionDateTime') 
    1015             e.setAttribute('Value', 'DATE') 
    1016             for exnum in self.exceptions: 
    1017                 appendXMLChild(doc, e, 'Content', format_time(rule[exnum], VCAL_DATE)) 
    1018             if e.hasChildNodes(): 
    1019                 top.appendChild(e) 
    1020  
    1021         e = doc.createElement('RecurrenceRule') 
    1022         if self.repeat_type == MOTO_REPEAT_DAILY: 
    1023             appendXMLChild(doc, e, 'Frequency', 'DAILY') 
    1024         elif self.repeat_type == MOTO_REPEAT_WEEKLY: 
    1025             appendXMLChild(doc, e, 'Frequency', 'WEEKLY') 
    1026         elif self.repeat_type == MOTO_REPEAT_MONTHLY_DATE: 
    1027             appendXMLChild(doc, e, 'Frequency', 'MONTHLY') 
    1028             appendXMLChild(doc, e, 'ByMonthDay', str(self.eventdt.day)) 
    1029         elif self.repeat_type == MOTO_REPEAT_MONTHLY_DAY: 
    1030             appendXMLChild(doc, e, 'Frequency', 'MONTHLY') 
    1031             day = VCAL_DAYS[self.eventdt.weekday()] 
    1032             appendXMLChild(doc, e, 'ByDay', '%d%s' % (weeknum, day)) 
    1033         elif self.repeat_type == MOTO_REPEAT_YEARLY: 
    1034             appendXMLChild(doc, e, 'Frequency', 'YEARLY') 
    1035             appendXMLChild(doc, e, 'ByMonth', str(self.eventdt.month)) 
    1036             appendXMLChild(doc, e, 'ByMonthDay', str(self.eventdt.day)) 
    1037  
    1038         if e.hasChildNodes(): 
    1039             top.appendChild(e) 
    1040  
    10411313        e = doc.createElement('Summary') 
    1042         appendXMLChild(doc, e, 'Content', self.name) 
     1314        appendXMLTag(doc, e, 'Content', self.name) 
    10431315        top.appendChild(e) 
    10441316 
    1045         return doc.toxml() 
    1046  
    1047  
    1048 class PhoneEventMoto(PhoneEvent): 
    1049     """Constructor for the PhoneEvent object with data in Motorola format""" 
     1317        if include_rrule: 
     1318            nodes = moto_rrule_to_xml(doc, self.eventdt, self.repeat_type, self.exceptions) 
     1319            for node in nodes: 
     1320                insertXMLNode(top, node) 
     1321 
     1322        return doc 
     1323 
     1324 
     1325class PhoneEventSimpleMoto(PhoneEventSimple): 
     1326    """Constructor for the PhoneEventSimple object with data in Motorola format""" 
    10501327    def __init__(self, data, exceptions): 
    10511328        """grab stuff out of the list of values from the phone""" 
    1052         PhoneEvent.__init__(self) 
    1053         assert(type(data) == list and len(data) == 10) 
     1329        PhoneEventSimple.__init__(self) 
     1330        assert(type(data) == list and len(data) >= 10) 
    10541331        self.pos = data[0] 
    10551332        self.name = data[1] 
     
    10761353 
    10771354 
    1078 class PhoneEventXML(PhoneEvent): 
    1079     """Constructor for the PhoneEvent object with data in OpenSync XML format""" 
    1080     def __init__(self, data): 
    1081         """Parse XML event data. 
    1082  
    1083         This function raises UnsupportedDataError for: 
    1084          * events that recur but can't be represented on the phone 
    1085          * events before year 2000 (my phone doesn't allow them) 
     1355class PhoneEventSimpleXML(PhoneEventSimple): 
     1356    """Constructor for the PhoneEventSimple object with data in OpenSync XML format""" 
     1357    def __init__(self, xmldata): 
     1358        """Parse XML event data.""" 
     1359        PhoneEventSimple.__init__(self) 
     1360        doc = xml.dom.minidom.parseString(xmldata) 
     1361        node = doc.getElementsByTagName('event')[0] 
     1362 
     1363        xmlevent_to_moto_simple(node, self) 
     1364 
     1365        rrules = node.getElementsByTagName('RecurrenceRule') 
     1366        exdates = node.getElementsByTagName('ExceptionDateTime') 
     1367        exrules = node.getElementsByTagName('ExceptionRule') 
     1368 
     1369        rule = xml_rrule_to_moto(rrules, exdates, exrules, self.eventdt) 
     1370        if (rule['repeat_type'] == MOTO_REPEAT_YEARLY_DAY or rule['repeat_every'] not in [0, 1] 
     1371            or rule['repeat_day'] != 0 or rule['repeat_end']): 
     1372            raise UnsupportedDataError("Recursion rule not supported by simple event format") 
     1373 
     1374        self.repeat_type = rule['repeat_type'] 
     1375        self.exceptions = rule['exceptions'] 
     1376 
     1377 
     1378class PhoneEventExtended(PhoneEventSimple): 
     1379    """Class representing the extended version of the event data supported by 
     1380    newer phone models. 
     1381 
     1382    This class should not be instantiated directly, use one of 
     1383    PhoneEventExtendedMoto or PhoneEventExtendedXML depending on the data format. 
     1384 
     1385    class members (in addition to those defined by PhoneEventSimple): 
     1386       event_type:  integer 0-15 
     1387       location:    string 
     1388       note:        string (stores XML description field) 
     1389       repeat_every: integer 
     1390       repeat_day:  integer 
     1391       repeat_end:  date object or None, for repeat end date 
     1392    """ 
     1393    def __init__(self): 
     1394        PhoneEventSimple.__init__(self) 
     1395        self.event_type = MOTO_EVENT_TYPE_NONE 
     1396        self.location = self.note = '' 
     1397        self.repeat_every = self.repeat_day = 0 
     1398        self.repeat_end = None 
     1399 
     1400    @staticmethod 
     1401    def capabilities(): 
     1402        """Return the OpenSync XML capabilities supported by this object.""" 
     1403        caps = """ 
     1404            <event> 
     1405                <Alarm /> 
     1406                <Categories /> 
     1407                <DateEnd /> 
     1408                <DateStarted /> 
     1409                <Description /> 
     1410                <Duration /> 
     1411                <ExceptionDateTime /> 
     1412                <Location /> 
     1413                <RecurrenceRule /> 
     1414                <Summary /> 
     1415            </event> 
    10861416        """ 
    1087         PhoneEvent.__init__(self) 
    1088         doc = xml.dom.minidom.parseString(data) 
    1089         event = doc.getElementsByTagName('event')[0] 
    1090  
    1091         def getField(tagname, subtag='Content'): 
    1092             """utility function for the XML processing below""" 
    1093             return getXMLField(event, tagname, subtag) 
    1094  
    1095         self.pos = None 
    1096         self.name = getField('Summary') 
    1097  
    1098         self.eventdt = parse_ical_time(getField('DateStarted')) 
    1099         if self.eventdt.year < 2000: 
    1100             raise UnsupportedDataError('Event is too old') 
    1101  
    1102         if event.getElementsByTagName('Duration') != []: 
    1103             duration = event.getElementsByTagName('Duration')[0] 
    1104             def toint(numstr): 
    1105                 """Convert a string to an integer, unless it's None, in which case return 0.""" 
    1106                 if numstr is None: 
    1107                     return 0 
    1108                 else: 
    1109                     return int(numstr) 
    1110             weeks = toint(getXMLField(duration, 'Weeks')) 
    1111             days = toint(getXMLField(duration, 'Days')) 
    1112             hours = toint(getXMLField(duration, 'Hours')) 
    1113             mins = toint(getXMLField(duration, 'Minutes')) 
    1114             secs = toint(getXMLField(duration, 'Seconds')) 
    1115             self.duration = timedelta(weeks * 7 + days, (hours * 60 + mins) * 60 + secs) 
    1116         else: 
    1117             endstr = getField('DateEnd') 
    1118             if endstr is not None: 
    1119                 self.duration = parse_ical_time(endstr) - self.eventdt 
    1120             else: 
    1121                 # no duration or end specified, assume whole-day or no duration 
    1122                 if isinstance(self.eventdt, date): 
    1123                     self.duration = timedelta(1) 
    1124                 else: 
    1125                     self.duration = timedelta(0) 
    1126  
    1127         # for some reason I don't understand, the phone only allows events 
    1128