# deltaTime.py
#
# Parser to convert a conversational time reference such as "in a minute" or 
# "noon tomorrow" and convert it to a Python datetime.  The returned 
# ParseResults object contains the results name "timeOffset" containing
# the timedelta, and "calculatedTime" containing the computed time relative 
# to datetime.now().
#
# Copyright 2010, by Paul McGuire
#

from datetime import datetime, timedelta
from pyparsing import *
import calendar

__all__ = ["nlTimeExpression"]

# string conversion parse actions
def convertToTimedelta(toks):
    unit = toks.timeunit.lower().rstrip("s")
    td = {
        'week'    : timedelta(7),
        'day'    : timedelta(1),
        'hour'   : timedelta(0,0,0,0,0,1),
        'minute' : timedelta(0,0,0,0,1),
        'second' : timedelta(0,1),
        }[unit]
    if toks.qty:
        td *= int(toks.qty)
    if toks.dir:
        td *= toks.dir
    toks["timeOffset"] = td
 
def convertToDay(toks):
    now = datetime.now()
    if "wkdayRef" in toks:
        todaynum = now.weekday()
        daynames = [n.lower() for n in calendar.day_name]
        nameddaynum = daynames.index(toks.wkdayRef.day.lower())
        if toks.wkdayRef.dir > 0:
            daydiff = (nameddaynum + 7 - todaynum) % 7
        else:
            daydiff = -((todaynum + 7 - nameddaynum) % 7)
        toks["absTime"] = datetime(now.year, now.month, now.day)+timedelta(daydiff)
    else:
        name = toks.name.lower()
        toks["absTime"] = {
            "now"       : now,
            "today"     : datetime(now.year, now.month, now.day),
            "yesterday" : datetime(now.year, now.month, now.day)+timedelta(-1),
            "tomorrow"  : datetime(now.year, now.month, now.day)+timedelta(+1),
            }[name]
 
def convertToAbsTime(toks):
    now = datetime.now()
    if "dayRef" in toks:
        day = toks.dayRef.absTime
        day = datetime(day.year, day.month, day.day)
    else:
        day = datetime(now.year, now.month, now.day)
    if "timeOfDay" in toks:
        if isinstance(toks.timeOfDay,str):
            timeOfDay = {
                "now"      : timedelta(0, (now.hour*60+now.minute)*60+now.second, now.microsecond),
                "noon"     : timedelta(0,0,0,0,0,12),
                "midnight" : timedelta(),
                }[toks.timeOfDay]
        else:
            hhmmss = toks.timeparts
            if hhmmss.miltime:
                hh,mm = hhmmss.miltime
                ss = 0
            else:            
                hh,mm,ss = (hhmmss.HH % 12), hhmmss.MM, hhmmss.SS
                if not mm: mm = 0
                if not ss: ss = 0
                if toks.timeOfDay.ampm == 'pm':
                    hh += 12
            timeOfDay = timedelta(0, (hh*60+mm)*60+ss, 0)
    else:
        timeOfDay = timedelta(0, (now.hour*60+now.minute)*60+now.second, now.microsecond)
    toks["absTime"] = day + timeOfDay
 
def calculateTime(toks):
    if toks.absTime:
        absTime = toks.absTime
    else:
        absTime = datetime.now()
    if toks.timeOffset:
        absTime += toks.timeOffset
    toks["calculatedTime"] = absTime
 
# grammar definitions
CL = CaselessLiteral
today, tomorrow, yesterday, noon, midnight, now = map( CL,
    "today tomorrow yesterday noon midnight now".split())
plural = lambda s : Combine(CL(s) + Optional(CL("s")))
week, day, hour, minute, second = map( plural,
    "week day hour minute second".split())
am = CL("am")
pm = CL("pm")
COLON = Suppress(':')
 
# are these actually operators?
in_ = CL("in").setParseAction(replaceWith(1))
from_ = CL("from").setParseAction(replaceWith(1))
before = CL("before").setParseAction(replaceWith(-1))
after = CL("after").setParseAction(replaceWith(1))
ago = CL("ago").setParseAction(replaceWith(-1))
next_ = CL("next").setParseAction(replaceWith(1))
last_ = CL("last").setParseAction(replaceWith(-1))
at_ = CL("at")
on_ = CL("on")

couple = (Optional(CL("a")) + CL("couple") + Optional(CL("of"))).setParseAction(replaceWith(2))
a_qty = CL("a").setParseAction(replaceWith(1))
integer = Word(nums).setParseAction(lambda t:int(t[0]))
int4 = Group(Word(nums,exact=4).setParseAction(lambda t: [int(t[0][:2]),int(t[0][2:])] ))
def fill_timefields(t):
    t[0]['HH'] = t[0][0]
    t[0]['MM'] = t[0][1]
    t[0]['ampm'] = ('am','pm')[t[0].HH >= 12]
int4.addParseAction(fill_timefields)
qty = integer | couple | a_qty
dayName = oneOf( list(calendar.day_name) )
 
dayOffset = (qty("qty") + (week | day)("timeunit"))
dayFwdBack = (from_ + now.suppress() | ago)("dir")
weekdayRef = (Optional(next_ | last_,1)("dir") + dayName("day"))
dayRef = Optional( (dayOffset + (before | after | from_)("dir") ).setParseAction(convertToTimedelta) ) + \
            ((yesterday | today | tomorrow)("name")|
             weekdayRef("wkdayRef")).setParseAction(convertToDay)
todayRef = (dayOffset + dayFwdBack).setParseAction(convertToTimedelta) | \
            (in_("dir") + qty("qty") + day("timeunit")).setParseAction(convertToTimedelta)
 
dayTimeSpec = dayRef | todayRef
dayTimeSpec.setParseAction(calculateTime)
 
relativeTimeUnit = (week | day | hour | minute | second)
 
timespec = Group(ungroup(int4) |
                 integer("HH") + 
                 ungroup(Optional(COLON + integer,[0]))("MM") + 
                 ungroup(Optional(COLON + integer,[0]))("SS") + 
                 (am | pm)("ampm")
                 )

absTimeSpec = ((noon | midnight | now | timespec("timeparts"))("timeOfDay") + 
                Optional(on_) + Optional(dayRef)("dayRef") |
                dayRef("dayRef") + at_ + 
                (noon | midnight | now | timespec("timeparts"))("timeOfDay"))
absTimeSpec.setParseAction(convertToAbsTime,calculateTime)
 
relTimeSpec = qty("qty") + relativeTimeUnit("timeunit") + \
                (from_ | before | after)("dir") + \
                Optional(at_) + \
                absTimeSpec("absTime") | \
              qty("qty") + relativeTimeUnit("timeunit") + ago("dir") | \
              in_ + qty("qty") + relativeTimeUnit("timeunit") 
relTimeSpec.setParseAction(convertToTimedelta,calculateTime)
 
nlTimeExpression = (absTimeSpec + Optional(dayTimeSpec) | 
                    dayTimeSpec + Optional(Optional(at_) + absTimeSpec) | 
                    relTimeSpec + Optional(absTimeSpec))
 
if __name__ == "__main__":
    # test grammar
    tests = """\
    today
    tomorrow
    yesterday
    in a couple of days
    a couple of days from now
    a couple of days from today
    in a day
    3 days ago
    3 days from now
    a day ago
    in 2 weeks
    in 3 days at 5pm
    now
    10 minutes ago
    10 minutes from now
    in 10 minutes
    in a minute
    in a couple of minutes
    20 seconds ago
    in 30 seconds
    20 seconds before noon
    20 seconds before noon tomorrow
    noon
    midnight
    noon tomorrow
    6am tomorrow
    0800 yesterday
    12:15 AM today
    3pm 2 days from today
    a week from today
    a week from now
    3 weeks ago
    noon next Sunday
    noon Sunday
    noon last Sunday
    2pm next Sunday
    next Sunday at 2pm"""

    print("(relative to %s)" % datetime.now())
    nlTimeExpression.runTests(tests)
