At 22:50 on 29 Dec 2010, Nick Drage wrote:
> Is there anyone here who's converted to Remind/Wyrd from using the
> Palm calendar application?  I'm currently looking to convert my Palm
> calendar to .rem format via jpilot and ical2rem.pl, but finding the
> conversion process "non trivial", so would appreciate lessons learnt
> from anyone who has gone before me.

Not this exactly, but I did write a python script to synchronise remind
with an ics file (actually from a Sony Ericsson phone). Mainly used the
python vobject module to parse the ics file - you may find the attached
scripts useful. I don't use these anymore as I sync with Google
Calendar on an Android phone using https://github.com/markk/gsync

Cheers,

Mark

-- 
Mark Knoop
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
A python script to compare two ics files. Identifies and prints changes in
a custom format.

TODO:
    - move lots of strings to config
    - enable config from file
    - detect dos/unix lineendings

"""
import codecs, logging, locale, sys
import vobject, datetime, calendar
from optparse import OptionParser
from dateutil.tz import *
Version = "0.1alpha"
# wrap stdout in StreamWriter
sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout)

# defaults
class Config():
    def __init__(self):
        pass

config = Config()
config.primarykey = 'x-irmc-luid'       # primary key to compare files
config.newline = '$newline$'            # string to replace \n
config.ignorefields = ['categories', 'uid', 'status', 'last-modified']
                                        # list of fields to ignore when
                                        # comparing events
config.modeline = '# vim: set ft=remind textwidth=95 nospell:\n'
                                        # first line of output
config.defaultcategory = 'personal'     # default category for events

# read commandline switches
usage = 'usage: %prog [options] [file1] [file2]'
parser = OptionParser(usage=usage, version='%prog ' + Version)
parser.add_option('-v', '--verbose', action='store_true', dest='verbose',
        default=False, help='print debugging info')
(options, args) = parser.parse_args()

# set logging level
if options.verbose:
    logging.basicConfig(level=logging.DEBUG)
else:
    logging.basicConfig(level=logging.INFO)

# output
class RemindOut():
    def __init__(self):
        # lists of events
        self.deletions = []
        self.additions = []
        self.changedto = []
        # list of change details
        self.changes = []

    def prettyPrint(self):
        """ print events in custom format """
        printmodeline = False
        if len(self.deletions):
            printmodeline = True
            print '# these events were deleted'
            for e in self.deletions:
                print formatevent(e)
            print
        if len(self.additions):
            printmodeline = True
            print '# these events were added'
            for e in self.additions:
                print formatevent(e)
            print
        if len(self.changedto):
            print '# these events were changed'
            for c, e in zip(self.changes, self.changedto):
                print c,
                print formatevent(e)
            print
        if printmodeline:
            print config.modeline

remindout = RemindOut()

def formatevent(event):
    """ format event """
    logging.debug(event)
    startdatetime = event['dtstart'][0].value.strftime('%b %d %Y AT %H:%M')
    dur_seconds = (event['dtend'][0].value - event['dtstart'][0].value).seconds
    hours, remainder = divmod(dur_seconds, 3600)
    minutes, seconds = divmod(remainder, 60)
    duration = '%02d:%02d' % (hours, minutes)
    try:
        if event['categories'] is not None:
            category = event['categories'][0].value
        else:
            category = config.defaultcategory
    except KeyError:
        category = config.defaultcategory
    if event['summary'] is not None:
        summary = event['summary'][0].value
    try:
        if event['location'] is not None:
            summary += ' at ' + event['location'][0].value
    except KeyError:
        pass
    try:
        if event['description'] is not None:
            summary += '|\\\n' + event['description'][0].value
    except KeyError:
        pass
    return 'REM %s DURATION %s TAG %s \\\n\tMSG %%g %%3 %%"%s%%"%%' % \
            (startdatetime, duration, category, summary)

def openfile(filename):
    """ open ical file, return ical object """
    try:
        fileIO = codecs.open(filename, 'r', 'utf-8')
        filedata = fileIO.readlines()
        fileIO.close()
    except IOError:
        raise Exception, 'Cannot open file.'
    # remove ignored fields and replace \r\n
    icaldata = ''
    for l in filedata:
        if l.split(':')[0].lower() in config.ignorefields:
            continue
        icaldata += l.replace('\n', config.newline).replace(\
            '\r%s' % config.newline, '\n')
    try:
        ical = vobject.readOne(icaldata)
    except:
        raise Exception, 'Cannot parse ical data.'
    return ical

def fielddiff(field_orig, field_sync):
    """ compare two fields """
    logging.debug(field_orig)
    logging.debug(field_sync)
    if field_orig is None:
        fieldname = field_sync[0].name
        orig = None
    else:
        fieldname = field_orig[0].name
        orig = field_orig[0].value
    if field_sync is None:
        sync = None
    else:
        try:
            sync = field_sync[0].value
        except AttributeError:
            logging.info("This is bug 1")
            logging.info(field_sync)
            sync = None
    return '# %s changed\n# from: %s\n#   to: %s\n' \
            % (fieldname, orig, sync)

def resolve(event_orig, event_sync):
    """ parse differences between events """
    if event_sync is None:
        # event deleted
        remindout.deletions.append(event_orig)
    elif event_orig is None:
        # new event
        remindout.additions.append(event_sync)
    else:
        # event changed
        remindout.changedto.append(event_sync)
        changes = ''
        # ensure both events have all keys
        for field in event_orig.keys():
            if field not in event_sync.keys():
                event_sync[field] = None
        for field in event_sync.keys():
            if field not in event_orig.keys():
                event_orig[field] = None
            if not event_orig[field] == event_sync[field]:
                logging.debug(event_orig)
                logging.debug(event_sync)
                changes += fielddiff(event_orig[field], event_sync[field])
        remindout.changes.append(changes)

def execute():
    ical0 = openfile(args[0])
    ical1 = openfile(args[1])
    # create ical1 dictionary keyed by primary key
    ical1dict = {}
    for event in ical1.vevent_list:
        try:
            ical1dict[event.contents[config.primarykey][0].value] = event.contents
        except KeyError:
            raise Exception, 'Primary key does not exist.'
    for event in ical0.vevent_list:
        # find corresponding event in ical1 
        try:
            ekey = event.contents[config.primarykey][0].value
        except KeyError:
            raise Exception, 'Primary key does not exist.'
        if not ekey in ical1dict:
            # event has been deleted
            resolve(event.contents, None)
        elif not event.contents == ical1dict[ekey]:
            # event has been changed
            resolve(event.contents, ical1dict[ekey])
            del ical1dict[ekey]
        else:
            # event is unchanged
            del ical1dict[ekey]
    # deal with remaining (new) events in ical1
    for ekey in ical1dict.keys():
        resolve(None, ical1dict[ekey])
    # print output
    remindout.prettyPrint()

if __name__ == '__main__':
    if not len(args) == 2:
        raise Exception, 'Incorrect usage.'
    execute()
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
A python script to convert standard input from remind to:
    - LaTeX
    - iCalendar (RFC2445)
    - Google Calendar (gdata)
      http://code.google.com/apis/calendar/data/1.0/developers_guide_python.html

Designed to take input from:

    rem -s

i.e:

    yyyy/mm/dd special tag dur time body

- special is ignored
- tags are comma-separated and become iCalendar CATEGORIES, except for a tag
  containing "=" which are parsed into particular data pairs
- recognized tags: 
    - TZ becomes iCalendar timezone
    - TRANSP={OPAQUE|TRANSPARENT} (default OPAQUE)
- dur becomes iCalendar DURATION
- time becomes starttime
- yyyy/mm/dd starttime become iCalendar DTSTART
- body is split by "|"
    - time is removed from front of line if present (crude test)
    - first line is split by " at " to become iCalendar SUMMARY and LOCATION
    - remaining lines become iCalendar DESCRIPTION

TODO:
- read a config file ~/.rempy/config ConfigParser module
- get all day events first each day in LaTeX (why does rem put them last?)
- .ics output obeys from and to options
- use rem -l option to get file info line
  # fileinfo <lineno> <filename>
  include this info in X-REMIND-FILEINFO
  not difficult, but would it be useful?
    perhaps, if it can also be added into a gevent
- rem breaks a multi-day event into multiple all day events
  try to join these back together
"""
import os, sys, locale, codecs, logging
import vobject, datetime, calendar
import hashlib
from optparse import OptionParser
from dateutil.tz import *
Version = "0.1alpha"

# defaults
class Config():
    def __init__(self):
        pass

config = Config()
config.remnewlinechar = '|'             # newline token in remind
config.remlocation = ' at '             # token denoting location in remind
config.fwd = 0                          # first weekday 0=Monday, 6=Sunday
config.timeformat = '%H:%M'             # strftime format for appointment times
config.timeseparator = '--'             # timespan separator
config.minicalmonths = 3                # number of months to show
config.datespanformat_d = '%-d'         # format for datespan, date only
config.datespanformat_dm = '%-d %B'     # format for datespan, date and month only
config.datespanformat_dmy = '%-d %B %Y' # format for datespan, complete
config.datespanseparator = ' -- '       # datespan separator
config.dayheadfield1 = '%-d %B'         # format for first field of day header
config.dayheadfield2 = '%A'             # format for second field of day header
config.latextemplate = 'calendar.tpl'   # name of latex template file
config.configpath = ['.', '~/.rempy', '/etc/rempy'] # files are looked for in these
                                        # places (in order)

# read commandline switches
usage = 'usage: rem -s[months|+weeks] [dd mmmm yyyy] | %prog [options]'
parser = OptionParser(usage=usage, version='%prog ' + Version)
parser.add_option('-o', '--output', dest='output', metavar='FILE',
        default='calendar', help='basename of the output file')
parser.add_option('-z', '--timezone', dest='timezone', metavar='Region/City',
        default=None, help='set the calendar timezone (default=local)')
# TODO should this be homezone (i.e. GMT)? to deal with Summer Time adjustment?
parser.add_option('-u', '--utc', action='store_true', dest='utc',
        default=False, help='adjust all appointments to UTC')
parser.add_option('-x', '--noical', action='store_false', dest='ical',
        default=True, help='do not output iCalendar file (default=True)')
parser.add_option('-l', '--latex', action='store_true', dest='latex',
        default=False, help='output LaTeX file (default=False)')
parser.add_option('-f', '--from', dest='fr', metavar='YYYYMMDD',
        default=None, help='output LaTeX from date')
parser.add_option('-t', '--to', dest='to', metavar='YYYYMMDD',
        default=None, help='output LaTeX to date')
parser.add_option('-s', '--shorten', action='store_true', dest='shortenallday',
        default=False, help='shorten all day appointments (00:00-23:59)')
parser.add_option('-v', '--verbose', action='store_true', dest='verbose',
        default=False, help='print debugging info')
(options, args) = parser.parse_args()

# set logging level
if options.verbose:
    logging.basicConfig(level=logging.DEBUG)
else:
    logging.basicConfig(level=logging.INFO)

# functions
def addevent(cal, app):
    """ converts a dictionary to a vobject event and adds it to the calendar
    object """
    evt = cal.add('vevent')
    evt.add('uid').value = app['uid']
    # parse date, time, timezone
    if app['timezone']:
        try:
            tz = tzfile('/usr/share/zoneinfo/%s' % app['timezone'])
        except IOError:
            logging.warning('No such timezone file %s' % app['timezone'])
            tz = tzlocal()
    else:
        tz = tzlocal()
    try:
        start = datetime.datetime(app['date'][0], app['date'][1], 
                app['date'][2], app['time'][0], app['time'][1], tzinfo=tz)
    except TypeError:
        logging.critical('Unable to parse datetime! Aborting.\n%s %s' % \
                (app['date'], app['time']))
        raise
    # adjust to UTC
    if options.utc:
        start = (start - start.utcoffset()).replace(tzinfo=None)
    # duration
    if app['dur']:
        # use dtend - seems to be better supported by other apps
        dtend = start + datetime.timedelta(minutes=app['dur'])
    elif start.hour == 0 and start.minute == 0:
        # all day event - TODO this is a guess...
        if options.shortenallday:
            dtend = start + datetime.timedelta(days=1, minutes=-1)
        else:
            # convert start to date
            start = datetime.date(start.year, start.month, start.day)
            dtend = start + datetime.timedelta(days=1)
        # make all day events transparent
        app['transp'] = 'TRANSPARENT'
    else:
        dtend = start
    evt.add('dtstart').value = start
    evt.add('dtend').value = dtend
    # add summary, location, description, transparency
    evt.add('summary').value = app['summary']
    if app['location']:
        evt.add('location').value = app['location']
    if app['description']:
        evt.add('description').value = app['description']
    if len(app['categories']):
        evt.add('categories').value = app['categories']
    if app['transp']:
        evt.add('transp').value = app['transp']
    return cal

def texize(text, NewLines=False):
    """ make replacements so text is safe for LaTeX """
    texChars = {u'-':u'--', u'%':u'\\% ', u'$':u'\\$ ', u'£':u'\\pounds ',
            u'#':u'\\# ', u'&':u'\\& ', u'_':u'\\_ ', u'...':u'\\ldots ',
            u'{':u'\\{ ', u'}':u'\\} ', u'~':u'$\\sim$',
            u'<':u'\\textlessthan ', u'>':u'\\textgreaterthan '}
    TexString = text.replace(u'\\', u'\\textbackslash') # must do this one first!
    TexString = TexString.replace('\\t', u' ') # remove any tabs
    for character in texChars.keys():
        TexString = TexString.replace(character, texChars[character])
    if NewLines is False:
        TexString = TexString.replace('\n', u' ')
    elif NewLines is True:
        TexString = TexString.replace('\n', u' \\newline\n')
    return TexString

def makelatex(vevent):
    """ parse an event into values for the LaTeX output """
    apptemplate = '\\appointment{%(tsp)s}{%(sum)s}{%(cat)s}{%(loc)s}{%(des)s}{%(transp)s}\n'
    event = {'tsp': ''}
    # summary
    event['sum'] = texize(vevent.summary.value)
    logging.debug('processing %s' % event['sum'])
    logging.debug(vevent)
    # transparency
    event['transp'] = vevent.transp.value
    # categories
    try:
        event['cat'] = texize(', '.join(vevent.categories.value))
    except AttributeError:
        event['cat'] = ''
    # location
    try:
        event['loc'] = texize(vevent.location.value)
    except AttributeError:
        event['loc'] = ''
    # description
    try:
        event['des'] = texize(vevent.description.value, NewLines=True)
    except AttributeError:
        event['des'] = ''
    # timespan
    stime = vevent.dtstart.value
    etime = vevent.dtend.value
    if stime + datetime.timedelta(days=1) == etime and \
            stime.hour == 0 and stime.minute == 0:
        # all day event 
        event['tsp'] = ''
    elif stime == etime: 
        # no duration event
        event['tsp'] = stime.strftime(config.timeformat)
    elif stime.date() == etime.date():
        # ends on the same day
        event['tsp'] = '%s%s%s' % (stime.strftime(config.timeformat), 
                config.timeseparator, etime.strftime(config.timeformat))
    else:
        # ends on a different day
        diff = etime.date() - stime.date()
        event['tsp'] = '%s--%s+%s' % (stime.strftime(config.timeformat), 
                etime.strftime(config.timeformat), diff.days)
    return apptemplate % event

def parsedatestring(yyyymmdd):
    """ convert a string to a date object if possible """
    if yyyymmdd is None: return None
    if type(yyyymmdd) == str or type(yyyymmdd) == unicode:
        if len(yyyymmdd) != 8:
            logging.warning('Ignoring invalid date %s.' % yyyymmdd)
            return None
        else:
            # parse yyyymmdd
            try:
                return datetime.date(int(yyyymmdd[0:4]),
                        int(yyyymmdd[4:6]), int(yyyymmdd[6:8]))
            except ValueError, TypeError:
                logging.warning('Ignoring invalid date %s.' % yyyymmdd)
                return None

def latex3months(year, month):
    """ return a LaTeX string of a table of next three months """
    calendar.setfirstweekday(config.fwd)
    head = u'\\minicalhead'
    def nozeros(d):
        if d == 0:
            return ''
        else:
            return str(d)
    weeks = [calendar.weekheader(1).split() * config.minicalmonths]
    for m in range(config.minicalmonths):
        head += datetime.date(year, month, 1).strftime('{%B}{%Y}')
        thismonth = calendar.monthcalendar(year, month)
        while len(thismonth) < 6:
            thismonth.append([0] * 7)
        for w in range(6):
            if m == 0:
                weeks.append([])
            weeks[w + 1] += [nozeros(d) for d in thismonth[w]]
        if month == 12:
            month = 1
            year += 1
        else:
            month += 1
    if weeks[-1] == [''] * (config.minicalmonths * 7):
        del weeks[-1]
    weekstring = ''
    for w in weeks:
        weekstring += ' & '.join(w) + ' \\\\\n' 
    return (head + '\n', weekstring)

# validate calendar range
options.fr = parsedatestring(options.fr)
options.to = parsedatestring(options.to)
# read stdin
encoding = locale.getpreferredencoding()
logging.debug('Encoding is %s' % encoding)
appointments = codecs.getreader(encoding)(sys.stdin)
# initialize list of processed appointments (list of dicts)
apps = []
# initialize iCalendar object
cal = vobject.iCalendar()
cal.add('prodid').value = "-//REMPY//PYVOBJECT//NONSGML Version 1//EN"
for appointment in appointments: #[0:5]:
    # strip trailing whitespace and newline
    appointment = appointment.rstrip()
    logging.debug('%s' % appointment)
    # make md5 digest
    uid = hashlib.md5(appointment.encode(encoding)).hexdigest()
    # split into fields
    remfields = appointment.split(None, 5)
    (year, month, day) = remfields[0].split('/')
    # check if appointment is in range
    appstart = parsedatestring('%s%s%s' % (year, month, day))
    logging.debug('DTSTART: %s (%s, %s, %s)' % (appstart, year, month, day))
    if options.fr is not None:
        if appstart < options.fr:
            continue
    if options.to is not None:
        if appstart > options.to:
            continue
    app = {'date': (int(year), int(month), int(day)), 'uid': uid}
    # ignore remfields[1] special
    # tags
    if remfields[2] == '*':
        app['tags'] = []
    else:
        app['tags'] = remfields[2].split(',')
    # parse tags
    app['timezone'] = options.timezone
    app['transp'] = 'OPAQUE'
    app['categories'] = []
    for tag in app['tags']:
        if '=' in tag:
            (key, value) = tag.split('=')
            if key == 'TZ':
                app['timezone'] = value
            elif key == 'TRANSP':
                app['transp'] = value
            else:
                logging.warning('Unknown tag pair: %s' % tag)
        else:
            app['categories'].append(tag)
    # dur
    if remfields[3] == '*':
        app['dur'] = None
    else:
        app['dur'] = int(remfields[3])
    # time
    if remfields[4] == '*':
        app['time'] = (0, 0)
    else:
        (hour, minute) = (int(remfields[4]) // 60, int(remfields[4]) % 60)
        app['time'] = (hour, minute)
    # body - remove leading time 
    # first test for 24hr time: hh:mm-hh:mm
    # then for 12hr time: will end in am, pm, or m+x
    timespan = remfields[5].split(None, 1)[0]
    if len(timespan) < 6 or timespan == 'Avraham':
        pass
    # TODO fix this time test - catches e.g. "Avraham"
    elif (timespan[2] == ':' and timespan[5] == '-' and timespan[8] == ':') \
            or (timespan.endswith('pm') or timespan.endswith('am') or \
            timespan[-3:-1] == 'm+'):
        remfields[5] = remfields[5].split(None, 1)[1]
    # body - split into lines
    remfields[5] = remfields[5].split(config.remnewlinechar)
    #      - split first line
    sumloc = remfields[5][0].split(config.remlocation)
    app['summary'] = sumloc[0]
    if len(sumloc) == 1:
        app['location'] = None
    elif len(sumloc) == 2:
        app['location'] = sumloc[1]
    else:
        logging.warning('Too many fields in body! %s' % sumloc)
    # join remaining lines into description
    if len(remfields[5]) > 1:
        app['description'] = '\n'.join(remfields[5][1:])
    else:
        app['description'] = None
    logging.debug('%s' % app)
    # add appointment to calendar
    cal = addevent(cal, app)
    # add appointment to list
    apps.append(app)

# output
# serialize calendar
icalstream = cal.serialize()
# set range fr/to if None
if options.fr is None:
    # use date of first event
    if type(cal.vevent_list[0].dtstart.value) == datetime.date:
        options.fr = cal.vevent_list[0].dtstart.value
    else:
        options.fr = cal.vevent_list[0].dtstart.value.date()
if options.to is None:
    # use date of last event
    if type(cal.vevent_list[-1].dtstart.value) == datetime.date:
        options.to = cal.vevent_list[-1].dtstart.value
    else:
        options.to = cal.vevent_list[-1].dtstart.value.date()
# outputs
if options.ical:
    # output calendar to file
    icalout = codecs.open('%s.ics' % options.output, 'w', encoding)
    icalout.write(icalstream.decode(encoding))
    icalout.close()
if options.latex:
    # format calendar period
    if options.fr.year == options.to.year:
        if options.fr.month == options.to.month:
            pfr = options.fr.strftime(config.datespanformat_d)
        else:
            pfr = options.fr.strftime(config.datespanformat_dm)
    else:
        pfr = options.fr.strftime(config.datespanformat_dmy)
    pto = options.to.strftime(config.datespanformat_dmy)
    calperiod = '%s%s%s' % (pfr, config.datespanseparator, pto)
    logging.debug('calendar period: %s' % calperiod)
    # make calendar covering date range
    currentday = options.fr
    days = ''
    appsiter = iter(cal.vevent_list)
    try:
        currentapp = appsiter.next()
        currentappdate = currentapp.dtstart.value.date()
    except StopIteration:
        logging.warning('No appointments in this calendar.')
        currentappdate = options.to + datetime.timedelta(1)
    while currentday <= options.to:
        logging.debug('processing day %s, app %s' % (currentday, currentappdate))
        thisday = currentday.strftime('\\dayhead{%s}{%s}\n' % \
                (config.dayheadfield1, config.dayheadfield2))
        if currentappdate > currentday:
            days += thisday
            currentday += datetime.timedelta(1)
            continue
        elif currentappdate < currentday:
            try:
                currentapp = appsiter.next()
                currentappdate = currentapp.dtstart.value.date()
                logging.debug('next app %s' % currentappdate)
            except StopIteration:
                currentappdate = options.to + datetime.timedelta(1)
            continue
        while currentappdate == currentday:
            thisday += makelatex(currentapp)
            try:
                currentapp = appsiter.next()
                currentappdate = currentapp.dtstart.value.date()
                logging.debug('next app %s' % currentappdate)
            except StopIteration:
                currentappdate = options.to + datetime.timedelta(1)
        days += thisday
        currentday += datetime.timedelta(1)
    # make minicalendar
    (minicalhead, minicalweeks) = latex3months(options.fr.year,
            options.fr.month)
    # substitute appointments into calendar
    foundtemplate = False
    for p in config.configpath:
        templatefilename = '%s/%s' % (os.path.expanduser(p),
                config.latextemplate)
        if os.path.isfile(templatefilename):
            foundtemplate = True
            break
    if foundtemplate is False:
        logging.critical('Cannot find template file!')
    caltemplatefile = codecs.open(templatefilename, 'r', encoding)
    caltemplate = caltemplatefile.read()
    caltemplatefile.close()
    callatex = caltemplate.replace('__CALPERIOD__', calperiod)
    callatex = callatex.replace('__DAYS__', days)
    callatex = callatex.replace('__MINICALHEAD__', minicalhead)
    callatex = callatex.replace('__MINICALWEEKS__', minicalweeks)
    # output latex to file
    callatexout = codecs.open('%s.tex' % options.output, 'w', encoding)
    callatexout.write(callatex) #.decode(encoding))
    callatexout.close()

_______________________________________________
Remind-fans mailing list
[email protected]
http://lists.roaringpenguin.com/cgi-bin/mailman/listinfo/remind-fans
Remind is at http://www.roaringpenguin.com/products/remind

Reply via email to