Wednesday, December 11, 2013

Python CalDAV script for creating/removing calendar events

I'd like to use terminal to create, view and remove events in my Owncloud calendar. I've found client library which is able to handle this. It is simple to use and offers only few functions (see documentation).
Based on this library I've written python2 script.

First step is to install client library. Download the archive and follow these steps:
NOTE: you might need to install some additional packages: python2 python2-setuptools libxslt
NOTE: for version 0.2.1 see Using caldav 0.2.1 with owncloud DAV services
  •  tar xf caldav-0.1.12.tar.gz
  •  cd caldav-0.1.12
  •  python2 setup.py build
  •  sudo python2 setup.py install
Now you should have all necessary to run following script, but first you need to set few things:
  1. local IP address to access from LAN network
  2. public IP address to access from the internet
  3. your name and password for owncloud
  4. URL to your calendar
  5. look at the script and replace <TAGS> with your information
#!/usr/bin/python2

from datetime import datetime, timedelta
import caldav
from caldav.elements import dav, cdav
import sys
import time
import os, binascii
import re
import socket

#get the right IP address
try:
 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 s.connect(("8.8.8.8",80))
 #true if ip is from local network, else use public ip
 if (s.getsockname()[0] == "<LAN IP of machine which is executing this script>"): ipAddress = "<LAN IP of machine with owncloud>"
 else: ipAddress = "<Public IP of machine with owncloud>"
 s.close()
except:
 print "Unable to connect"
 exit(1)

#NOTE
#if password contains special characters, fill them in hex format in url e.g. https://name:passd#f@... > https://name:passd%23f@...
#then edit the file /usr/lib/python2.7/site-packages/caldav-0.1.12-py2.7.egg/caldav/davclient.py and add corresponding replace on line 65
#do not use < or > in name/password, as this is parsed in XML and causes issues with parsing

#set caldav url
url = "https://<username>:<password>@" + ipAddress + "/owncloud/remote.php/caldav/calendars/<user>/<calendar name>"

#default number of days to show
defDaysSh=7
#default number of days for creating event
defEventLen=7
#timezone +2 hours
calTimeZone=2
#default reminder time, in minutes (defined as str(), because it's part of reminderData structure)
defReminderTime="10"

#connect to the calendar
def connect():
 client = caldav.DAVClient(url)
 principal = caldav.Principal(client, url)
 calendars = principal.calendars()
 if len(calendars) > 0:
  return (calendars[0], client)

def setAlert(UID, reminderTime):
 reminderData = """
BEGIN:VALARM
TRIGGER;VALUE=DURATION:-PT""" + reminderTime + """M
ACTION:DISPLAY
DESCRIPTION:Default Event Notification
X-WR-ALARMUID:""" + UID + """
END:VALARM"""
 return reminderData
 
def help():
 print "Script for ownCloud calDAV manipulation.\n KrisKo 2013"
 print "\nUSAGE: ", sys.argv[0], " OPTIONS"
 print "  sh [days] \n\tshow upcoming events for n-days, default is", defDaysSh
 print "  add [days] [hrs] (event summary) [remind]\n\tadd event for n-days, default is", defEventLen, ", \"remind<nr>\" at the end sets notification <nr> minutes before begin"
 print "  rem (search string) \n\tremove matching event"

 print "\nEXAMPLE USAGE:"
 print "  ", sys.argv[0], "add 3 9-14 Event for 3 days from 9:00 until 14:00"
 print "  ", sys.argv[0], "add 9-14 Event for today from 9:00 until 14:00, with 15 minute before start reminder remind15"
 print "  ", sys.argv[0], "add +3 9-14 Event for 3 days, beginning in 3 days from 9:00 until 14:00"
 print "  ", sys.argv[0], "add +2-5 9-14 Event for 5 days, beginning in 2 days from 9:00 until 14:00"
 print "  ", sys.argv[0], "rem some event\tremove event containing \"some event\""

def show(days):
 #TODO add det param to show detailed events

 #get connector data
 connector = connect()
 calendar = connector[0]
 client = connector[1]
 
 #get events within date range
 results = calendar.date_search(datetime.now().date()+timedelta(days=1), datetime.now().date()+timedelta(days=days+1))
        #here is a bug when creating event from phone; there are 3 DTSTART tags and the above function parses the first/second  one
        #which belongs to section BEGIN:DAYLIGHT/STANDARD instead correct one: BEGIN:VEVENT (this is maybe connected to winter daylight saving time)
        #this may not occur on Android newer than 2.3.7, further testing needed

 for event in results:
  events = str(event)
  eventDetail = caldav.Event(client, url=events, parent = calendar).load()
  for line in eventDetail.data.split('\n'):
   if "DTSTART" in line: start = line.split(":", 1)[1].strip()
   elif "DTEND" in line: end = line.split(":", 1)[1].strip()
   elif "SUMMARY" in line: sum = line.split(":", 1)[1].strip()
  
  #true if all day event
  if (len(start) == 8) and (len(end) == 8):
   print time.strftime('%d.%m', time.strptime(start, '%Y%m%d')), "-", time.strftime('%d.%m', time.strptime(end, '%Y%m%d'))
   print " ", sum
  #else if timerange specified
  elif (len(start) == 16) and (len(end) == 16):
   print time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(start, '%Y%m%dT%H%M%SZ'))+60*60*calTimeZone)), "-", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(end, '%Y%m%dT%H%M%SZ'))+60*60*calTimeZone))
   print " ", sum
  #else if timerange event created from phone
  elif (len(start) == 15) and (len(end) == 15):
   print time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(start, '%Y%m%dT%H%M%S')))), "-", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(end, '%Y%m%dT%H%M%S'))))
   print " ", sum

def add(event):
 #initialize variables
 reminderData = ""
 reminderMsg = ""
 daysAdd = 0
 #generate random UID
 UID = binascii.hexlify(os.urandom(5))
 #is this future event?
 if (event[0][0] == "+"):
  #remove trailing +
  event[0] = event[0].replace("+", "", 1)
  daysAdd = int(event[0].split("-")[0])*3600*24
  #try to set event length
  try:
   event[0] = event[0].split("-")[1]
  except:
   event[0] = event[0].split("-")[0]
 #is there a reminder to be set?
 if (event[-1][0:6] == "remind"):
  if event[-1][6:9].isdigit():
   reminderTime = event[-1][6:9]
  else:
   reminderTime = defReminderTime
  reminderData = setAlert(UID, reminderTime)
  reminderMsg = "with reminder"
  del event[-1]
 #create timestamps
 DTstart = time.strftime('%Y%m%d', time.localtime(time.mktime(time.localtime())+daysAdd))

 try:
  #if only time range is specified 
  if (bool(re.compile('^[0-9]+-+[0-9]+$').match(event[0]+'\n'))):
   startTime = int(event[0].split("-")[0])-calTimeZone
   endTime = int(event[0].split("-")[1])-calTimeZone
   #swap times if needed
   if (startTime >= endTime):
    startTime, endTime = endTime, startTime
   DTstart = time.strftime('%Y%m%dT' + str(startTime).zfill(2) + '0000Z', time.localtime(time.mktime(time.localtime())+daysAdd))
   DTend = time.strftime('%Y%m%dT' + str(endTime).zfill(2) + '0000Z', time.localtime(time.mktime(time.localtime())+daysAdd))
   del event[0]
   print "Creating event for today from", startTime+calTimeZone, "-", endTime+calTimeZone, reminderMsg
  #else if also day range is specified
  elif (bool(re.compile('^[0-9]+-+[0-9]+$').match(event[1]+'\n')) and int(event[0]) > 0):
   startTime = int(event[1].split("-")[0])-calTimeZone
   endTime = int(event[1].split("-")[1])-calTimeZone
   DTstart = time.strftime('%Y%m%dT' + str(startTime).zfill(2) + '0000Z', time.localtime(time.mktime(time.localtime())+daysAdd))
   DTend = time.strftime('%Y%m%dT' + str(endTime).zfill(2) + '0000Z', time.localtime(time.mktime(time.localtime())+3600*24*int(event[0])+daysAdd))
   print "Creating event for", event[0], "days", reminderMsg
   del event[0:2]
  #else create all day event
  else:
   DTend = time.strftime('%Y%m%d', time.localtime(time.mktime(time.localtime())+3600*24*int(event[0])+daysAdd))
   print "Creating event for", event[0], "days."
   del event[0]
 except:
  DTend = time.strftime('%Y%m%d', time.localtime(time.mktime(time.localtime())+3600*24*defEventLen))
  print "Creating event for 7 days."

 #current timestamp
 DTcurr = time.strftime('%Y%m%dT%H%M%SZ', time.localtime())
 summary = " ".join(event)

 #TODO implement (now are these variables not used)
 location=""
 description=""
 category=""

 #prepare calDav event
 vcal = """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ownCloud Calendar
BEGIN:VEVENT
CREATED;VALUE=DATE-TIME:""" + DTcurr + """
UID:""" + UID + """
LAST-MODIFIED;VALUE=DATE-TIME:""" + DTcurr + """
DTSTAMP;VALUE=DATE-TIME:""" + DTcurr + """
SUMMARY:""" + summary + """
DTSTART;VALUE=DATE-TIME:""" + DTstart + """
DTEND;VALUE=DATE-TIME:""" + DTend + """
CLASS:PUBLIC
LOCATION:""" + location + """
DESCRIPTION:""" + description + """
CATEGORIES:""" + category + reminderData + """
END:VEVENT
END:VCALENDAR
 """

 #get connector data
 connector = connect()
 calendar = connector[0]
 client = connector[1]

 #create event
 event = caldav.Event(client, data = vcal, parent = calendar).save()

def rem(string):
 #get connector data
 connector = connect()
 calendar = connector[0]
 client = connector[1]
 
 #get all events, search for match
 #download only events from past 7 days and month from future
 calEvents = calendar.date_search(datetime.now().date()-timedelta(days=7), datetime.now().date()+timedelta(days=31))
 for event in calEvents:
  eventDetail = caldav.Event(client, url=str(event), parent = calendar).load()
  match = False
  for line in eventDetail.data.split('\n'):
   if "DTSTART" in line:
    start = line.split(":")[1].strip()
   if "DTEND" in line:
    end = line.split(":")[1].strip()
   #test for match with string in SUMMARY
   if ("SUMMARY" in line and " ".join(string) in line):
    sum = line.split(":")[1].strip()
    match = True
  if ( match ):
   #true if all day events
   if (len(start) == 8) and (len(end) == 8):
    print "Found matching event:"
    print "  ", time.strftime('%d.%m', time.strptime(start, '%Y%m%d')), "-", time.strftime('%d.%m', time.strptime(end, '%Y%m%d'))
    print "  ", sum
    answer = raw_input("Remove event? [Y/n]: ")
    if (bool(re.compile('^[y|Y]').match(answer)) or answer == ""):
     eventDetail = caldav.Event(client, url=str(event), parent = calendar).delete()
   #true if event with time range
   elif (len(start) == 16) and (len(end) == 16):
    print "Found matching event:"
    print "  ", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(start, '%Y%m%dT%H%M%SZ'))+60*60*calTimeZone)), "-", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(end, '%Y%m%dT%H%M%SZ'))+60*60*calTimeZone))
    print "  ", sum
    answer = raw_input("Remove event? [Y/n]: ")
    if (bool(re.compile('^[y|Y]').match(answer)) or answer == ""):
     eventDetail = caldav.Event(client, url=str(event), parent = calendar).delete()
   #true if event was created with phone
   elif (len(start) == 15) and (len(end) == 15):
    print "Found matching event:"
    print "  ", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(start, '%Y%m%dT%H%M%S')))), "-", time.strftime('%d.%m %H:%M', time.localtime(time.mktime(time.strptime(end, '%Y%m%dT%H%M%S'))))
    print "  ", sum
    answer = raw_input("Remove event? [Y/n]: ")
    if (bool(re.compile('^[y|Y]').match(answer)) or answer == ""):
     eventDetail = caldav.Event(client, url=str(event), parent = calendar).delete()

# # # # #
# BEGIN 
# # # # #

#show event for Ndays or default 7 days if no value is specified
if (len(sys.argv) >= 2) and (sys.argv[1] == "sh"):
 try:
  show(int(sys.argv[2]))
 except:
  show(defDaysSh)
#add event, syntax .py add [nr] (description)
elif (len(sys.argv) > 2) and (sys.argv[1] == "add"):
 #remove first two arguments
 del sys.argv[0:2]
 add(sys.argv)
elif (len(sys.argv) > 2) and (sys.argv[1] == "rem"):
 #remove first two arguments
 del sys.argv[0:2]
 rem(sys.argv)
else:
 help()

exit(0)

Additional info:
If your password contains special characters, you need to enter them in script in HEX format (to get HEX values see man ascii).
Example:
https://name:pass.d#f@... (you need to replace '.' and '#' characters)
https://name:pass%2ed%23@...
Then edit file /usr/lib/python2.7/site-packages/caldav-0.1.12-py2.7.egg/caldav/davclient.py and add corresponding replace on line 65:
hash = (("%s:%s" % (self.url.username.replace('%40', '@'),
                    self.url.password.replace('%23', '#').replace('%2e', '.')))\
                    .encode('base64')[:-1])

NOTE: do not use '<' or '>' in name/password, as this is parsed as XML and will cause issues with parsing.

USAGE:  ./owcal  OPTIONS
  sh [days]
        show upcoming events for n-days, default is 7
  add [days] [hrs] (event summary) [remind]
        add event for n-days, default is 7 , "remind<NR>" at the end sets notification <NR> minutes before begin
  rem (search string)
        remove matching event


EXAMPLE USAGE:
   ./owcal add 3 9-14 Event for 3 days from 9:00 until 14:00
   ./owcal add 9-14 Event for today from 9:00 until 14:00, with 15 minute before start reminder remind15
   ./owcal add +3 9-14 Event for 3 days, beginning in 3 days from 9:00 until 14:00
   ./owcal add +2-5 9-14 Event for 5 days, beginning in 2 days from 9:00 until 14:00
   ./owcal rem some event       remove event containing "some event"



Troubleshooting:
If you get following error:
Traceback (most recent call last):
  File "./owcal", line 265, in <module>
    add(sys.argv)
  File "./owcal", line 198, in add
    calendar = connector[0]
TypeError: 'NoneType' object has no attribute '__getitem__'
check if you have valid calendar name filled in script.

2 comments:

  1. Would it be possible to add a function that removes events older than X days?
    Easy or not? ;-)

    ReplyDelete
    Replies
    1. Hi, it should be easy, you just have to specify some date range, e.g. find events between 6 days and one year in the past and delete them all...
      -from the rem function
      calEvents = calendar.date_search(datetime.now().date()-timedelta(days=365), datetime.now().date()-timedelta(days=6))

      Or you can get all events (if you don't want to specify date range) by using
      http://pythonhosted.org/caldav/caldav/objects.html#caldav.objects.Calendar.events
      and go through all events looking on timestamp and deleting older ones.

      Delete