ndp.lua

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