Creando entradas para mi calendario en Ical usando Python

Runners World Half Marathon training plan
Runners World Half Marathon training plan

Para quienes siguen esta bitácora, seguramente recordarán que me aceptaron en el NYC Half Marathon del 2013. Eso significa que me va a tocar entrenar este inverno, en esta ocasión quise probar los planes gratuitos de la gente de Runners World para ver que tan buenos son.

Después de registrarme y de colocar varios parámetros, esto fué más o menos lo que el plan me generó:

El plan básico de Runners World Smart Coach no soporta enviar correos electrónicos para recordarte acerca de tus entrenamientos; Esto es algo que puedo hacer yo sólo y no creo que justifique que pagué por la mejora al servicio pago, dado lo corto de la carrera (para el maratón es otra cosa y allí les recomiendo que paguen su suscripción).

Para tener mis recordatorios lo único que tengo que hacer es guardar el plan en formato de texto plano, simplemente seleccionamos la tabla del sitio web y la guardamos en un archivo de texto:

#Macintosh:Documents josevnz$ vim half.txt

WEEK 1: 4 Mi
Sun     Dec 30  Long Run        Dist: 4 Mi @9:35

WEEK 2: 11 Mi
Tue     Jan 1   Easy Run        Dist: 4 Mi @9:36
Thu     Jan 3   Easy Run        Dist: 3 Mi @9:36
Sun     Jan 6   Easy Run        Dist: 4 Mi @9:36

WEEK 3: 12 Mi
Tue     Jan 8   Easy Run        Dist: 3 Mi @9:35
Thu     Jan 10  Tempo Run       Dist: 4 Mi, inc Warm; 2 Mi @ 7:59; Cool
Sun     Jan 13  Long Run        Dist: 5 Mi @9:35

WEEK 4: 10 Mi
Tue     Jan 15  Easy Run        Dist: 3 Mi @9:32
Thu     Jan 17  Easy Run        Dist: 3 Mi @9:32
Sun     Jan 20  Easy Run        Dist: 4 Mi @9:32

WEEK 5: 13 Mi
Tue     Jan 22  Easy Run        Dist: 4 Mi @9:32
Thu     Jan 24  Tempo Run       Dist: 4 Mi, inc Warm; 2 Mi @ 7:57; Cool
Sun     Jan 27  Long Run        Dist: 5 Mi @9:32

WEEK 6: 14 Mi
Tue     Jan 29  Easy Run        Dist: 4 Mi @9:30
Thu     Jan 31  Speedwork       Dist: 4 Mi, inc Warm; 2x1600 in 7:32 w/800 jogs; Cool
Sun     Feb 3   Long Run        Dist: 6 Mi @9:30

WEEK 7: 15 Mi
Tue     Feb 5   Easy Run        Dist: 3 Mi @9:27
Thu     Feb 7   Tempo Run       Dist: 4 Mi, inc Warm; 2 Mi @ 7:52; Cool
Fri     Feb 8   Easy Run        Dist: 2 Mi @9:27
Sun     Feb 10  Long Run        Dist: 6 Mi @9:27

WEEK 8: 13 Mi
Tue     Feb 12  Easy Run        Dist: 3 Mi @9:25
Thu     Feb 14  Easy Run        Dist: 3 Mi @9:25
Fri     Feb 15  Easy Run        Dist: 3 Mi @9:25
Sun     Feb 17  Easy Run        Dist: 4 Mi @9:25

WEEK 9: 17 Mi
Tue     Feb 19  Easy Run        Dist: 2 Mi @9:25
Thu     Feb 21  Tempo Run       Dist: 5 Mi, inc Warm; 3 Mi @ 7:54; Cool
Fri     Feb 22  Easy Run        Dist: 2 Mi @9:25
Sun     Feb 24  Long Run        Dist: 8 Mi @9:25

WEEK 10: 18 Mi
Tue     Feb 26  Easy Run        Dist: 3 Mi @9:22
Thu     Feb 28  Speedwork       Dist: 4 Mi, inc Warm; 2x1600 in 7:25 w/800 jogs; Cool
Fri     Mar 1   Easy Run        Dist: 2 Mi @9:22
Sun     Mar 3   Long Run        Dist: 9 Mi @9:22

WEEK 11: 19 Mi
Tue     Mar 5   Easy Run        Dist: 2 Mi @9:20
Thu     Mar 7   Tempo Run       Dist: 5 Mi, inc Warm; 3 Mi @ 7:50; Cool
Fri     Mar 8   Easy Run        Dist: 2 Mi @9:20
Sun     Mar 10  Long Run        Dist: 10 Mi @9:20

WEEK 12: 18 Mi
Tue     Mar 12  Easy Run        Dist: 2 Mi @9:17
Thu     Mar 14  Speedwork       Dist: 3 Mi, inc Warm; 1x1600 in 7:21 w/800 jogs; Cool
Sun     Mar 17  Half Marathon Race Day  13.1 Mi @8:02 Time: 1:45:17

El formato es super fácil de digerir; Con un poco de ayuda para entender el formato de iCalendar, escribí un pequeño script en Python el cual toma el archivo de texto y lo convierte al formato adecuado:

#!/usr/bin/env python
# Simple script to parse output from the Runners World Smart Coach calendar to convert it to Ical entries. PLEASE CONSIDER BUYING THEIR PLAN, IS WORTH IT
# http://kodegeek.com/blog
import os, sys, re
from datetime import *

class Cal:
        def __init__(self, params):
                self.params = params

        def __str__(self):
                return '''BEGIN:VEVENT
DTEND;TZID=%(date)s
TRANSP:OPAQUE
SUMMARY:%(summary)s
DTSTART;TZID=%(date)s
SEQUENCE:4
BEGIN:VALARM
X-WR-ALARMUID:454BC86C-A39E-4C87-8CF9-7D79D80AC01B
TRIGGER:-PT1M
ATTACH;VALUE=URI:Basso
ACTION:AUDIO
END:VALARM
END:VEVENT''' % self.params

(Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec) = range(0, 12)

def parseLine(line, now, format):
        if len(line) == 1 or re.search('WEEK', line):
                return None
        ''' Parsing the data. Each line format looks like this:
        Sun     Dec 30  Long Run        Dist: 4 Mi @9:35
        Tue     Jan 29  Easy Run        Dist: 4 Mi @9:30
        Thu     Jan 31  Speedwork       Dist: 4 Mi, inc Warm; 2x1600 in 7:32 w/800 jogs; Cool
        Sun     Mar 17  Half Marathon Race Day  13.1 Mi @8:02 Time: 1:45:17
        '''
        matcher = re.search('(.*)\s+Dist:(.*)', line.strip())
        if matcher == None:
                matcher = re.search('(.*)\s+Race Day(.*)', line.strip())
                if matcher == None:
                        return None # Don't know how to handle this!
        params = {}
        # Runs finish and end the same day
        tokens = matcher.group(1).strip().split(' ', -1)
        cdate = ' '.join(tokens[:3]).strip()
        time = "05:00"
        if tokens[0] in [ "Sun", "Sat" ]:
                time = "06:00"
        # We add the year and start time to the date. Start time is the time I want to run
        # We figure out the year as none of these plans are longer than 13 weeks
        year = 2013 # Yes, it is hardcoded. This version of the script doesn't handle the special case of dates across years

        # For date we expect something like: Sun Dec 30 2012 05:00
        date = "%s %s %s" % (cdate, year, time)
        ndate = datetime.strptime(date, "%a %b %d %Y %H:%M")
        if ndate == None:
                raise Exception("Unable to parse date, won't continue: '%s'" % date)
        params['date'] = ndate.strftime(format)
        desc = ' '.join(tokens[3:])
        params['summary'] = '%s %s' % (desc.strip(), matcher.group(2).strip())
        return Cal(params)

# You may want to override some defaults here
def writeEvents(cals, calendarName='Races', tz='America/New_York'):
        vals = {}
        vals['tz'] = tz
        vals['calname'] = calendarName
        print '''BEGIN:VCALENDAR
METHOD:PUBLISH
VERSION:2.0
X-WR-CALNAME:%(calname)s
PRODID:-//Apple Inc.//iCal 5.0.3//EN
X-APPLE-CALENDAR-COLOR:#B90E28
X-WR-TIMEZONE:%(tz)s
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:%(tz)s
BEGIN:DAYLIGHT
TZOFFSETFROM:-0500
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
DTSTART:20070311T020000
TZNAME:EDT
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:-0400
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
DTSTART:20071104T020000
TZNAME:EST
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE''' % vals
        for cal in cals:
                print "%s" % cal
        print '''END:VCALENDAR'''

def main(args):
        format = "America/New_York:%Y%m%dT%H%M00" # America/New_York:20130101T060000
        now = date.today()
        fh = open(args[0], 'r')
        cals = []
        for line in fh.xreadlines():
                cal = parseLine(line, now, format)
                if cal != None:
                        cals.append(cal)
        writeEvents(cals)

if __name__ == "__main__":
        main(sys.argv[1:])
# End of script

Para correrlo sólo tiene que escribir lo siguiente:
bin/runnersworld_to_ical.py ~/Documents/half.ics

Después sólo hay que importarlo en la aplicación Ical en OSX.

Espero que le sea de utilidad, mañana me toca correr en nieve (a 25F), así que aún no ando seguro si me toca usar un Treadmil o si voy a correr en la carretera. Si sólo eso se pudiera resolver con un pequeño programita…