git » el » master » tree

[master] / el

#!/usr/bin/env python

import sys
import os
import optparse
import time
import datetime
import locale
import tempfile
import calendar
import ConfigParser

# json is needed but available only in >= 2.6, simplejson is a third-party
# module with compatible API
try:
	import json
except ImportError:
	import simplejson as json


# enable locale settings
locale.setlocale(locale.LC_ALL, '');


#
# Configuration
#

# A single, global instance so it can be easily accessed when needed. The
# main() function is in charge of loading this with the user-supplied values.
config = ConfigParser.SafeConfigParser()



#
# Event representation (internal and on-disk)
#

class Event (object):
	def __init__(self, date, msg):
		self.date = date
		self.msg = msg

	def __cmp__(self, other):
		return cmp(self.date, other.date)

	def to_dict(self):
		return {
			'date': (self.date.year, self.date.month,
				self.date.day),
			'msg': self.msg,
		}

def event_from_dict(d):
	date = datetime.date(*d['date'])
	msg = d['msg']
	return Event(date, msg)

class DB (object):
	def __init__(self, fname = ""):
		self.fname = fname
		if not self.fname:
			if not os.path.isdir(os.environ["HOME"] + "/.el/"):
				os.mkdir(os.environ["HOME"] + "/.el/")
			self.fname = os.environ["HOME"] + "/.el/events"
		self.dirname = os.path.dirname(self.fname)
		self.events = []
		self.has_loaded = False

	def load(self):
		if self.has_loaded:
			return
		if not os.path.exists(self.fname):
			return
		evts = json.load(open(self.fname))
		self.events = [ event_from_dict(d) for d in evts ]
		self.has_loaded = True

	def save(self):
		self.load()
		self.events.sort()
		evts = [ e.to_dict() for e in self.events ]
		tmpfd, tmpfname = tempfile.mkstemp(dir = self.dirname)
		tmpfd = os.fdopen(tmpfd, 'w')
		json.dump(evts, tmpfd, indent = 4)
		tmpfd.close()
		os.rename(tmpfname, self.fname)

	def append(self, evt):
		self.events.append(evt)
		self.save()


#
# Date parsing
#

def try_strptime(s, *fmts):
	"""Attempts to apply the different formats, in order, to the given
	string. Returns the matching date object (not datetime), or None if
	there was no match."""
	for f in fmts:
		try:
			d = datetime.datetime.strptime(s, f)
		except ValueError:
			continue
		return d.date()
	return None


def parse_when(w):
	"Parses the <when> argument, returns a datetime.date object."
	days_abbr = [ x.lower() for x in calendar.day_abbr]
	days_names = [ x.lower() for x in calendar.day_name]

	today = datetime.date.today()

	w = w.lower()

	if set(w) == set('+'):
		# "+" == tomorrow, "+++" = "3 days from now", and so on
		td = datetime.timedelta(days = len(w))
		return today + td
	elif w.startswith('+') and len(w) > 1:
		# time offset, in days (default), weeks, months or years
		if w[-1] in "dwmy":
			incr = int(w[1:-1])
			if w[-1] == 'd':
				td = datetime.timedelta(days = incr)
			if w[-1] == 'w':
				td = datetime.timedelta(weeks = incr)
		else:
			incr = int(w[1:])
			td = datetime.timedelta(days = incr)
		return today + td
	elif w.isdigit():
		# a day, from either this month or the next
		day = int(w)
		if day <= today.day:
			# the day of the next month
			next_month = (today.month + 1) % 12
			year = today.year
			if next_month < today.month:
				year += 1
			return datetime.date(year, next_month, day)
		else:
			# the day of this month
			return datetime.date(today.year, today.month, day)
	elif w in days_abbr or w in days_names:
		# next <day of the week>
		current = today.weekday()
		if w in days_abbr:
			dst = days_abbr.index(w)
		else:
			dst = days_names.index(w)
		diff = (dst - current) % 7
		return today + datetime.timedelta(days = diff)
	else:
		# attempt to parse different formats; note we force the use of
		# the month by name to prevent misinterpreting ambiguous
		# formats
		d = try_strptime(w,
			"%d-%b", "%d/%b", "%d %b",           # day-month
			"%b-%d", "%b/%d", "%b %d")           # month-day
		if d:
			# if the month is before the current one, it refers to
			# next year's
			if d.month < today.month:
				return datetime.date(today.year + 1, d.month,
						d.day)
			else:
				return datetime.date(today.year, d.month,
						d.day)

		# no match, try the versions with a year
		d = try_strptime(w,
			"%d-%b-%Y", "%d/%b/%Y", "%d %b %Y",  # day-month-year
			"%b-%d-%Y", "%b/%d/%Y", "%b %d %Y",  # month-day-year
			"%Y-%b-%d",                          # year-month-day
		)

		return d


#
# Email sending
#

def send_events(evts):
	import email
	import email.mime.text
	import socket
	import random

	if not config.has_option('email', 'to'):
		print "Error: destination email needed, check config file"
		return 1

	# leave only today's events
	today = datetime.date.today()
	tomorrow = today + datetime.timedelta(days = 1)
	today_evts = [ e for e in evts if e.date == today ]
	tomorrow_evts = [ e for e in evts if e.date == tomorrow ]

	if not today_evts and not tomorrow_evts:
		# do not send anything when there are no events to report
		return 0

	body = "\n"
	if today_evts:
		body += "Events for today, %s\n" % today
		body += "-----------------------------------\n\n"
		for e in today_evts:
			body += "%s  %s\n" % (e.date, e.msg.encode('utf8'))
		body += "\n"
	else:
		body += "No events for today, %s\n" % today

	body += "\n"

	if tomorrow_evts:
		body += "Events for tomorrow, %s\n" % tomorrow
		body += "-----------------------------------\n\n"
		for e in tomorrow_evts:
			body += "%s  %s\n" % (e.date, e.msg.encode('utf8'))
		body += "\n"
	else:
		body += "No events for tomorrow, %s\n" % tomorrow

	e = email.mime.text.MIMEText("")
	e['Subject'] = "Events for %s" % today
	e['To'] = config.get("email", "to")
	if config.has_option("email", "from"):
		e['From'] = config.get("email", "from")
	e['Message-Id'] = "<%f.%d.%d@%s>" % (time.time(), os.getpid(),
			int(random.random() * 100000), socket.gethostname())
	e['X-Sender'] = "el"
	e.set_payload(body, 'utf8')

	return sendmail(e)

def sendmail(e):
	# TODO: SMTP via configuration
	import subprocess
	cmd = subprocess.Popen( ["/usr/sbin/sendmail", "-t"],
			stdin = subprocess.PIPE)
	cmd.communicate(e.as_string())
	return cmd.wait()


#
# Main functions
#

def main():
	usage = \
"""
	%prog [options] <when> <message>
		Add an event

	%prog [options] <when>
		List events for today or on the given date

	%prog [options] --list [when]
		List events, either all or on the given date

	%prog [options] --send-email
		Send today's events via email, using sendmail
"""
	parser = optparse.OptionParser(usage = usage)
	parser.add_option("", "--send-email",
		action = "store_true", default = False,
		help = "send events via email")
	parser.add_option("-l", "--list",
		action = "store_true", default = False,
		help = "list events")
	parser.add_option("-c", "--config-file",
		default = "",
		help = "configuration file path [~/.el/config]")

	options, args = parser.parse_args()

	cfname = os.environ['HOME'] + '/.el/config'
	if options.config_file:
		cfname = options.config_file
	if os.path.exists(cfname):
		config.read(cfname)

	db = DB()
	db.load()

	if options.send_email:
		return send_events(db.events)

	elif options.list or len(args) <= 1:
		if options.list:
			when = None
		else:
			when = datetime.date.today()

		if args:
			when = parse_when(args[0])
			if not when:
				print "Error: unknown date format"
				return 1

		for evt in db.events:
			if when and evt.date != when:
				continue
			elif not when:
				if evt.date < datetime.date.today():
					continue

			flag = " "
			if evt.date == datetime.date.today():
				flag = "*"
			print flag, evt.date, evt.msg

	else:
		if len(args) < 2:
			parser.print_help()
			return 1

		when = parse_when(args[0])
		msg = " ".join(args[1:])

		if not when:
			print "Error: unknown date format"
			return 1

		db.append(Event(when, msg))

if __name__ == '__main__':
	sys.exit(main())