| 1 | -- ndp - Natural Date Parser library for Lua |
| 2 | -- Copyright (C) 2009 Matthew Wild <mwild1@gmail.com> |
| 3 | -- |
| 4 | -- This project is MIT/X11 licensed. Please see the |
| 5 | -- COPYING file in the source package for more information. |
| 6 | -- |
| 7 | |
| 8 | module(..., package.seeall); |
| 9 | |
| 10 | require "luarocks.require" |
| 11 | require "lpeg" |
| 12 | |
| 13 | -- Add case-insensitive string matching to Lpeg |
| 14 | function lpeg.Pi(s) |
| 15 | local patt = lpeg.P(true); |
| 16 | for c in s:gmatch(".") do |
| 17 | patt = patt * (lpeg.P(c:lower()) + lpeg.P(c:upper())); |
| 18 | end |
| 19 | return patt; |
| 20 | end |
| 21 | |
| 22 | function lpeg.one_of(list) |
| 23 | local patt = lpeg.P(false); |
| 24 | for _, match in ipairs(list) do |
| 25 | patt = patt + lpeg.Pi(match); |
| 26 | end |
| 27 | return patt; |
| 28 | end |
| 29 | |
| 30 | local wordsep = lpeg.S" "; |
| 31 | |
| 32 | local ordinal = lpeg.P{ lpeg.C(lpeg.R("09")^-2) * (lpeg.Pi("st") + lpeg.Pi("nd") + lpeg.Pi("rd") + lpeg.Pi("th")) + 1 * lpeg.V(1) }; |
| 33 | local number = lpeg.R "09"^1 |
| 34 | |
| 35 | local day_name = lpeg.one_of {'monday', 'tuesday', 'wednesday', |
| 36 | 'thursday', 'friday', 'saturday', 'sunday'} |
| 37 | |
| 38 | local month_name = lpeg.one_of {'january', 'february', 'march', 'april', 'may', 'june', |
| 39 | 'july', 'august', 'september', 'october', 'november', 'december' } |
| 40 | |
| 41 | local year = lpeg.R("09") * lpeg.R("09") * lpeg.R("09") * lpeg.R("09"); |
| 42 | |
| 43 | local unit_of_time = lpeg.one_of { 'second', 'minute', 'hour', 'day', 'week', 'month', 'year' } |
| 44 | |
| 45 | local time_of_day = lpeg.one_of { 'morning', 'noon', 'afternoon', 'evening', 'night', 'midnight' } |
| 46 | local time_of_days = { morning = 09, noon = 12, afternoon = 13, evening = 17, night = 21, midnight = 00 } |
| 47 | |
| 48 | local quantity; |
| 49 | local quantities = { |
| 50 | ["a"] = 1; |
| 51 | ["an"] = 1; |
| 52 | |
| 53 | ["a couple of"] = 2; |
| 54 | |
| 55 | ["a few"] = 3; |
| 56 | ["several"] = 3; |
| 57 | }; |
| 58 | |
| 59 | -- Create 'quantity' to match any of the quantities we know |
| 60 | do |
| 61 | local quantity_list = {}; |
| 62 | for k in pairs(quantities) do |
| 63 | quantity_list[#quantity_list+1] = k; |
| 64 | end |
| 65 | table.sort(quantity_list, function (a,b) return #a>#b; end); |
| 66 | quantity = number + lpeg.one_of(quantity_list); |
| 67 | end |
| 68 | |
| 69 | seconds_in_a = { second = 1 } |
| 70 | seconds_in_a.minute = seconds_in_a.second * 60; |
| 71 | seconds_in_a.hour = seconds_in_a.minute * 60; |
| 72 | seconds_in_a.day = seconds_in_a.hour * 24; |
| 73 | seconds_in_a.week = seconds_in_a.day * 7; |
| 74 | seconds_in_a.month = seconds_in_a.week * 4; |
| 75 | seconds_in_a.year = seconds_in_a.day * 365; |
| 76 | |
| 77 | local function get_time_part(time, part) |
| 78 | return os.date("*t", time)[part]; |
| 79 | end |
| 80 | |
| 81 | local function adjust_time(time, part, value) |
| 82 | local split_time = os.date("*t", time); |
| 83 | |
| 84 | split_time[part] = value; |
| 85 | |
| 86 | return os.time(split_time); |
| 87 | end |
| 88 | |
| 89 | local function find_next_day_by_name(time, day_name) |
| 90 | day_name = day_name:lower():gsub("^.", string.upper); -- Normalize |
| 91 | |
| 92 | for i=1,8 do |
| 93 | time = time + seconds_in_a.day; |
| 94 | if os.date("%A", time) == day_name then |
| 95 | return time; |
| 96 | end |
| 97 | end |
| 98 | return; |
| 99 | end |
| 100 | |
| 101 | local function find_next_month_by_name(time, month_name) |
| 102 | month_name = month_name:lower():gsub("^.", string.upper); -- Normalize |
| 103 | |
| 104 | local split_time = os.date("*t", time); |
| 105 | for i=1,13 do |
| 106 | split_time.month = split_time.month + 1; |
| 107 | if split_time.month == 13 then split_time.month = 1; end |
| 108 | |
| 109 | time = os.time(split_time); |
| 110 | if os.date("%B", time) == month_name then |
| 111 | return time; |
| 112 | end |
| 113 | end |
| 114 | |
| 115 | return; |
| 116 | end |
| 117 | |
| 118 | local function advance_months(time, n) |
| 119 | local split_time = os.date("*t", time); |
| 120 | split_time.month = ((split_time.month-1)+n)%12+1; |
| 121 | split_time.year = split_time.year + math.floor(n/12); |
| 122 | return os.time(split_time); |
| 123 | end |
| 124 | |
| 125 | local function advance_years(time, n) |
| 126 | local split_time = os.date("*t", time); |
| 127 | split_time.year = split_time.year + n; |
| 128 | return os.time(split_time); |
| 129 | end |
| 130 | |
| 131 | function when(str, relative_to) |
| 132 | local time = relative_to or os.time(); |
| 133 | local P, Pi = lpeg.P, lpeg.Pi; |
| 134 | |
| 135 | local patterns = |
| 136 | { |
| 137 | { Pi"today" }; |
| 138 | { Pi"tomorrow" / |
| 139 | function () |
| 140 | time = time + seconds_in_a.day; |
| 141 | end }; |
| 142 | { (lpeg.one_of{"a ", "the "}+true) * Pi"day after" / |
| 143 | function () |
| 144 | time = time + seconds_in_a.day; |
| 145 | end }; |
| 146 | { Pi"next week" / |
| 147 | function () |
| 148 | time = time + seconds_in_a.week; |
| 149 | end }; |
| 150 | { Pi"next month" / |
| 151 | function () |
| 152 | time = advance_months(time, 1); |
| 153 | end }; |
| 154 | { Pi"next year" / |
| 155 | function () |
| 156 | time = advance_years(time, 1); |
| 157 | end }; |
| 158 | { year / |
| 159 | function (year) |
| 160 | time = adjust_time(time, "year", tonumber(year)); |
| 161 | end }; |
| 162 | { (Pi"in " + true) * month_name / |
| 163 | function (month_name) |
| 164 | time = find_next_month_by_name(time, month_name:match("%S+$")); |
| 165 | end }; |
| 166 | { (Pi"on " + true) * day_name / |
| 167 | function (day_name) |
| 168 | time = find_next_day_by_name(time, day_name:match("%S+$")); |
| 169 | end }; |
| 170 | { (Pi"in " + true) * ( quantity * P" " * unit_of_time ) * (P"s"^-1) / |
| 171 | function (number_and_unit) |
| 172 | local number, unit = number_and_unit:gsub("^in ", ""):match("^(.+)%s+(.-)s?$"); |
| 173 | |
| 174 | number = quantities[number] or tonumber(number); |
| 175 | |
| 176 | if unit == "month" then |
| 177 | time = advance_months(time, number); |
| 178 | elseif unit == "year" then |
| 179 | time = advance_years(time, number); |
| 180 | else |
| 181 | time = time + seconds_in_a[unit] * number; |
| 182 | end |
| 183 | end }; |
| 184 | { (lpeg.one_of{"this ", "in the ", "at "} + true)* time_of_day / |
| 185 | function (time_of_day) |
| 186 | time_of_day = time_of_day:match("%S+$"); |
| 187 | |
| 188 | if time_of_day == "morning" and get_time_part(time, "hour") > time_of_days.morning then |
| 189 | time = time + seconds_in_a.day; -- Morning has passed, so next morning |
| 190 | end |
| 191 | |
| 192 | time = adjust_time(time, "hour", time_of_days[time_of_day]); |
| 193 | if time_of_day == "noon" or time_of_day == "midnight" then |
| 194 | time = adjust_time(time, "min", 00); |
| 195 | else |
| 196 | time = adjust_time(time, "min", 30); |
| 197 | end |
| 198 | end }; |
| 199 | } |
| 200 | |
| 201 | local ret, min_pos, max_pos; |
| 202 | local function check_min_pos(start) start = start - 1; if not min_pos or start < min_pos then min_pos = start; end end; |
| 203 | for _, pattern in pairs(patterns) do |
| 204 | ret = lpeg.match(lpeg.P{ lpeg.Cp()*pattern[1] + 1 * (1-wordsep)^0 * wordsep * lpeg.V(1) }/check_min_pos, str); |
| 205 | if ret then |
| 206 | if not max_pos or ret > max_pos then max_pos = ret; end |
| 207 | --print("Matches ".._.." until "..ret); |
| 208 | end |
| 209 | end |
| 210 | |
| 211 | return time, min_pos, max_pos; |
| 212 | end |