nl2time 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/clock/index.d.ts +40 -0
- package/dist/clock/index.js +99 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +75 -0
- package/dist/corpus/runner.d.ts +81 -0
- package/dist/corpus/runner.js +220 -0
- package/dist/data/dayPeriods.d.ts +18 -0
- package/dist/data/dayPeriods.js +24 -0
- package/dist/data/weekData.d.ts +11 -0
- package/dist/data/weekData.js +25 -0
- package/dist/describe/index.d.ts +25 -0
- package/dist/describe/index.js +289 -0
- package/dist/engine/resolve.d.ts +31 -0
- package/dist/engine/resolve.js +684 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/ir/types.d.ts +165 -0
- package/dist/ir/types.js +31 -0
- package/dist/ir/validate.d.ts +11 -0
- package/dist/ir/validate.js +205 -0
- package/dist/llm/index.d.ts +26 -0
- package/dist/llm/index.js +89 -0
- package/dist/parse/en.d.ts +18 -0
- package/dist/parse/en.js +2708 -0
- package/dist/parse/index.d.ts +20 -0
- package/dist/parse/index.js +230 -0
- package/dist/parse/tokenizer.d.ts +33 -0
- package/dist/parse/tokenizer.js +118 -0
- package/package.json +56 -0
- package/schema/timeexpr.schema.json +239 -0
package/dist/parse/en.js
ADDED
|
@@ -0,0 +1,2708 @@
|
|
|
1
|
+
const UNIT_WORDS = {
|
|
2
|
+
second: 'second', seconds: 'second', sec: 'second', secs: 'second',
|
|
3
|
+
minute: 'minute', minutes: 'minute', min: 'minute', mins: 'minute',
|
|
4
|
+
hour: 'hour', hours: 'hour', hr: 'hour', hrs: 'hour',
|
|
5
|
+
day: 'day', days: 'day',
|
|
6
|
+
week: 'week', weeks: 'week', wk: 'week', wks: 'week',
|
|
7
|
+
month: 'month', months: 'month', mo: 'month',
|
|
8
|
+
quarter: 'quarter', quarters: 'quarter',
|
|
9
|
+
year: 'year', years: 'year', yr: 'year', yrs: 'year',
|
|
10
|
+
night: 'day', nights: 'day', // "two nights" as a duration
|
|
11
|
+
};
|
|
12
|
+
const WEEKDAY_WORDS = {
|
|
13
|
+
monday: 'mon', mon: 'mon',
|
|
14
|
+
tuesday: 'tue', tue: 'tue', tues: 'tue',
|
|
15
|
+
wednesday: 'wed', wed: 'wed', weds: 'wed',
|
|
16
|
+
thursday: 'thu', thu: 'thu', thur: 'thu', thurs: 'thu',
|
|
17
|
+
friday: 'fri', fri: 'fri',
|
|
18
|
+
saturday: 'sat', sat: 'sat',
|
|
19
|
+
sunday: 'sun', sun: 'sun',
|
|
20
|
+
};
|
|
21
|
+
const MONTH_WORDS = {
|
|
22
|
+
january: 1, jan: 1, february: 2, feb: 2, march: 3, mar: 3, april: 4, apr: 4,
|
|
23
|
+
may: 5, june: 6, jun: 6, july: 7, jul: 7, august: 8, aug: 8,
|
|
24
|
+
september: 9, sep: 9, sept: 9, october: 10, oct: 10,
|
|
25
|
+
november: 11, nov: 11, december: 12, dec: 12,
|
|
26
|
+
};
|
|
27
|
+
const PERIOD_WORDS = {
|
|
28
|
+
morning: 'morning', mornings: 'morning',
|
|
29
|
+
afternoon: 'afternoon', afternoons: 'afternoon',
|
|
30
|
+
evening: 'evening', evenings: 'evening',
|
|
31
|
+
night: 'night',
|
|
32
|
+
};
|
|
33
|
+
const ONES = {
|
|
34
|
+
one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8,
|
|
35
|
+
nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14,
|
|
36
|
+
fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19,
|
|
37
|
+
};
|
|
38
|
+
const TENS = { twenty: 20, thirty: 30, forty: 40, fifty: 50 };
|
|
39
|
+
const FUZZY = { a: 1, an: 1, couple: 2, few: 3, some: 3, several: 3 };
|
|
40
|
+
const NOW = { op: 'now' };
|
|
41
|
+
function snapNow(unit) {
|
|
42
|
+
return { op: 'snap', base: NOW, unit };
|
|
43
|
+
}
|
|
44
|
+
function snapOffset(amount, unit) {
|
|
45
|
+
return { op: 'snap', base: { op: 'offset', base: NOW, amount, unit }, unit };
|
|
46
|
+
}
|
|
47
|
+
function word(t) {
|
|
48
|
+
return t?.type === 'word' ? t.value : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Digits, word numbers (incl. "twenty nine"), fuzzy quantities, decimals. */
|
|
51
|
+
function readNumber(tokens, i) {
|
|
52
|
+
const t = tokens[i];
|
|
53
|
+
if (t?.type === 'number')
|
|
54
|
+
return { value: t.value, consumed: 1 };
|
|
55
|
+
const w = word(t);
|
|
56
|
+
if (w === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (/^\d+\.\d+$/.test(w))
|
|
59
|
+
return { value: Number(w), consumed: 1 };
|
|
60
|
+
if (w === '\u00bd')
|
|
61
|
+
return { value: 0.5, consumed: 1 };
|
|
62
|
+
if (w === '\u00bc')
|
|
63
|
+
return { value: 0.25, consumed: 1 };
|
|
64
|
+
if (w === '\u00be')
|
|
65
|
+
return { value: 0.75, consumed: 1 };
|
|
66
|
+
if (w in ONES) {
|
|
67
|
+
// "two thousand [and] thirty two"
|
|
68
|
+
if (word(tokens[i + 1]) === 'thousand') {
|
|
69
|
+
let at = i + 2;
|
|
70
|
+
let value = ONES[w] * 1000;
|
|
71
|
+
if (word(tokens[at]) === 'and')
|
|
72
|
+
at += 1;
|
|
73
|
+
const rest = word(tokens[at]);
|
|
74
|
+
if (rest !== undefined && (rest in TENS || rest in ONES)) {
|
|
75
|
+
const rn = readNumber(tokens, at);
|
|
76
|
+
if (rn && rn.value < 1000) {
|
|
77
|
+
return { value: value + rn.value, consumed: at + rn.consumed - i };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { value, consumed: 2 };
|
|
81
|
+
}
|
|
82
|
+
return { value: ONES[w], consumed: 1 };
|
|
83
|
+
}
|
|
84
|
+
if (w in TENS) {
|
|
85
|
+
// "twenty nine" / "twenty and four"
|
|
86
|
+
let at = i + 1;
|
|
87
|
+
if (word(tokens[at]) === 'and')
|
|
88
|
+
at += 1;
|
|
89
|
+
const next = word(tokens[at]);
|
|
90
|
+
if (next !== undefined && next in ONES && ONES[next] < 10) {
|
|
91
|
+
return { value: TENS[w] + ONES[next], consumed: at + 1 - i };
|
|
92
|
+
}
|
|
93
|
+
return { value: TENS[w], consumed: 1 };
|
|
94
|
+
}
|
|
95
|
+
if (w in FUZZY) {
|
|
96
|
+
// "a couple", "a few" — collapse the article.
|
|
97
|
+
if ((w === 'a' || w === 'an') && word(tokens[i + 1]) !== undefined && word(tokens[i + 1]) in FUZZY) {
|
|
98
|
+
const inner = FUZZY[word(tokens[i + 1])];
|
|
99
|
+
return { value: inner, consumed: 2 };
|
|
100
|
+
}
|
|
101
|
+
return { value: FUZZY[w], consumed: 1 };
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
function unitField(unit) {
|
|
106
|
+
return unit === 'quarter' ? 'months' : `${unit}s`;
|
|
107
|
+
}
|
|
108
|
+
/** Calendar amount for n units — quarters scale to months. */
|
|
109
|
+
function amountFor(unit, n) {
|
|
110
|
+
return unit === 'quarter' ? { months: 3 * n } : { [`${unit}s`]: n };
|
|
111
|
+
}
|
|
112
|
+
/** A year as digits or words ("2032", "two thousand and thirty two"). */
|
|
113
|
+
function readYear(tokens, i) {
|
|
114
|
+
const y = yearAt(tokens, i);
|
|
115
|
+
if (y !== undefined)
|
|
116
|
+
return { value: y, consumed: 1 };
|
|
117
|
+
const n = readNumber(tokens, i);
|
|
118
|
+
if (n && Number.isInteger(n.value) && n.value >= 1500 && n.value <= 2199)
|
|
119
|
+
return n;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
function yearAt(tokens, i) {
|
|
123
|
+
const t = tokens[i];
|
|
124
|
+
return t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 9999
|
|
125
|
+
? t.value
|
|
126
|
+
: undefined;
|
|
127
|
+
}
|
|
128
|
+
/** "in the morning" / "at night" / "tonight" lookahead. Returns period + consumed. */
|
|
129
|
+
function readPeriodSuffix(tokens, i) {
|
|
130
|
+
let at = i;
|
|
131
|
+
if (word(tokens[at]) === 'in' || word(tokens[at]) === 'at')
|
|
132
|
+
at += 1;
|
|
133
|
+
if (word(tokens[at]) === 'the')
|
|
134
|
+
at += 1;
|
|
135
|
+
const w = word(tokens[at]);
|
|
136
|
+
if (w === 'tonight')
|
|
137
|
+
return { period: 'night', consumed: at + 1 - i };
|
|
138
|
+
const period = w !== undefined ? PERIOD_WORDS[w] : undefined;
|
|
139
|
+
if (period && at > i)
|
|
140
|
+
return { period, consumed: at + 1 - i };
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
function meridiemFor(period) {
|
|
144
|
+
return period === 'morning' ? 'am' : 'pm';
|
|
145
|
+
}
|
|
146
|
+
// --- rules -----------------------------------------------------------------
|
|
147
|
+
/** today / yesterday / tomorrow / tonight / day before yesterday / day after tomorrow */
|
|
148
|
+
const ruleDeicticDay = (tokens, i) => {
|
|
149
|
+
const w = word(tokens[i]);
|
|
150
|
+
if (w === 'today' || w === 'tdy')
|
|
151
|
+
return { expr: snapNow('day'), consumed: 1, confidence: 1, role: 'date' };
|
|
152
|
+
if (w === 'yesterday')
|
|
153
|
+
return { expr: snapOffset(-1, 'day'), consumed: 1, confidence: 1, role: 'date' };
|
|
154
|
+
if (w === 'tomorrow' || w === 'tmrw' || w === 'tmr') {
|
|
155
|
+
return { expr: snapOffset(1, 'day'), consumed: 1, confidence: 1, role: 'date' };
|
|
156
|
+
}
|
|
157
|
+
if (w === 'tonight' || w === 'tonite') {
|
|
158
|
+
return {
|
|
159
|
+
expr: { op: 'intersect', parts: [snapNow('day'), { op: 'literal', dayPeriod: 'night' }] },
|
|
160
|
+
consumed: 1,
|
|
161
|
+
confidence: 1,
|
|
162
|
+
role: 'datetime',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (w === 'day' || (w === 'the' && word(tokens[i + 1]) === 'day')) {
|
|
166
|
+
const at = w === 'the' ? i + 1 : i;
|
|
167
|
+
const rel = word(tokens[at + 1]);
|
|
168
|
+
const target = word(tokens[at + 2]);
|
|
169
|
+
if (rel === 'before' && target === 'yesterday') {
|
|
170
|
+
return { expr: snapOffset(-2, 'day'), consumed: at + 3 - i, confidence: 1, role: 'date' };
|
|
171
|
+
}
|
|
172
|
+
if (rel === 'after' && target === 'tomorrow') {
|
|
173
|
+
return { expr: snapOffset(2, 'day'), consumed: at + 3 - i, confidence: 1, role: 'date' };
|
|
174
|
+
}
|
|
175
|
+
// Trailing "the day after?" / "the day before" — adjacent days.
|
|
176
|
+
if (rel === 'after' && target === undefined && w === 'the') {
|
|
177
|
+
return { expr: snapOffset(1, 'day'), consumed: at + 2 - i, confidence: 0.8, role: 'date' };
|
|
178
|
+
}
|
|
179
|
+
if (rel === 'before' && target === undefined && w === 'the') {
|
|
180
|
+
return { expr: snapOffset(-1, 'day'), consumed: at + 2 - i, confidence: 0.8, role: 'date' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Ranges: [from|between]? A (to|and|until|till|through|-) B [shared suffix].
|
|
187
|
+
* Ellipsis is resolved by the suffix: "between 3 and 12 of Sept" is a day
|
|
188
|
+
* range in September; "5 to 6pm" shares B's meridiem; "between 5 and 6 in the
|
|
189
|
+
* afternoon" infers pm for both.
|
|
190
|
+
*/
|
|
191
|
+
const ruleRange = (tokens, i, ctx) => {
|
|
192
|
+
let at = i;
|
|
193
|
+
const prefix = word(tokens[at]);
|
|
194
|
+
const hasPrefix = prefix === 'from' || prefix === 'between';
|
|
195
|
+
if (hasPrefix)
|
|
196
|
+
at += 1;
|
|
197
|
+
// Joined tokens: "4-23" (day range), "11-4" (hour range), "2014-2018".
|
|
198
|
+
const joined = word(tokens[at]);
|
|
199
|
+
// "10/1/2018-10/7/2018" and "5/1-5/7/2020": joined numeric-date ranges.
|
|
200
|
+
const fdr = joined?.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})-(\d{1,2})\/(\d{1,2})\/(\d{4})$/) ??
|
|
201
|
+
joined?.match(/^(\d{1,2})\/(\d{1,2})()-(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
202
|
+
if (fdr) {
|
|
203
|
+
const sharedYear = Number(fdr[6]);
|
|
204
|
+
const d1 = numericDate(fdr[3] === '' ? [Number(fdr[1]), Number(fdr[2]), sharedYear] : [Number(fdr[1]), Number(fdr[2]), Number(fdr[3])], ctx.dateOrder);
|
|
205
|
+
const d2 = numericDate([Number(fdr[4]), Number(fdr[5]), sharedYear], ctx.dateOrder);
|
|
206
|
+
if (d1 && d2) {
|
|
207
|
+
return {
|
|
208
|
+
expr: { op: 'between', start: { op: 'literal', date: d1 }, end: { op: 'literal', date: d2 } },
|
|
209
|
+
consumed: at + 1 - i,
|
|
210
|
+
confidence: 0.9,
|
|
211
|
+
role: 'date',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Year range vs military time: leading zero or implausible values mean
|
|
216
|
+
// "0730-0930" is a time range, not years.
|
|
217
|
+
const looksLikeYears = joined !== undefined &&
|
|
218
|
+
/^\d{4}[-~]\d{4}$/.test(joined) &&
|
|
219
|
+
!joined.startsWith('0') &&
|
|
220
|
+
Number(joined.slice(0, 4)) >= 1000 && Number(joined.slice(0, 4)) <= 2999 &&
|
|
221
|
+
Number(joined.slice(5, 9)) >= 1000 && Number(joined.slice(5, 9)) <= 2999 &&
|
|
222
|
+
Number(joined.slice(0, 4)) < Number(joined.slice(5, 9));
|
|
223
|
+
const ym = looksLikeYears ? joined?.match(/^(\d{4})[-~](\d{4})$/) : null;
|
|
224
|
+
if (ym) {
|
|
225
|
+
return {
|
|
226
|
+
expr: {
|
|
227
|
+
op: 'between',
|
|
228
|
+
start: { op: 'literal', date: { year: Number(ym[1]) } },
|
|
229
|
+
end: { op: 'literal', date: { year: Number(ym[2]) } },
|
|
230
|
+
},
|
|
231
|
+
consumed: at + 1 - i,
|
|
232
|
+
confidence: 0.9,
|
|
233
|
+
role: 'date',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// "0730-0930": military time range (year ranges were claimed above).
|
|
237
|
+
const mil = looksLikeYears ? null : joined?.match(/^(\d{3,4})-(\d{3,4})$/);
|
|
238
|
+
if (mil && Number(mil[1]) % 100 < 60 && Number(mil[2]) % 100 < 60 && Number(mil[1]) <= 2359 && Number(mil[2]) <= 2359) {
|
|
239
|
+
const mkMil = (v) => {
|
|
240
|
+
const hour = Math.floor(v / 100);
|
|
241
|
+
return {
|
|
242
|
+
op: 'literal',
|
|
243
|
+
time: hour <= 12
|
|
244
|
+
? { hour, minute: v % 100, meridiem: 'unknown' }
|
|
245
|
+
: { hour, minute: v % 100 },
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
expr: { op: 'between', start: mkMil(Number(mil[1])), end: mkMil(Number(mil[2])) },
|
|
250
|
+
consumed: at + 1 - i,
|
|
251
|
+
confidence: 0.9,
|
|
252
|
+
role: 'time',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// "10-1-2018-10-7-2018": two joined numeric dates.
|
|
256
|
+
const dd = joined?.match(/^(\d{1,2})-(\d{1,2})-(\d{4})-(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
257
|
+
if (dd) {
|
|
258
|
+
const d1 = numericDate([Number(dd[1]), Number(dd[2]), Number(dd[3])], ctx.dateOrder);
|
|
259
|
+
const d2 = numericDate([Number(dd[4]), Number(dd[5]), Number(dd[6])], ctx.dateOrder);
|
|
260
|
+
if (d1 && d2) {
|
|
261
|
+
return {
|
|
262
|
+
expr: { op: 'between', start: { op: 'literal', date: d1 }, end: { op: 'literal', date: d2 } },
|
|
263
|
+
consumed: at + 1 - i,
|
|
264
|
+
confidence: 0.9,
|
|
265
|
+
role: 'date',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// "10/1-11/7": joined numeric-date range.
|
|
270
|
+
const ndr = joined?.match(/^(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})$/);
|
|
271
|
+
if (ndr) {
|
|
272
|
+
const d1 = numericDate([Number(ndr[1]), Number(ndr[2])], ctx.dateOrder);
|
|
273
|
+
const d2 = numericDate([Number(ndr[3]), Number(ndr[4])], ctx.dateOrder);
|
|
274
|
+
if (d1 && d2) {
|
|
275
|
+
// "from 5/1-5/7, 2020" — trailing year applies to both ends.
|
|
276
|
+
const year = yearAt(tokens, at + 1);
|
|
277
|
+
if (year !== undefined) {
|
|
278
|
+
d1.year = year;
|
|
279
|
+
d2.year = year;
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
expr: {
|
|
283
|
+
op: 'between',
|
|
284
|
+
start: { op: 'literal', date: d1 },
|
|
285
|
+
end: { op: 'literal', date: d2 },
|
|
286
|
+
},
|
|
287
|
+
consumed: (year !== undefined ? at + 2 : at + 1) - i,
|
|
288
|
+
confidence: 0.9,
|
|
289
|
+
role: 'date',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let a;
|
|
294
|
+
let b0;
|
|
295
|
+
let bStart = at;
|
|
296
|
+
// "11-4", "9.30-4.30pm": joined hour(-minute) ranges.
|
|
297
|
+
let jm = joined?.match(/^(\d{1,2})(?:[.:](\d{2}))?(am|pm)?-(\d{1,2})(?:[.:](\d{2}))?(am|pm)?$/);
|
|
298
|
+
if (jm) {
|
|
299
|
+
const [, h1, m1, mer1, h2, m2, mer2] = jm;
|
|
300
|
+
const timey = m1 !== undefined || m2 !== undefined || mer1 !== undefined || mer2 !== undefined;
|
|
301
|
+
const hoursOk = Number(h1) <= 12 && Number(h2) <= 12;
|
|
302
|
+
const mkTime = (h, m, mer) => ({
|
|
303
|
+
kind: 'time',
|
|
304
|
+
time: {
|
|
305
|
+
hour: Number(h),
|
|
306
|
+
...(m !== undefined ? { minute: Number(m) } : {}),
|
|
307
|
+
meridiem: mer ?? 'unknown',
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
if (hoursOk && (timey || !hasPrefix)) {
|
|
311
|
+
// "11-4", "9.30-4.30pm" → hour range.
|
|
312
|
+
a = mkTime(h1, m1, mer1);
|
|
313
|
+
b0 = mkTime(h2, m2, mer2);
|
|
314
|
+
}
|
|
315
|
+
else if (hasPrefix && !timey && Number(h1) <= 31 && Number(h2) <= 31) {
|
|
316
|
+
// "from 4-23 …" → day-number pair (scope decides day vs time).
|
|
317
|
+
a = { kind: 'number', value: Number(h1) };
|
|
318
|
+
b0 = { kind: 'number', value: Number(h2) };
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
jm = null;
|
|
322
|
+
}
|
|
323
|
+
bStart = at;
|
|
324
|
+
}
|
|
325
|
+
let connWord;
|
|
326
|
+
if (!jm) {
|
|
327
|
+
const read = readOperand(tokens, at, ctx);
|
|
328
|
+
if (!read)
|
|
329
|
+
return undefined;
|
|
330
|
+
a = read.operand;
|
|
331
|
+
at += read.consumed;
|
|
332
|
+
const conn = word(tokens[at]);
|
|
333
|
+
connWord = conn;
|
|
334
|
+
const isConn = conn === 'to' || conn === 'until' || conn === 'till' || conn === 'through' || conn === 'thru' ||
|
|
335
|
+
conn === '-' || (conn === 'and' && prefix === 'between');
|
|
336
|
+
if (!isConn)
|
|
337
|
+
return undefined;
|
|
338
|
+
bStart = at + 1;
|
|
339
|
+
}
|
|
340
|
+
let b;
|
|
341
|
+
let afterB;
|
|
342
|
+
if (jm && b0 && a) {
|
|
343
|
+
b = b0;
|
|
344
|
+
afterB = at + 1;
|
|
345
|
+
// Share B's meridiem with A only when A precedes B within the same half
|
|
346
|
+
// of the day ("5-6pm" → 5pm; "9.30-4.30pm" crosses noon → 9:30am).
|
|
347
|
+
if (a.kind === 'time' && b.kind === 'time' &&
|
|
348
|
+
a.time.meridiem === 'unknown' && b.time.meridiem && b.time.meridiem !== 'unknown' &&
|
|
349
|
+
(a.time.hour ?? 0) < (b.time.hour ?? 0)) {
|
|
350
|
+
a = { kind: 'time', time: { ...a.time, meridiem: b.time.meridiem } };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const read = readOperand(tokens, bStart, ctx);
|
|
355
|
+
if (!read || !a)
|
|
356
|
+
return undefined;
|
|
357
|
+
b = read.operand;
|
|
358
|
+
afterB = bStart + read.consumed;
|
|
359
|
+
}
|
|
360
|
+
// Shared suffix: a date scope ("of Sept", "in next month", "on 1/1/2015",
|
|
361
|
+
// "of April 22") or a day-period ("in the afternoon").
|
|
362
|
+
let scope;
|
|
363
|
+
let period;
|
|
364
|
+
let end = afterB;
|
|
365
|
+
const ps = readPeriodSuffix(tokens, afterB);
|
|
366
|
+
if (ps) {
|
|
367
|
+
period = ps.period;
|
|
368
|
+
end = afterB + ps.consumed;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
let sAt = afterB;
|
|
372
|
+
const lead = word(tokens[sAt]);
|
|
373
|
+
if (lead === 'of' || lead === 'in' || lead === 'on')
|
|
374
|
+
sAt += 1;
|
|
375
|
+
// A bare year suffix ("26 june to 28 june in 2020") rewrites both ends.
|
|
376
|
+
const suffixYear = yearAt(tokens, sAt);
|
|
377
|
+
if (suffixYear !== undefined) {
|
|
378
|
+
const applyYear = (o) => o.kind === 'date' && o.expr.op === 'literal' && o.expr.date !== undefined
|
|
379
|
+
? { kind: 'date', expr: { ...o.expr, date: { ...o.expr.date, year: suffixYear } } }
|
|
380
|
+
: o;
|
|
381
|
+
a = applyYear(a);
|
|
382
|
+
b = applyYear(b);
|
|
383
|
+
end = sAt + 1;
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
for (const rule of [ruleLastThisNext, ruleDeicticDay, ruleCalendarDate]) {
|
|
387
|
+
const m = rule(tokens, sAt, ctx);
|
|
388
|
+
if (m && m.role === 'date') {
|
|
389
|
+
scope = m.expr;
|
|
390
|
+
end = sAt + m.consumed;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const expr = buildRange(a, b, scope, period, connWord);
|
|
397
|
+
if (!expr)
|
|
398
|
+
return undefined;
|
|
399
|
+
// Unprefixed spelled-out number pairs need an explicit "to"/"-" connector
|
|
400
|
+
// (joined tokens like "11-4" are already explicit ranges).
|
|
401
|
+
if (!hasPrefix && !jm && a.kind === 'number' && b.kind === 'number' && !period && !scope) {
|
|
402
|
+
const conn = word(tokens[i + (a.kind === 'number' ? 1 : 0)]);
|
|
403
|
+
if (conn !== 'to' && conn !== '-')
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
return { expr, consumed: end - i, confidence: 0.95, role: rangeRole(a, b) };
|
|
407
|
+
};
|
|
408
|
+
function readOperand(tokens, i, ctx) {
|
|
409
|
+
// "between the end of 2007 and the end of 2008" — inside a range, "end of
|
|
410
|
+
// YEAR" means the year's end boundary (a point), not its final third.
|
|
411
|
+
const w0 = word(tokens[i]);
|
|
412
|
+
if ((w0 === 'end' || w0 === 'beginning' || w0 === 'start') && word(tokens[i + 1]) === 'of') {
|
|
413
|
+
let yAt = i + 2;
|
|
414
|
+
if (word(tokens[yAt]) === 'the')
|
|
415
|
+
yAt += 1;
|
|
416
|
+
const year = yearAt(tokens, yAt);
|
|
417
|
+
if (year !== undefined) {
|
|
418
|
+
return {
|
|
419
|
+
operand: {
|
|
420
|
+
kind: 'date',
|
|
421
|
+
expr: {
|
|
422
|
+
op: 'snap',
|
|
423
|
+
base: { op: 'literal', date: { year } },
|
|
424
|
+
unit: 'year',
|
|
425
|
+
edge: w0 === 'end' ? 'end' : 'start',
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
consumed: yAt + 1 - i,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const rel = ruleLastThisNext(tokens, i, ctx);
|
|
433
|
+
if (rel && rel.role === 'date') {
|
|
434
|
+
// "this friday 5/12": an explicit date after the weekday supersedes it.
|
|
435
|
+
if (rel.expr.op === 'seek' && rel.expr.target.kind === 'weekday') {
|
|
436
|
+
const refine = ruleCalendarDate(tokens, i + rel.consumed, ctx);
|
|
437
|
+
if (refine && refine.expr.op === 'literal' && refine.expr.date?.day !== undefined) {
|
|
438
|
+
return { operand: { kind: 'date', expr: refine.expr }, consumed: rel.consumed + refine.consumed };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { operand: { kind: 'date', expr: rel.expr }, consumed: rel.consumed };
|
|
442
|
+
}
|
|
443
|
+
const date = ruleCalendarDate(tokens, i, ctx);
|
|
444
|
+
if (date)
|
|
445
|
+
return { operand: { kind: 'date', expr: date.expr }, consumed: date.consumed };
|
|
446
|
+
const clock = ruleClockTime(tokens, i, ctx);
|
|
447
|
+
if (clock && clock.expr.op === 'literal' && clock.expr.time) {
|
|
448
|
+
return { operand: { kind: 'time', time: clock.expr.time }, consumed: clock.consumed };
|
|
449
|
+
}
|
|
450
|
+
const year = yearAt(tokens, i);
|
|
451
|
+
if (year !== undefined) {
|
|
452
|
+
return { operand: { kind: 'date', expr: { op: 'literal', date: { year } } }, consumed: 1 };
|
|
453
|
+
}
|
|
454
|
+
const n = readNumber(tokens, i);
|
|
455
|
+
if (n && Number.isInteger(n.value) && n.value >= 1 && n.value <= 31) {
|
|
456
|
+
return { operand: { kind: 'number', value: n.value }, consumed: n.consumed };
|
|
457
|
+
}
|
|
458
|
+
const deictic = ruleDeicticDay(tokens, i, ctx);
|
|
459
|
+
if (deictic)
|
|
460
|
+
return { operand: { kind: 'date', expr: deictic.expr }, consumed: deictic.consumed };
|
|
461
|
+
const wd = WEEKDAY_WORDS[word(tokens[i]) ?? ''];
|
|
462
|
+
if (wd && word(tokens[i]).length > 5) {
|
|
463
|
+
return {
|
|
464
|
+
operand: {
|
|
465
|
+
kind: 'date',
|
|
466
|
+
expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd } },
|
|
467
|
+
},
|
|
468
|
+
consumed: 1,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (word(tokens[i]) === 'now') {
|
|
472
|
+
// In a date range, "now" means today ("between now and November 15th").
|
|
473
|
+
return {
|
|
474
|
+
operand: { kind: 'date', expr: { op: 'snap', base: NOW, unit: 'day', edge: 'start' } },
|
|
475
|
+
consumed: 1,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
function rangeRole(a, b) {
|
|
481
|
+
if (a.kind === 'time' || b.kind === 'time')
|
|
482
|
+
return 'time';
|
|
483
|
+
if (a.kind === 'date' || b.kind === 'date')
|
|
484
|
+
return 'date';
|
|
485
|
+
return 'time';
|
|
486
|
+
}
|
|
487
|
+
/** A scope naming a specific day (vs a month/year) turns number pairs into time ranges. */
|
|
488
|
+
function scopeIsDaySized(expr) {
|
|
489
|
+
if (expr.op === 'literal')
|
|
490
|
+
return expr.date?.day !== undefined;
|
|
491
|
+
if (expr.op === 'snap')
|
|
492
|
+
return expr.unit === 'day';
|
|
493
|
+
if (expr.op === 'seek')
|
|
494
|
+
return expr.target.kind === 'weekday';
|
|
495
|
+
if (expr.op === 'intersect')
|
|
496
|
+
return expr.parts.some(scopeIsDaySized);
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
function buildRange(a, b, scope, period, connWord) {
|
|
500
|
+
// Day range: numbers scoped by a month/year ("between 3 and 12 of Sept"),
|
|
501
|
+
// or numbers too large to be clock hours. Numbers scoped by a specific DAY
|
|
502
|
+
// ("between 3 and 5 on 1/1/2015", "five to seven today") are clock times.
|
|
503
|
+
const dayRange = a.kind === 'number' &&
|
|
504
|
+
b.kind === 'number' &&
|
|
505
|
+
((scope !== undefined && !scopeIsDaySized(scope)) ||
|
|
506
|
+
(scope === undefined && (a.value > 12 || b.value > 12)));
|
|
507
|
+
if (dayRange && a.kind === 'number' && b.kind === 'number') {
|
|
508
|
+
const between = {
|
|
509
|
+
op: 'between',
|
|
510
|
+
start: { op: 'literal', date: { day: a.value } },
|
|
511
|
+
end: { op: 'literal', date: { day: b.value } },
|
|
512
|
+
};
|
|
513
|
+
return scope ? { op: 'intersect', parts: [scope, between] } : between;
|
|
514
|
+
}
|
|
515
|
+
const toTime = (o) => {
|
|
516
|
+
if (o.kind === 'time')
|
|
517
|
+
return { ...o.time };
|
|
518
|
+
if (o.kind === 'number' && o.value >= 1 && o.value <= 12) {
|
|
519
|
+
return { hour: o.value, meridiem: 'unknown' };
|
|
520
|
+
}
|
|
521
|
+
return undefined;
|
|
522
|
+
};
|
|
523
|
+
if (a.kind !== 'date' && b.kind !== 'date') {
|
|
524
|
+
const ta = toTime(a);
|
|
525
|
+
const tb = toTime(b);
|
|
526
|
+
if (!ta || !tb)
|
|
527
|
+
return undefined;
|
|
528
|
+
if (period) {
|
|
529
|
+
const m = meridiemFor(period);
|
|
530
|
+
if (ta.meridiem === 'unknown' || ta.meridiem === undefined)
|
|
531
|
+
ta.meridiem = m;
|
|
532
|
+
if (tb.meridiem === 'unknown' || tb.meridiem === undefined)
|
|
533
|
+
tb.meridiem = m;
|
|
534
|
+
}
|
|
535
|
+
else if (ta.meridiem === 'unknown' && tb.meridiem && tb.meridiem !== 'unknown' &&
|
|
536
|
+
(ta.hour ?? 0) < (tb.hour ?? 0)) {
|
|
537
|
+
ta.meridiem = tb.meridiem; // "5 to 6pm" (but "9.30 to 4.30pm" crosses noon)
|
|
538
|
+
}
|
|
539
|
+
const between = {
|
|
540
|
+
op: 'between',
|
|
541
|
+
start: { op: 'literal', time: ta },
|
|
542
|
+
end: { op: 'literal', time: tb },
|
|
543
|
+
};
|
|
544
|
+
return scope ? { op: 'intersect', parts: [scope, between] } : between;
|
|
545
|
+
}
|
|
546
|
+
if (a.kind === 'date' && b.kind === 'date') {
|
|
547
|
+
// "previous week - Monday": a weekday after a week names the day within it.
|
|
548
|
+
if (a.expr.op === 'snap' && a.expr.unit === 'week' &&
|
|
549
|
+
b.expr.op === 'seek' && b.expr.target.kind === 'weekday') {
|
|
550
|
+
return { op: 'seek', base: a.expr, dir: 'next', target: b.expr.target, n: 1 };
|
|
551
|
+
}
|
|
552
|
+
// Past deictic ends are inclusive: "…to yesterday" runs through
|
|
553
|
+
// yesterday ("…to today/tomorrow" stays exclusive at that day's start).
|
|
554
|
+
if (b.expr.op === 'snap' && b.expr.unit === 'day' && b.expr.edge === undefined &&
|
|
555
|
+
b.expr.base.op === 'offset' && b.expr.base.amount < 0) {
|
|
556
|
+
b = { kind: 'date', expr: { op: 'snap', base: b.expr, unit: 'day', edge: 'end' } };
|
|
557
|
+
}
|
|
558
|
+
// "Friday-Jun-15": a dash-annotated weekday is redundant next to a full
|
|
559
|
+
// date — but "from Friday to Jun 15" is a genuine range.
|
|
560
|
+
if (connWord === '-' &&
|
|
561
|
+
a.expr.op === 'seek' && a.expr.target.kind === 'weekday' &&
|
|
562
|
+
b.expr.op === 'literal' && b.expr.date?.day !== undefined) {
|
|
563
|
+
return b.expr;
|
|
564
|
+
}
|
|
565
|
+
// Year back-propagation: "Nov 18 - Dec 19, 2015", "Nov - Feb 2017".
|
|
566
|
+
let start = a.expr;
|
|
567
|
+
if (a.expr.op === 'literal' && b.expr.op === 'literal' &&
|
|
568
|
+
a.expr.date?.month !== undefined && a.expr.date.year === undefined &&
|
|
569
|
+
b.expr.date?.month !== undefined && b.expr.date.year !== undefined) {
|
|
570
|
+
const year = a.expr.date.month > b.expr.date.month ? b.expr.date.year - 1 : b.expr.date.year;
|
|
571
|
+
start = { ...a.expr, date: { ...a.expr.date, year } };
|
|
572
|
+
}
|
|
573
|
+
return { op: 'between', start, end: b.expr };
|
|
574
|
+
}
|
|
575
|
+
// Mixed date/number: "May 2 to 7".
|
|
576
|
+
if (a.kind === 'date' && b.kind === 'number') {
|
|
577
|
+
return { op: 'between', start: a.expr, end: { op: 'literal', date: { day: b.value } } };
|
|
578
|
+
}
|
|
579
|
+
// "between 3 and 12 of Sept": B swallowed the month — reuse it as scope.
|
|
580
|
+
if (a.kind === 'number' && b.kind === 'date' && b.expr.op === 'literal' && b.expr.date?.day !== undefined && b.expr.date.month !== undefined) {
|
|
581
|
+
const monthScope = {
|
|
582
|
+
op: 'literal',
|
|
583
|
+
date: {
|
|
584
|
+
month: b.expr.date.month,
|
|
585
|
+
...(b.expr.date.year !== undefined ? { year: b.expr.date.year } : {}),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
op: 'intersect',
|
|
590
|
+
parts: [
|
|
591
|
+
monthScope,
|
|
592
|
+
{
|
|
593
|
+
op: 'between',
|
|
594
|
+
start: { op: 'literal', date: { day: a.value } },
|
|
595
|
+
end: { op: 'literal', date: { day: b.expr.date.day } },
|
|
596
|
+
},
|
|
597
|
+
],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
/** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
|
|
603
|
+
const ruleWeekOf = (tokens, i, ctx) => {
|
|
604
|
+
let at = i;
|
|
605
|
+
if (word(tokens[at]) === 'the')
|
|
606
|
+
at += 1;
|
|
607
|
+
// "w/c Feb 4" (week commencing)
|
|
608
|
+
if (word(tokens[at]) === 'w/c' || word(tokens[at]) === 'wc') {
|
|
609
|
+
const date = ruleCalendarDate(tokens, at + 1, ctx);
|
|
610
|
+
if (date && date.role === 'date') {
|
|
611
|
+
return {
|
|
612
|
+
expr: { op: 'snap', base: date.expr, unit: 'week' },
|
|
613
|
+
consumed: at + 1 + date.consumed - i,
|
|
614
|
+
confidence: 0.95,
|
|
615
|
+
role: 'date',
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// "the week starting/beginning/commencing [on] <date>"
|
|
620
|
+
if (word(tokens[at]) === 'week' && ['starting', 'beginning', 'commencing'].includes(word(tokens[at + 1]) ?? '')) {
|
|
621
|
+
let dAt = at + 2;
|
|
622
|
+
if (word(tokens[dAt]) === 'on' || word(tokens[dAt]) === 'from')
|
|
623
|
+
dAt += 1;
|
|
624
|
+
const date = ruleCalendarDate(tokens, dAt, ctx) ?? ruleDeicticDay(tokens, dAt, ctx);
|
|
625
|
+
if (date && date.role === 'date') {
|
|
626
|
+
return {
|
|
627
|
+
expr: { op: 'snap', base: date.expr, unit: 'week' },
|
|
628
|
+
consumed: dAt + date.consumed - i,
|
|
629
|
+
confidence: 1,
|
|
630
|
+
role: 'date',
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
let nth;
|
|
636
|
+
const nw = word(tokens[at]);
|
|
637
|
+
const nt = tokens[at];
|
|
638
|
+
if (nw === 'first') {
|
|
639
|
+
nth = 'first';
|
|
640
|
+
at += 1;
|
|
641
|
+
}
|
|
642
|
+
else if (nw === 'last') {
|
|
643
|
+
nth = 'last';
|
|
644
|
+
at += 1;
|
|
645
|
+
}
|
|
646
|
+
else if (nw === 'second') {
|
|
647
|
+
nth = 2;
|
|
648
|
+
at += 1;
|
|
649
|
+
}
|
|
650
|
+
else if (nw === 'third') {
|
|
651
|
+
nth = 3;
|
|
652
|
+
at += 1;
|
|
653
|
+
}
|
|
654
|
+
else if (nt?.type === 'number' && nt.ordinal && nt.value <= 5) {
|
|
655
|
+
nth = nt.value;
|
|
656
|
+
at += 1;
|
|
657
|
+
}
|
|
658
|
+
if (word(tokens[at]) !== 'week' || word(tokens[at + 1]) !== 'of')
|
|
659
|
+
return undefined;
|
|
660
|
+
at += 2;
|
|
661
|
+
if (word(tokens[at]) === 'the')
|
|
662
|
+
at += 1;
|
|
663
|
+
// Scope: a date, a month, or a year.
|
|
664
|
+
let scope;
|
|
665
|
+
let consumed = 0;
|
|
666
|
+
const year = yearAt(tokens, at);
|
|
667
|
+
const date = ruleCalendarDate(tokens, at, ctx);
|
|
668
|
+
if (date) {
|
|
669
|
+
scope = date.expr;
|
|
670
|
+
consumed = date.consumed;
|
|
671
|
+
}
|
|
672
|
+
else if (year !== undefined) {
|
|
673
|
+
scope = { op: 'literal', date: { year } };
|
|
674
|
+
consumed = 1;
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
const rel = ruleLastThisNext(tokens, at, ctx) ?? ruleDeicticDay(tokens, at, ctx);
|
|
678
|
+
if (!rel)
|
|
679
|
+
return undefined;
|
|
680
|
+
scope = rel.expr;
|
|
681
|
+
consumed = rel.consumed;
|
|
682
|
+
}
|
|
683
|
+
let expr;
|
|
684
|
+
if (nth === undefined) {
|
|
685
|
+
expr = { op: 'snap', base: scope, unit: 'week' };
|
|
686
|
+
}
|
|
687
|
+
else if (nth === 'last') {
|
|
688
|
+
// Last week mostly within the scope: the week containing (end − 4 days) —
|
|
689
|
+
// the ISO minDays-4 idea. A week needs ≥4 of its days inside the period
|
|
690
|
+
// to be its last week.
|
|
691
|
+
expr = {
|
|
692
|
+
op: 'snap',
|
|
693
|
+
base: {
|
|
694
|
+
op: 'offset',
|
|
695
|
+
base: { op: 'snap', base: scope, unit: monthOrYearUnit(scope), edge: 'end' },
|
|
696
|
+
amount: -4,
|
|
697
|
+
unit: 'day',
|
|
698
|
+
},
|
|
699
|
+
unit: 'week',
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const n = nth === 'first' ? 1 : nth;
|
|
704
|
+
const first = {
|
|
705
|
+
op: 'snap',
|
|
706
|
+
base: { op: 'snap', base: scope, unit: monthOrYearUnit(scope), edge: 'start' },
|
|
707
|
+
unit: 'week',
|
|
708
|
+
};
|
|
709
|
+
expr = n === 1 ? first : { op: 'offset', base: first, amount: n - 1, unit: 'week' };
|
|
710
|
+
}
|
|
711
|
+
return { expr, consumed: at + consumed - i, confidence: 1, role: 'date' };
|
|
712
|
+
};
|
|
713
|
+
function monthOrYearUnit(scope) {
|
|
714
|
+
if (scope.op === 'literal' && scope.date && scope.date.month === undefined)
|
|
715
|
+
return 'year';
|
|
716
|
+
return 'month';
|
|
717
|
+
}
|
|
718
|
+
/** "early/earlier/later/late (in) (the) day|this week|this month|today…" → half-splits. */
|
|
719
|
+
const ruleEarlyLate = (tokens, i, ctx) => {
|
|
720
|
+
const w = word(tokens[i]);
|
|
721
|
+
const isEarly = w === 'early' || w === 'earlier';
|
|
722
|
+
const isLate = w === 'later' || w === 'late';
|
|
723
|
+
const isMid = w === 'mid' || w === 'middle';
|
|
724
|
+
// "mid-November", "mid-june" as one token
|
|
725
|
+
const midJoined = w?.match(/^mid-?([a-z]+)$/);
|
|
726
|
+
if (midJoined && MONTH_WORDS[midJoined[1]] !== undefined) {
|
|
727
|
+
return {
|
|
728
|
+
expr: { op: 'literal', date: { month: MONTH_WORDS[midJoined[1]] }, mod: 'mid' },
|
|
729
|
+
consumed: 1,
|
|
730
|
+
confidence: 0.95,
|
|
731
|
+
role: 'date',
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (!isEarly && !isLate && !isMid)
|
|
735
|
+
return undefined;
|
|
736
|
+
const mod = isMid ? 'mid' : isEarly ? 'start' : 'end';
|
|
737
|
+
let at = i + 1;
|
|
738
|
+
if (word(tokens[at]) === 'in')
|
|
739
|
+
at += 1;
|
|
740
|
+
if (word(tokens[at]) === 'of')
|
|
741
|
+
at += 1;
|
|
742
|
+
if (word(tokens[at]) === 'the')
|
|
743
|
+
at += 1;
|
|
744
|
+
// "mid today", "mid November", "mid 1989"
|
|
745
|
+
if (isMid) {
|
|
746
|
+
const month = MONTH_WORDS[word(tokens[at]) ?? ''];
|
|
747
|
+
if (month !== undefined) {
|
|
748
|
+
return {
|
|
749
|
+
expr: { op: 'literal', date: { month }, mod: 'mid' },
|
|
750
|
+
consumed: at + 1 - i,
|
|
751
|
+
confidence: 0.95,
|
|
752
|
+
role: 'date',
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const midYear = yearAt(tokens, at);
|
|
756
|
+
if (midYear !== undefined) {
|
|
757
|
+
return {
|
|
758
|
+
expr: {
|
|
759
|
+
op: 'span',
|
|
760
|
+
anchor: { op: 'literal', date: { year: midYear, month: 5 } },
|
|
761
|
+
amount: { months: 4 },
|
|
762
|
+
},
|
|
763
|
+
consumed: at + 1 - i,
|
|
764
|
+
confidence: 0.95,
|
|
765
|
+
role: 'date',
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
let base;
|
|
770
|
+
let consumed = 0;
|
|
771
|
+
const unitWord = word(tokens[at]);
|
|
772
|
+
const period = unitWord !== undefined ? PERIOD_WORDS[unitWord] : undefined;
|
|
773
|
+
const bareUnit = unitWord !== undefined && unitWord !== 'day' && unitWord !== 'night'
|
|
774
|
+
? UNIT_WORDS[unitWord]
|
|
775
|
+
: undefined;
|
|
776
|
+
if (period) {
|
|
777
|
+
// "late afternoon", "later in the afternoon" → half of the period.
|
|
778
|
+
return {
|
|
779
|
+
expr: {
|
|
780
|
+
op: 'intersect',
|
|
781
|
+
parts: [snapNow('day'), { op: 'literal', dayPeriod: period }],
|
|
782
|
+
mod,
|
|
783
|
+
},
|
|
784
|
+
consumed: at + 1 - i,
|
|
785
|
+
confidence: 0.95,
|
|
786
|
+
role: 'datetime',
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (bareUnit && bareUnit !== 'second' && bareUnit !== 'minute' && bareUnit !== 'hour') {
|
|
790
|
+
// "earlier in the week", "later in the year"
|
|
791
|
+
base = snapNow(bareUnit);
|
|
792
|
+
consumed = 1;
|
|
793
|
+
}
|
|
794
|
+
else if (word(tokens[at]) === 'day') {
|
|
795
|
+
// "early in the day Wednesday" — an optional following weekday rebinds the day.
|
|
796
|
+
const wd = WEEKDAY_WORDS[word(tokens[at + 1]) ?? ''];
|
|
797
|
+
if (wd) {
|
|
798
|
+
base = { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd } };
|
|
799
|
+
consumed = 2;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
base = snapNow('day');
|
|
803
|
+
consumed = 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
const inner = ruleDeicticDay(tokens, at, ctx) ?? ruleLastThisNext(tokens, at, ctx);
|
|
808
|
+
if (!inner || (inner.role !== 'date' && inner.role !== 'datetime'))
|
|
809
|
+
return undefined;
|
|
810
|
+
base = inner.expr;
|
|
811
|
+
consumed = inner.consumed;
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
expr: { ...base, mod },
|
|
815
|
+
consumed: at + consumed - i,
|
|
816
|
+
confidence: 0.95,
|
|
817
|
+
role: 'date',
|
|
818
|
+
};
|
|
819
|
+
};
|
|
820
|
+
/** "end of this sunday", "beginning of next month", "the middle of 2000". */
|
|
821
|
+
const ruleEndOf = (tokens, i, ctx) => {
|
|
822
|
+
const w = word(tokens[i]);
|
|
823
|
+
const isEnd = w === 'end';
|
|
824
|
+
const isStart = w === 'beginning' || w === 'start';
|
|
825
|
+
const isMid = w === 'middle';
|
|
826
|
+
if (!isEnd && !isStart && !isMid)
|
|
827
|
+
return undefined;
|
|
828
|
+
if (word(tokens[i + 1]) !== 'of')
|
|
829
|
+
return undefined;
|
|
830
|
+
let at = i + 2;
|
|
831
|
+
if (word(tokens[at]) === 'the')
|
|
832
|
+
at += 1;
|
|
833
|
+
// "end of the sunday": end-of-day points on the (ambiguous) weekday.
|
|
834
|
+
const edgeWd = WEEKDAY_WORDS[word(tokens[at]) ?? ''];
|
|
835
|
+
if (edgeWd && (isEnd || isStart)) {
|
|
836
|
+
return {
|
|
837
|
+
expr: {
|
|
838
|
+
op: 'snap',
|
|
839
|
+
base: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: edgeWd } },
|
|
840
|
+
unit: 'day',
|
|
841
|
+
edge: isEnd ? 'end' : 'start',
|
|
842
|
+
},
|
|
843
|
+
consumed: at + 1 - i,
|
|
844
|
+
confidence: 0.9,
|
|
845
|
+
role: 'date',
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
// Bare units: "end of day" (a point), "end of month/year/week" (late part
|
|
849
|
+
// as a fixed split: week from +3d, month from the 16th, year from July —
|
|
850
|
+
// deliberately NOT reference-clamped, unlike "later this month").
|
|
851
|
+
const bareUnit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
852
|
+
if (bareUnit && word(tokens[at]) !== 'night') {
|
|
853
|
+
if (bareUnit === 'day') {
|
|
854
|
+
return {
|
|
855
|
+
expr: { op: 'snap', base: snapNow('day'), unit: 'day', edge: isEnd ? 'end' : 'start' },
|
|
856
|
+
consumed: at + 1 - i,
|
|
857
|
+
confidence: 0.9,
|
|
858
|
+
role: 'time',
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (bareUnit === 'week' || bareUnit === 'month' || bareUnit === 'year' || bareUnit === 'quarter') {
|
|
862
|
+
const startPoint = { op: 'snap', base: NOW, unit: bareUnit, edge: 'start' };
|
|
863
|
+
const endPoint = { op: 'snap', base: NOW, unit: bareUnit, edge: 'end' };
|
|
864
|
+
const midOffset = bareUnit === 'week'
|
|
865
|
+
? { op: 'offset', base: startPoint, amount: 3, unit: 'day' }
|
|
866
|
+
: bareUnit === 'month'
|
|
867
|
+
? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
|
|
868
|
+
: bareUnit === 'year'
|
|
869
|
+
? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
|
|
870
|
+
: { op: 'offset', base: startPoint, amount: 45, unit: 'day' };
|
|
871
|
+
const expr2 = isEnd
|
|
872
|
+
? { op: 'between', start: midOffset, end: endPoint }
|
|
873
|
+
: { op: 'between', start: startPoint, end: midOffset };
|
|
874
|
+
return { expr: expr2, consumed: at + 1 - i, confidence: 0.9, role: 'date' };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const inner = ruleDeicticDay(tokens, at, ctx) ??
|
|
878
|
+
ruleLastThisNext(tokens, at, ctx) ??
|
|
879
|
+
ruleBareYear(tokens, at, ctx) ??
|
|
880
|
+
ruleCalendarDate(tokens, at, ctx);
|
|
881
|
+
if (!inner || inner.role !== 'date')
|
|
882
|
+
return undefined;
|
|
883
|
+
// Year scopes split into thirds: beginning = Jan–Apr, middle = May–Aug,
|
|
884
|
+
// end = Sep–Dec (Recognizers convention).
|
|
885
|
+
if (inner.expr.op === 'literal' && inner.expr.date?.year !== undefined && inner.expr.date.month === undefined) {
|
|
886
|
+
const year = inner.expr.date.year;
|
|
887
|
+
const startMonth = isStart ? 1 : isMid ? 5 : 9;
|
|
888
|
+
return {
|
|
889
|
+
expr: {
|
|
890
|
+
op: 'span',
|
|
891
|
+
anchor: { op: 'literal', date: { year, month: startMonth } },
|
|
892
|
+
amount: { months: 4 },
|
|
893
|
+
},
|
|
894
|
+
consumed: at + inner.consumed - i,
|
|
895
|
+
confidence: 0.95,
|
|
896
|
+
role: 'date',
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const dayScoped = inner.expr.op === 'seek' ||
|
|
900
|
+
(inner.expr.op === 'snap' && inner.expr.unit === 'day') ||
|
|
901
|
+
(inner.expr.op === 'literal' && inner.expr.date?.day !== undefined);
|
|
902
|
+
const expr = dayScoped
|
|
903
|
+
? { op: 'snap', base: inner.expr, unit: 'day', edge: isEnd ? 'end' : 'start' }
|
|
904
|
+
: { ...inner.expr, mod: isEnd ? 'end' : isMid ? 'mid' : 'start' };
|
|
905
|
+
return { expr, consumed: at + inner.consumed - i, confidence: 0.95, role: 'date' };
|
|
906
|
+
};
|
|
907
|
+
const REL_SYNONYMS = {
|
|
908
|
+
last: 'last', previous: 'last', prior: 'last',
|
|
909
|
+
this: 'this', current: 'this', present: 'this', that: 'this',
|
|
910
|
+
next: 'next', coming: 'next', upcoming: 'next', following: 'next',
|
|
911
|
+
};
|
|
912
|
+
function weekendExpr(dir) {
|
|
913
|
+
// Weekend = Saturday 00:00 → Monday 00:00.
|
|
914
|
+
return {
|
|
915
|
+
op: 'span',
|
|
916
|
+
anchor: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: 'sat' } },
|
|
917
|
+
amount: { days: 2 },
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
/** last/this/next (+synonyms) + unit | weekday | weekend | day-period | month name. */
|
|
921
|
+
const ruleLastThisNext = (tokens, i, ctx) => {
|
|
922
|
+
const w = REL_SYNONYMS[word(tokens[i]) ?? ''];
|
|
923
|
+
if (w === undefined)
|
|
924
|
+
return undefined;
|
|
925
|
+
// "this calendar year" — 'calendar' is transparent.
|
|
926
|
+
const skip = word(tokens[i + 1]) === 'calendar' ? 1 : 0;
|
|
927
|
+
const nextWord = word(tokens[i + 1 + skip]);
|
|
928
|
+
if (nextWord === undefined) {
|
|
929
|
+
// "this 5/12" / "next 5/19" (numdate token follows).
|
|
930
|
+
const dated = ruleCalendarDate(tokens, i + 1, ctx);
|
|
931
|
+
if (dated && dated.expr.op === 'literal' && dated.expr.date?.day !== undefined && dated.expr.date.year === undefined) {
|
|
932
|
+
const expr2 = w === 'this'
|
|
933
|
+
? dated.expr
|
|
934
|
+
: { op: 'offset', base: dated.expr, amount: w === 'next' ? 1 : -1, unit: 'year' };
|
|
935
|
+
return { expr: expr2, consumed: 1 + dated.consumed, confidence: 0.95, role: 'date' };
|
|
936
|
+
}
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
if (skip) {
|
|
940
|
+
const unit = UNIT_WORDS[nextWord];
|
|
941
|
+
if (unit && unit !== 'day') {
|
|
942
|
+
const delta = w === 'last' ? -1 : w === 'next' ? 1 : 0;
|
|
943
|
+
const expr = delta === 0 ? snapNow(unit) : snapOffset(delta, unit);
|
|
944
|
+
return { expr, consumed: 3, confidence: 1, role: 'date' };
|
|
945
|
+
}
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
if (nextWord === 'weekend') {
|
|
949
|
+
const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'nearest';
|
|
950
|
+
return { expr: weekendExpr(dir), consumed: 2, confidence: 1, role: 'date' };
|
|
951
|
+
}
|
|
952
|
+
if (nextWord === 'workweek' || ((nextWord === 'work' || nextWord === 'working') && word(tokens[i + 2]) === 'week')) {
|
|
953
|
+
// Monday 00:00 → Saturday 00:00 of the relevant week.
|
|
954
|
+
const delta = w === 'last' ? -1 : w === 'next' ? 1 : 0;
|
|
955
|
+
const weekExpr = delta === 0 ? snapNow('week') : snapOffset(delta, 'week');
|
|
956
|
+
return {
|
|
957
|
+
expr: {
|
|
958
|
+
op: 'span',
|
|
959
|
+
anchor: { op: 'snap', base: weekExpr, unit: 'week', edge: 'start' },
|
|
960
|
+
amount: { days: 5 },
|
|
961
|
+
},
|
|
962
|
+
consumed: nextWord === 'workweek' ? 2 : 3,
|
|
963
|
+
confidence: 1,
|
|
964
|
+
role: 'date',
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
const unit = UNIT_WORDS[nextWord];
|
|
968
|
+
if (unit && nextWord !== 'night' && nextWord !== 'nights') {
|
|
969
|
+
const delta = w === 'last' ? -1 : w === 'next' ? 1 : 0;
|
|
970
|
+
// Sub-day units are rolling windows ("next hour" = the coming 60 min).
|
|
971
|
+
if (unit === 'second' || unit === 'minute' || unit === 'hour') {
|
|
972
|
+
if (delta === 0)
|
|
973
|
+
return { expr: snapNow(unit), consumed: 2, confidence: 1, role: 'datetime' };
|
|
974
|
+
return {
|
|
975
|
+
expr: { op: 'span', anchor: NOW, amount: amountFor(unit, delta) },
|
|
976
|
+
consumed: 2,
|
|
977
|
+
confidence: 1,
|
|
978
|
+
role: 'datetime',
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const expr = delta === 0 ? snapNow(unit) : snapOffset(delta, unit);
|
|
982
|
+
return { expr, consumed: 2, confidence: 1, role: 'date' };
|
|
983
|
+
}
|
|
984
|
+
const weekday = WEEKDAY_WORDS[nextWord];
|
|
985
|
+
if (weekday) {
|
|
986
|
+
const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'nearest';
|
|
987
|
+
// Only the literal words "next"/"last" carry the dialect ambiguity;
|
|
988
|
+
// "upcoming Friday" is strictly the adjacent occurrence (n: 1).
|
|
989
|
+
const literalWord = word(tokens[i]);
|
|
990
|
+
const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'nearest';
|
|
991
|
+
return {
|
|
992
|
+
expr: {
|
|
993
|
+
op: 'seek',
|
|
994
|
+
base: NOW,
|
|
995
|
+
dir,
|
|
996
|
+
target: { kind: 'weekday', weekday },
|
|
997
|
+
...(strict ? { n: 1 } : {}),
|
|
998
|
+
},
|
|
999
|
+
consumed: 2,
|
|
1000
|
+
confidence: 1,
|
|
1001
|
+
role: 'date',
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
if (nextWord === 'fortnight') {
|
|
1005
|
+
// "the next fortnight" = the two whole weeks after the current one.
|
|
1006
|
+
const weekDelta = w === 'last' ? -2 : w === 'next' ? 2 : 0;
|
|
1007
|
+
return {
|
|
1008
|
+
expr: {
|
|
1009
|
+
op: 'span',
|
|
1010
|
+
anchor: {
|
|
1011
|
+
op: 'snap',
|
|
1012
|
+
base: { op: 'offset', base: NOW, amount: weekDelta, unit: 'week' },
|
|
1013
|
+
unit: 'week',
|
|
1014
|
+
edge: 'start',
|
|
1015
|
+
},
|
|
1016
|
+
amount: { days: 14 },
|
|
1017
|
+
},
|
|
1018
|
+
consumed: 2,
|
|
1019
|
+
confidence: 0.95,
|
|
1020
|
+
role: 'date',
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const period = PERIOD_WORDS[nextWord];
|
|
1024
|
+
if (period) {
|
|
1025
|
+
const dayExpr = w === 'last' ? snapOffset(-1, 'day') : w === 'next' ? snapOffset(1, 'day') : snapNow('day');
|
|
1026
|
+
return {
|
|
1027
|
+
expr: { op: 'intersect', parts: [dayExpr, { op: 'literal', dayPeriod: period }] },
|
|
1028
|
+
consumed: 2,
|
|
1029
|
+
confidence: 1,
|
|
1030
|
+
role: 'datetime',
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const month = MONTH_WORDS[nextWord];
|
|
1034
|
+
if (month !== undefined) {
|
|
1035
|
+
const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'nearest';
|
|
1036
|
+
return {
|
|
1037
|
+
expr: { op: 'seek', base: NOW, dir, target: { kind: 'month', month } },
|
|
1038
|
+
consumed: 2,
|
|
1039
|
+
confidence: 1,
|
|
1040
|
+
role: 'date',
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
// "this 5/12" / "next 5/19": a dated literal shifted by a year for next/last.
|
|
1044
|
+
const dated = ruleCalendarDate(tokens, i + 1, ctx);
|
|
1045
|
+
if (dated && dated.expr.op === 'literal' && dated.expr.date?.day !== undefined && dated.expr.date.year === undefined) {
|
|
1046
|
+
const expr = w === 'this'
|
|
1047
|
+
? dated.expr
|
|
1048
|
+
: { op: 'offset', base: dated.expr, amount: w === 'next' ? 1 : -1, unit: 'year' };
|
|
1049
|
+
return { expr, consumed: 1 + dated.consumed, confidence: 0.95, role: 'date' };
|
|
1050
|
+
}
|
|
1051
|
+
return undefined;
|
|
1052
|
+
};
|
|
1053
|
+
/** "N units ago" / "in N units" — day-and-coarser results snap to the day. */
|
|
1054
|
+
const ruleAgoIn = (tokens, i) => {
|
|
1055
|
+
const w = word(tokens[i]);
|
|
1056
|
+
const mkExpr = (n, unit, sign) => {
|
|
1057
|
+
const shifted = { op: 'offset', base: NOW, amount: sign * n, unit };
|
|
1058
|
+
const coarse = unit !== 'second' && unit !== 'minute' && unit !== 'hour';
|
|
1059
|
+
return coarse ? { op: 'snap', base: shifted, unit: 'day' } : shifted;
|
|
1060
|
+
};
|
|
1061
|
+
if (w === 'in') {
|
|
1062
|
+
// "in half an hour"
|
|
1063
|
+
if (word(tokens[i + 1]) === 'half' && (word(tokens[i + 2]) === 'an' || word(tokens[i + 2]) === 'a')) {
|
|
1064
|
+
const hUnit = UNIT_WORDS[word(tokens[i + 3]) ?? ''];
|
|
1065
|
+
if (hUnit === 'hour') {
|
|
1066
|
+
return {
|
|
1067
|
+
expr: { op: 'offset', base: NOW, amount: 30, unit: 'minute' },
|
|
1068
|
+
consumed: 4,
|
|
1069
|
+
confidence: 1,
|
|
1070
|
+
role: 'datetime',
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// "in a fortnight['s time]"
|
|
1075
|
+
if (word(tokens[i + 1]) === 'a' && word(tokens[i + 2]) === 'fortnight') {
|
|
1076
|
+
const extra = word(tokens[i + 3]) === 'time' ? 4 : 3;
|
|
1077
|
+
return { expr: mkExpr(14, 'day', 1), consumed: extra, confidence: 1, role: 'date' };
|
|
1078
|
+
}
|
|
1079
|
+
const n = readNumber(tokens, i + 1);
|
|
1080
|
+
if (!n || !Number.isInteger(n.value))
|
|
1081
|
+
return undefined;
|
|
1082
|
+
const unit = UNIT_WORDS[word(tokens[i + 1 + n.consumed]) ?? ''];
|
|
1083
|
+
if (unit) {
|
|
1084
|
+
// "in two years since 2011" belongs to the anchored-offset rule.
|
|
1085
|
+
const after = word(tokens[i + 2 + n.consumed]);
|
|
1086
|
+
if (after === 'since' || after === 'after' || after === 'from')
|
|
1087
|
+
return undefined;
|
|
1088
|
+
return { expr: mkExpr(n.value, unit, 1), consumed: 2 + n.consumed, confidence: 1, role: 'datetime' };
|
|
1089
|
+
}
|
|
1090
|
+
return undefined;
|
|
1091
|
+
}
|
|
1092
|
+
const n = readNumber(tokens, i);
|
|
1093
|
+
if (!n || !Number.isInteger(n.value))
|
|
1094
|
+
return undefined;
|
|
1095
|
+
const unit = UNIT_WORDS[word(tokens[i + n.consumed]) ?? ''];
|
|
1096
|
+
const tail = word(tokens[i + n.consumed + 1]);
|
|
1097
|
+
if (unit && (tail === 'ago' || tail === 'earlier' || tail === 'before')) {
|
|
1098
|
+
return { expr: mkExpr(n.value, unit, -1), consumed: n.consumed + 2, confidence: 1, role: 'datetime' };
|
|
1099
|
+
}
|
|
1100
|
+
if (unit && tail === 'later') {
|
|
1101
|
+
return { expr: mkExpr(n.value, unit, 1), consumed: n.consumed + 2, confidence: 1, role: 'datetime' };
|
|
1102
|
+
}
|
|
1103
|
+
return undefined;
|
|
1104
|
+
};
|
|
1105
|
+
/** "N units after/before <date>": offset; "within N units after X": span. */
|
|
1106
|
+
const ruleNAfterDate = (tokens, i, ctx) => {
|
|
1107
|
+
let at = i;
|
|
1108
|
+
let spanMode = false;
|
|
1109
|
+
const lead = word(tokens[at]);
|
|
1110
|
+
if (lead === 'within') {
|
|
1111
|
+
spanMode = true;
|
|
1112
|
+
at += 1;
|
|
1113
|
+
}
|
|
1114
|
+
else if (lead === 'less' && word(tokens[at + 1]) === 'than') {
|
|
1115
|
+
spanMode = true;
|
|
1116
|
+
at += 2;
|
|
1117
|
+
}
|
|
1118
|
+
const n = readNumber(tokens, at);
|
|
1119
|
+
if (!n || !Number.isInteger(n.value))
|
|
1120
|
+
return undefined;
|
|
1121
|
+
const unit = UNIT_WORDS[word(tokens[at + n.consumed]) ?? ''];
|
|
1122
|
+
if (!unit)
|
|
1123
|
+
return undefined;
|
|
1124
|
+
const dirWord = word(tokens[at + n.consumed + 1]);
|
|
1125
|
+
if (dirWord !== 'after' && dirWord !== 'before' && dirWord !== 'from' && dirWord !== 'since')
|
|
1126
|
+
return undefined;
|
|
1127
|
+
const dAt = at + n.consumed + 2;
|
|
1128
|
+
const inner = ruleDeicticDay(tokens, dAt, ctx) ??
|
|
1129
|
+
ruleNow(tokens, dAt, ctx) ??
|
|
1130
|
+
ruleLastThisNext(tokens, dAt, ctx) ??
|
|
1131
|
+
ruleHoliday(tokens, dAt, ctx) ??
|
|
1132
|
+
ruleTheUnit(tokens, dAt, ctx) ??
|
|
1133
|
+
ruleBareYear(tokens, dAt, ctx) ??
|
|
1134
|
+
ruleCalendarDate(tokens, dAt, ctx);
|
|
1135
|
+
if (!inner)
|
|
1136
|
+
return undefined;
|
|
1137
|
+
const sign = dirWord === 'before' ? -1 : 1;
|
|
1138
|
+
const subDay = unit === 'second' || unit === 'minute' || unit === 'hour';
|
|
1139
|
+
const offset = { op: 'offset', base: inner.expr, amount: sign * n.value, unit };
|
|
1140
|
+
const expr = spanMode
|
|
1141
|
+
? { op: 'span', anchor: inner.expr, amount: amountFor(unit, sign * n.value) }
|
|
1142
|
+
: subDay
|
|
1143
|
+
? offset
|
|
1144
|
+
: { op: 'snap', base: offset, unit: 'day' };
|
|
1145
|
+
return { expr, consumed: dAt + inner.consumed - i, confidence: 1, role: subDay ? 'datetime' : 'date' };
|
|
1146
|
+
};
|
|
1147
|
+
/** "<unit> after next": the unit interval two steps out. */
|
|
1148
|
+
const ruleUnitAfterNext = (tokens, i) => {
|
|
1149
|
+
const unit = UNIT_WORDS[word(tokens[i]) ?? ''];
|
|
1150
|
+
if (!unit)
|
|
1151
|
+
return undefined;
|
|
1152
|
+
if (word(tokens[i + 1]) !== 'after' || word(tokens[i + 2]) !== 'next')
|
|
1153
|
+
return undefined;
|
|
1154
|
+
return { expr: snapOffset(2, unit), consumed: 3, confidence: 1, role: 'date' };
|
|
1155
|
+
};
|
|
1156
|
+
/** "last/past/previous N units" → span backwards; "the past week" → trailing 7 days. */
|
|
1157
|
+
const rulePastN = (tokens, i) => {
|
|
1158
|
+
const w = word(tokens[i]);
|
|
1159
|
+
if (w !== 'last' && w !== 'past' && w !== 'previous' && w !== 'prior')
|
|
1160
|
+
return undefined;
|
|
1161
|
+
const n = readNumber(tokens, i + 1);
|
|
1162
|
+
if (n && Number.isInteger(n.value)) {
|
|
1163
|
+
let uAt = i + 1 + n.consumed;
|
|
1164
|
+
const biz = word(tokens[uAt]) === 'business' || word(tokens[uAt]) === 'working';
|
|
1165
|
+
if (biz)
|
|
1166
|
+
uAt += 1;
|
|
1167
|
+
const unit = UNIT_WORDS[word(tokens[uAt]) ?? ''];
|
|
1168
|
+
if (unit) {
|
|
1169
|
+
if (biz && unit !== 'day')
|
|
1170
|
+
return undefined;
|
|
1171
|
+
return {
|
|
1172
|
+
expr: {
|
|
1173
|
+
op: 'span',
|
|
1174
|
+
anchor: NOW,
|
|
1175
|
+
amount: amountFor(unit, -n.value),
|
|
1176
|
+
...(biz ? { business: true } : {}),
|
|
1177
|
+
},
|
|
1178
|
+
consumed: uAt + 1 - i,
|
|
1179
|
+
confidence: 1,
|
|
1180
|
+
role: 'date',
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (w === 'past') {
|
|
1185
|
+
const soloUnit = UNIT_WORDS[word(tokens[i + 1]) ?? ''];
|
|
1186
|
+
if (soloUnit && word(tokens[i + 1]) !== 'night') {
|
|
1187
|
+
// "the past week" is the trailing 7 days, but "the past quarter/month/
|
|
1188
|
+
// year" reads as the previous calendar period.
|
|
1189
|
+
if (soloUnit === 'month' || soloUnit === 'quarter' || soloUnit === 'year') {
|
|
1190
|
+
return { expr: snapOffset(-1, soloUnit), consumed: 2, confidence: 1, role: 'date' };
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
expr: { op: 'span', anchor: NOW, amount: amountFor(soloUnit, -1) },
|
|
1194
|
+
consumed: 2,
|
|
1195
|
+
confidence: 1,
|
|
1196
|
+
role: 'date',
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return undefined;
|
|
1201
|
+
};
|
|
1202
|
+
/** "next/coming/following N units" / "over the next couple weeks" → span forward. */
|
|
1203
|
+
const ruleNextN = (tokens, i) => {
|
|
1204
|
+
const w0 = word(tokens[i]);
|
|
1205
|
+
if (w0 !== 'next' && w0 !== 'coming' && w0 !== 'upcoming' && w0 !== 'following')
|
|
1206
|
+
return undefined;
|
|
1207
|
+
const n = readNumber(tokens, i + 1);
|
|
1208
|
+
if (!n || !Number.isInteger(n.value))
|
|
1209
|
+
return undefined;
|
|
1210
|
+
let at = i + 1 + n.consumed;
|
|
1211
|
+
const biz = word(tokens[at]) === 'business' || word(tokens[at]) === 'working';
|
|
1212
|
+
if (biz)
|
|
1213
|
+
at += 1;
|
|
1214
|
+
const unit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
1215
|
+
if (!unit || (biz && unit !== 'day'))
|
|
1216
|
+
return undefined;
|
|
1217
|
+
return {
|
|
1218
|
+
expr: {
|
|
1219
|
+
op: 'span',
|
|
1220
|
+
anchor: NOW,
|
|
1221
|
+
amount: amountFor(unit, n.value),
|
|
1222
|
+
...(biz ? { business: true } : {}),
|
|
1223
|
+
},
|
|
1224
|
+
consumed: at + 1 - i,
|
|
1225
|
+
confidence: 1,
|
|
1226
|
+
role: 'date',
|
|
1227
|
+
};
|
|
1228
|
+
};
|
|
1229
|
+
/** "the year [2008]", "the week [31]", "the month", "the day" — with ISO week numbers. */
|
|
1230
|
+
const ruleTheUnit = (tokens, i) => {
|
|
1231
|
+
if (word(tokens[i]) !== 'the')
|
|
1232
|
+
return undefined;
|
|
1233
|
+
// "the same week/month/year (that it happened)" → the current one.
|
|
1234
|
+
if (word(tokens[i + 1]) === 'same') {
|
|
1235
|
+
const unit = UNIT_WORDS[word(tokens[i + 2]) ?? ''];
|
|
1236
|
+
if (unit && unit !== 'second' && unit !== 'minute' && unit !== 'hour') {
|
|
1237
|
+
return { expr: snapNow(unit), consumed: 3, confidence: 0.85, role: 'date' };
|
|
1238
|
+
}
|
|
1239
|
+
return undefined;
|
|
1240
|
+
}
|
|
1241
|
+
const uw = word(tokens[i + 1]);
|
|
1242
|
+
if (uw === 'fortnight') {
|
|
1243
|
+
// Week-aligned: the current and following week.
|
|
1244
|
+
return {
|
|
1245
|
+
expr: {
|
|
1246
|
+
op: 'span',
|
|
1247
|
+
anchor: { op: 'snap', base: NOW, unit: 'week', edge: 'start' },
|
|
1248
|
+
amount: { days: 14 },
|
|
1249
|
+
},
|
|
1250
|
+
consumed: 2,
|
|
1251
|
+
confidence: 0.85,
|
|
1252
|
+
role: 'date',
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (uw === undefined || uw === 'day' || uw === 'night') {
|
|
1256
|
+
// "the day" only as "for/of the day" (too ambiguous otherwise).
|
|
1257
|
+
if (uw === 'day' && (word(tokens[i - 1]) === 'for' || word(tokens[i - 1]) === 'of')) {
|
|
1258
|
+
return { expr: snapNow('day'), consumed: 2, confidence: 0.85, role: 'date' };
|
|
1259
|
+
}
|
|
1260
|
+
return undefined;
|
|
1261
|
+
}
|
|
1262
|
+
const unit = UNIT_WORDS[uw];
|
|
1263
|
+
if (!unit || unit === 'second' || unit === 'minute' || unit === 'hour')
|
|
1264
|
+
return undefined;
|
|
1265
|
+
// "the year to date", "the month to date"
|
|
1266
|
+
if (word(tokens[i + 2]) === 'to' && word(tokens[i + 3]) === 'date') {
|
|
1267
|
+
return {
|
|
1268
|
+
expr: {
|
|
1269
|
+
op: 'between',
|
|
1270
|
+
start: { op: 'snap', base: NOW, unit, edge: 'start' },
|
|
1271
|
+
end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
|
|
1272
|
+
},
|
|
1273
|
+
consumed: 4,
|
|
1274
|
+
confidence: 1,
|
|
1275
|
+
role: 'date',
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
const numAt = word(tokens[i + 2]) === 'of' ? i + 3 : i + 2;
|
|
1279
|
+
const numTok = tokens[numAt];
|
|
1280
|
+
if (numTok?.type === 'number' && !numTok.ordinal) {
|
|
1281
|
+
if (unit === 'year' && numTok.value >= 1000 && numTok.value <= 9999) {
|
|
1282
|
+
return {
|
|
1283
|
+
expr: { op: 'literal', date: { year: numTok.value } },
|
|
1284
|
+
consumed: numAt + 1 - i,
|
|
1285
|
+
confidence: 1,
|
|
1286
|
+
role: 'date',
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
if (unit === 'week' && numTok.value >= 1 && numTok.value <= 53) {
|
|
1290
|
+
// ISO week n = week containing Jan 4, plus (n-1) weeks.
|
|
1291
|
+
return {
|
|
1292
|
+
expr: {
|
|
1293
|
+
op: 'offset',
|
|
1294
|
+
base: { op: 'snap', base: { op: 'literal', date: { month: 1, day: 4 } }, unit: 'week' },
|
|
1295
|
+
amount: numTok.value - 1,
|
|
1296
|
+
unit: 'week',
|
|
1297
|
+
},
|
|
1298
|
+
consumed: 3,
|
|
1299
|
+
confidence: 0.95,
|
|
1300
|
+
role: 'date',
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
// Bare "the week"/"the month"/"the year" — only after a preposition, and
|
|
1305
|
+
// never when a modifier follows ("the week after next", "the week starting…",
|
|
1306
|
+
// "the week before last" — but "the year before independence" is current).
|
|
1307
|
+
const follow = word(tokens[i + 2]);
|
|
1308
|
+
if (follow === 'after' || follow === 'starting' || follow === 'of')
|
|
1309
|
+
return undefined;
|
|
1310
|
+
if (follow === 'before') {
|
|
1311
|
+
const anchorish = word(tokens[i + 3]);
|
|
1312
|
+
if (anchorish === 'last' || anchorish === 'next' || anchorish === 'yesterday' || anchorish === 'that') {
|
|
1313
|
+
return undefined;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return { expr: snapNow(unit), consumed: 2, confidence: 0.8, role: 'date' };
|
|
1317
|
+
};
|
|
1318
|
+
/** Holidays: "christmas [eve] [2019]", "new year", "last easter", "mother's day". */
|
|
1319
|
+
const ruleHoliday = (tokens, i) => {
|
|
1320
|
+
const w = word(tokens[i]);
|
|
1321
|
+
if (w === undefined)
|
|
1322
|
+
return undefined;
|
|
1323
|
+
let name;
|
|
1324
|
+
let consumed = 1;
|
|
1325
|
+
const next = word(tokens[i + 1]);
|
|
1326
|
+
if (w === 'black' && next === 'friday') {
|
|
1327
|
+
name = 'black-friday';
|
|
1328
|
+
consumed = 2;
|
|
1329
|
+
}
|
|
1330
|
+
else if (w === 'christmas' || w === 'xmas') {
|
|
1331
|
+
if (next === 'eve') {
|
|
1332
|
+
name = 'christmas-eve';
|
|
1333
|
+
consumed = 2;
|
|
1334
|
+
}
|
|
1335
|
+
else
|
|
1336
|
+
name = 'christmas';
|
|
1337
|
+
}
|
|
1338
|
+
else if (w === 'new' && (next === 'year' || next === 'years')) {
|
|
1339
|
+
const third = word(tokens[i + 2]);
|
|
1340
|
+
if (third === 'eve') {
|
|
1341
|
+
name = 'new-year-eve';
|
|
1342
|
+
consumed = 3;
|
|
1343
|
+
}
|
|
1344
|
+
else if (third === 'day') {
|
|
1345
|
+
name = 'new-year';
|
|
1346
|
+
consumed = 3;
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
name = 'new-year';
|
|
1350
|
+
consumed = 2;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
else if (w === 'thanksgiving') {
|
|
1354
|
+
name = 'thanksgiving';
|
|
1355
|
+
if (next === 'day')
|
|
1356
|
+
consumed = 2;
|
|
1357
|
+
}
|
|
1358
|
+
else if (w === 'halloween')
|
|
1359
|
+
name = 'halloween';
|
|
1360
|
+
else if (w === 'easter') {
|
|
1361
|
+
name = 'easter';
|
|
1362
|
+
if (next === 'day' || next === 'sunday')
|
|
1363
|
+
consumed = 2;
|
|
1364
|
+
}
|
|
1365
|
+
else if (w === 'valentine' || w === 'valentines') {
|
|
1366
|
+
name = 'valentines';
|
|
1367
|
+
if (next === 'day')
|
|
1368
|
+
consumed = 2;
|
|
1369
|
+
}
|
|
1370
|
+
else if ((w === 'saint' || w === 'st') && (next === 'patrick' || next === 'patricks')) {
|
|
1371
|
+
name = 'st-patricks';
|
|
1372
|
+
consumed = word(tokens[i + 2]) === 'day' ? 3 : 2;
|
|
1373
|
+
}
|
|
1374
|
+
else if (w === 'international' && next === 'workers' && word(tokens[i + 2]) === 'day') {
|
|
1375
|
+
name = 'workers-day';
|
|
1376
|
+
consumed = 3;
|
|
1377
|
+
}
|
|
1378
|
+
else if (next === 'day') {
|
|
1379
|
+
const twoWord = {
|
|
1380
|
+
independence: 'independence-day',
|
|
1381
|
+
labor: 'labor-day',
|
|
1382
|
+
memorial: 'memorial-day',
|
|
1383
|
+
mother: 'mothers-day',
|
|
1384
|
+
mothers: 'mothers-day',
|
|
1385
|
+
father: 'fathers-day',
|
|
1386
|
+
fathers: 'fathers-day',
|
|
1387
|
+
earth: 'earth-day',
|
|
1388
|
+
workers: 'workers-day',
|
|
1389
|
+
may: 'workers-day',
|
|
1390
|
+
};
|
|
1391
|
+
if (twoWord[w]) {
|
|
1392
|
+
name = twoWord[w];
|
|
1393
|
+
consumed = 2;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (!name)
|
|
1397
|
+
return undefined;
|
|
1398
|
+
const year = yearAt(tokens, i + consumed);
|
|
1399
|
+
if (year !== undefined)
|
|
1400
|
+
consumed += 1;
|
|
1401
|
+
const prev = word(tokens[i - 1]);
|
|
1402
|
+
const dir = prev === 'last' || prev === 'previous' ? 'prev' : prev === 'next' || prev === 'coming' ? 'next' : undefined;
|
|
1403
|
+
const holiday = {
|
|
1404
|
+
op: 'holiday',
|
|
1405
|
+
name,
|
|
1406
|
+
...(year !== undefined ? { year } : {}),
|
|
1407
|
+
...(dir !== undefined ? { dir } : {}),
|
|
1408
|
+
};
|
|
1409
|
+
// "labor day weekend": Saturday before through the end of the holiday span.
|
|
1410
|
+
if (word(tokens[i + consumed]) === 'weekend') {
|
|
1411
|
+
return {
|
|
1412
|
+
expr: {
|
|
1413
|
+
op: 'between',
|
|
1414
|
+
start: { op: 'seek', base: holiday, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
|
|
1415
|
+
end: { op: 'snap', base: holiday, unit: 'day', edge: 'end' },
|
|
1416
|
+
},
|
|
1417
|
+
consumed: consumed + 1,
|
|
1418
|
+
confidence: 0.95,
|
|
1419
|
+
role: 'date',
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
return { expr: holiday, consumed, confidence: 0.95, role: 'date' };
|
|
1423
|
+
};
|
|
1424
|
+
/** "the weekend of halloween" / "the weekend of <date>". */
|
|
1425
|
+
const ruleWeekendOf = (tokens, i, ctx) => {
|
|
1426
|
+
let at = i;
|
|
1427
|
+
if (word(tokens[at]) === 'the')
|
|
1428
|
+
at += 1;
|
|
1429
|
+
if (word(tokens[at]) !== 'weekend' || word(tokens[at + 1]) !== 'of')
|
|
1430
|
+
return undefined;
|
|
1431
|
+
at += 2;
|
|
1432
|
+
const inner = ruleHoliday(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
|
|
1433
|
+
if (!inner || inner.role !== 'date')
|
|
1434
|
+
return undefined;
|
|
1435
|
+
return {
|
|
1436
|
+
expr: {
|
|
1437
|
+
op: 'between',
|
|
1438
|
+
start: { op: 'seek', base: inner.expr, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
|
|
1439
|
+
end: { op: 'snap', base: inner.expr, unit: 'day', edge: 'end' },
|
|
1440
|
+
},
|
|
1441
|
+
consumed: at + inner.consumed - i,
|
|
1442
|
+
confidence: 0.95,
|
|
1443
|
+
role: 'date',
|
|
1444
|
+
};
|
|
1445
|
+
};
|
|
1446
|
+
/** "the 15th [day] of next month" → that day within the scope. */
|
|
1447
|
+
const ruleDayOfScope = (tokens, i, ctx) => {
|
|
1448
|
+
let at = i;
|
|
1449
|
+
if (word(tokens[at]) === 'the')
|
|
1450
|
+
at += 1;
|
|
1451
|
+
const t = tokens[at];
|
|
1452
|
+
let day;
|
|
1453
|
+
let dc = 0;
|
|
1454
|
+
if (t?.type === 'number' && t.ordinal && t.value >= 1 && t.value <= 31) {
|
|
1455
|
+
day = t.value;
|
|
1456
|
+
dc = 1;
|
|
1457
|
+
}
|
|
1458
|
+
else {
|
|
1459
|
+
const ord = readOrdinalDayWord(tokens, at);
|
|
1460
|
+
if (ord) {
|
|
1461
|
+
day = ord.value;
|
|
1462
|
+
dc = ord.consumed;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if (day === undefined)
|
|
1466
|
+
return undefined;
|
|
1467
|
+
let oAt = at + dc;
|
|
1468
|
+
if (word(tokens[oAt]) === 'day')
|
|
1469
|
+
oAt += 1;
|
|
1470
|
+
if (word(tokens[oAt]) !== 'of')
|
|
1471
|
+
return undefined;
|
|
1472
|
+
oAt += 1;
|
|
1473
|
+
const scope = ruleLastThisNext(tokens, oAt, ctx) ?? ruleCalendarDate(tokens, oAt, ctx);
|
|
1474
|
+
if (!scope || scope.role !== 'date')
|
|
1475
|
+
return undefined;
|
|
1476
|
+
return {
|
|
1477
|
+
expr: { op: 'intersect', parts: [scope.expr, { op: 'literal', date: { day } }] },
|
|
1478
|
+
consumed: oAt + scope.consumed - i,
|
|
1479
|
+
confidence: 1,
|
|
1480
|
+
role: 'date',
|
|
1481
|
+
};
|
|
1482
|
+
};
|
|
1483
|
+
/** "now", "right now", "at the moment", "at present", "at this time", "otd". */
|
|
1484
|
+
const ruleNow = (tokens, i) => {
|
|
1485
|
+
const w = word(tokens[i]);
|
|
1486
|
+
if (w === 'now')
|
|
1487
|
+
return { expr: NOW, consumed: 1, confidence: 0.85, role: 'datetime' };
|
|
1488
|
+
if (w === 'right' && word(tokens[i + 1]) === 'now') {
|
|
1489
|
+
return { expr: NOW, consumed: 2, confidence: 0.95, role: 'datetime' };
|
|
1490
|
+
}
|
|
1491
|
+
if (w === 'otd') {
|
|
1492
|
+
return { expr: snapNow('day'), consumed: 1, confidence: 0.85, role: 'date' };
|
|
1493
|
+
}
|
|
1494
|
+
if (w === 'at') {
|
|
1495
|
+
const a = word(tokens[i + 1]);
|
|
1496
|
+
const b = word(tokens[i + 2]);
|
|
1497
|
+
const c = word(tokens[i + 3]);
|
|
1498
|
+
if (a === 'present')
|
|
1499
|
+
return { expr: NOW, consumed: 2, confidence: 0.9, role: 'datetime' };
|
|
1500
|
+
if (a === 'the' && (b === 'moment' || b === 'minute')) {
|
|
1501
|
+
return { expr: NOW, consumed: 3, confidence: 0.9, role: 'datetime' };
|
|
1502
|
+
}
|
|
1503
|
+
if (a === 'the' && b === 'present' && c === 'time') {
|
|
1504
|
+
return { expr: NOW, consumed: 4, confidence: 0.9, role: 'datetime' };
|
|
1505
|
+
}
|
|
1506
|
+
if (a === 'this' && b === 'time') {
|
|
1507
|
+
return { expr: NOW, consumed: 3, confidence: 0.9, role: 'datetime' };
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return undefined;
|
|
1511
|
+
};
|
|
1512
|
+
/** "the 5 past minutes", "the 2 next days" → rolling/complete spans. */
|
|
1513
|
+
const ruleTheNPastNext = (tokens, i) => {
|
|
1514
|
+
// Require the article: without it, "…counted 13 last minute" would misparse.
|
|
1515
|
+
if (word(tokens[i - 1]) !== 'the')
|
|
1516
|
+
return undefined;
|
|
1517
|
+
const n = readNumber(tokens, i);
|
|
1518
|
+
if (!n || !Number.isInteger(n.value))
|
|
1519
|
+
return undefined;
|
|
1520
|
+
const dirWord = word(tokens[i + n.consumed]);
|
|
1521
|
+
const DIR = {
|
|
1522
|
+
past: -1, last: -1, previous: -1, prior: -1,
|
|
1523
|
+
next: 1, upcoming: 1, coming: 1, following: 1,
|
|
1524
|
+
};
|
|
1525
|
+
const sign = dirWord !== undefined ? DIR[dirWord] : undefined;
|
|
1526
|
+
if (sign === undefined)
|
|
1527
|
+
return undefined;
|
|
1528
|
+
const unit = UNIT_WORDS[word(tokens[i + n.consumed + 1]) ?? ''];
|
|
1529
|
+
if (!unit)
|
|
1530
|
+
return undefined;
|
|
1531
|
+
return {
|
|
1532
|
+
expr: { op: 'span', anchor: NOW, amount: amountFor(unit, sign * n.value) },
|
|
1533
|
+
consumed: n.consumed + 2,
|
|
1534
|
+
confidence: 1,
|
|
1535
|
+
role: 'date',
|
|
1536
|
+
};
|
|
1537
|
+
};
|
|
1538
|
+
const ORDINAL_WORDS = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5 };
|
|
1539
|
+
const ORDINAL_DAY_WORDS = {
|
|
1540
|
+
first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7,
|
|
1541
|
+
eighth: 8, ninth: 9, tenth: 10, eleventh: 11, twelfth: 12, thirteenth: 13,
|
|
1542
|
+
fourteenth: 14, fifteenth: 15, sixteenth: 16, seventeenth: 17,
|
|
1543
|
+
eighteenth: 18, nineteenth: 19, twentieth: 20, thirtieth: 30,
|
|
1544
|
+
};
|
|
1545
|
+
/** "first", "twenty first" → day-of-month. */
|
|
1546
|
+
function readOrdinalDayWord(tokens, i) {
|
|
1547
|
+
const w = word(tokens[i]);
|
|
1548
|
+
if (w === undefined)
|
|
1549
|
+
return undefined;
|
|
1550
|
+
if (w === 'twenty' || w === 'thirty') {
|
|
1551
|
+
const next = word(tokens[i + 1]);
|
|
1552
|
+
if (next !== undefined && next in ORDINAL_DAY_WORDS && ORDINAL_DAY_WORDS[next] <= 9) {
|
|
1553
|
+
return { value: (w === 'twenty' ? 20 : 30) + ORDINAL_DAY_WORDS[next], consumed: 2 };
|
|
1554
|
+
}
|
|
1555
|
+
return undefined;
|
|
1556
|
+
}
|
|
1557
|
+
if (w in ORDINAL_DAY_WORDS)
|
|
1558
|
+
return { value: ORDINAL_DAY_WORDS[w], consumed: 1 };
|
|
1559
|
+
return undefined;
|
|
1560
|
+
}
|
|
1561
|
+
/** "the first Monday [evening] of next month", "the last Friday of March". */
|
|
1562
|
+
const ruleNthWeekdayOf = (tokens, i, ctx) => {
|
|
1563
|
+
let at = i;
|
|
1564
|
+
const ow = word(tokens[at]);
|
|
1565
|
+
const t = tokens[at];
|
|
1566
|
+
let n;
|
|
1567
|
+
if (ow !== undefined && ow in ORDINAL_WORDS)
|
|
1568
|
+
n = ORDINAL_WORDS[ow];
|
|
1569
|
+
else if (ow === 'last')
|
|
1570
|
+
n = 'last';
|
|
1571
|
+
else if (t?.type === 'number' && t.ordinal && t.value <= 5)
|
|
1572
|
+
n = t.value;
|
|
1573
|
+
if (n === undefined)
|
|
1574
|
+
return undefined;
|
|
1575
|
+
const weekday = WEEKDAY_WORDS[word(tokens[at + 1]) ?? ''];
|
|
1576
|
+
if (!weekday)
|
|
1577
|
+
return undefined;
|
|
1578
|
+
at += 2;
|
|
1579
|
+
// Optional day-period or time range between weekday and "of" ("first
|
|
1580
|
+
// Monday evening of…", "first Monday 1pm to 3pm of next month").
|
|
1581
|
+
let period;
|
|
1582
|
+
let timeRange;
|
|
1583
|
+
const pw = word(tokens[at]);
|
|
1584
|
+
if (pw !== undefined && PERIOD_WORDS[pw]) {
|
|
1585
|
+
period = PERIOD_WORDS[pw];
|
|
1586
|
+
at += 1;
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
// Bound the range attempt at the next "of" so it can't swallow the scope.
|
|
1590
|
+
let ofIdx = -1;
|
|
1591
|
+
for (let k = at; k < Math.min(at + 8, tokens.length); k += 1) {
|
|
1592
|
+
if (word(tokens[k]) === 'of') {
|
|
1593
|
+
ofIdx = k;
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
const scoped = ofIdx >= 0 ? tokens.slice(0, ofIdx) : tokens;
|
|
1598
|
+
const range = ruleRange(scoped, at, ctx);
|
|
1599
|
+
if (range && range.role === 'time') {
|
|
1600
|
+
timeRange = range.expr;
|
|
1601
|
+
at += range.consumed;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (word(tokens[at]) !== 'of')
|
|
1605
|
+
return undefined;
|
|
1606
|
+
at += 1;
|
|
1607
|
+
if (word(tokens[at]) === 'the')
|
|
1608
|
+
at += 1;
|
|
1609
|
+
const scope = ruleLastThisNext(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
|
|
1610
|
+
if (!scope || scope.role !== 'date')
|
|
1611
|
+
return undefined;
|
|
1612
|
+
const seek = n === 'last'
|
|
1613
|
+
? {
|
|
1614
|
+
op: 'seek',
|
|
1615
|
+
base: { op: 'snap', base: scope.expr, unit: 'month', edge: 'end' },
|
|
1616
|
+
dir: 'prev',
|
|
1617
|
+
target: { kind: 'weekday', weekday },
|
|
1618
|
+
}
|
|
1619
|
+
: { op: 'seek', base: scope.expr, dir: 'next', target: { kind: 'weekday', weekday }, n };
|
|
1620
|
+
const expr = period
|
|
1621
|
+
? { op: 'intersect', parts: [seek, { op: 'literal', dayPeriod: period }] }
|
|
1622
|
+
: timeRange
|
|
1623
|
+
? { op: 'intersect', parts: [seek, timeRange] }
|
|
1624
|
+
: seek;
|
|
1625
|
+
return {
|
|
1626
|
+
expr,
|
|
1627
|
+
consumed: at + scope.consumed - i,
|
|
1628
|
+
confidence: 1,
|
|
1629
|
+
role: period || timeRange ? 'datetime' : 'date',
|
|
1630
|
+
};
|
|
1631
|
+
};
|
|
1632
|
+
/** "Monday the twenty seventh" / "Monday 21" → the day number (weekday is redundant). */
|
|
1633
|
+
const ruleWeekdayDay = (tokens, i) => {
|
|
1634
|
+
const w = word(tokens[i]);
|
|
1635
|
+
const weekday = w !== undefined ? WEEKDAY_WORDS[w] : undefined;
|
|
1636
|
+
if (!weekday || WEEKDAY_WORDS[w] === undefined)
|
|
1637
|
+
return undefined;
|
|
1638
|
+
if (w.length <= 5 && !['friday', 'monday', 'sunday'].includes(w)) {
|
|
1639
|
+
// abbreviations handled elsewhere
|
|
1640
|
+
}
|
|
1641
|
+
let at = i + 1;
|
|
1642
|
+
const hasThe = word(tokens[at]) === 'the';
|
|
1643
|
+
if (hasThe)
|
|
1644
|
+
at += 1;
|
|
1645
|
+
const t = tokens[at];
|
|
1646
|
+
let day;
|
|
1647
|
+
let dc = 0;
|
|
1648
|
+
// A bare cardinal ≤ 12 after a weekday reads as a clock hour ("Wednesday
|
|
1649
|
+
// 4"), not a day — require an ordinal, "the", or a value above 12.
|
|
1650
|
+
if (t?.type === 'number' && t.value >= 1 && t.value <= 31 &&
|
|
1651
|
+
(t.ordinal || hasThe || t.value > 12) &&
|
|
1652
|
+
yearAt(tokens, at) === undefined) {
|
|
1653
|
+
day = t.value;
|
|
1654
|
+
dc = 1;
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
const ord = readOrdinalDayWord(tokens, at);
|
|
1658
|
+
if (ord) {
|
|
1659
|
+
day = ord.value;
|
|
1660
|
+
dc = ord.consumed;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (day === undefined)
|
|
1664
|
+
return undefined;
|
|
1665
|
+
return {
|
|
1666
|
+
expr: { op: 'literal', date: { day } },
|
|
1667
|
+
consumed: at + dc - i,
|
|
1668
|
+
confidence: 0.95,
|
|
1669
|
+
role: 'date',
|
|
1670
|
+
};
|
|
1671
|
+
};
|
|
1672
|
+
/** Bare weekday: "Friday" → both directions, bias-ordered (engine). */
|
|
1673
|
+
const ruleWeekdayAlone = (tokens, i) => {
|
|
1674
|
+
const prev = word(tokens[i - 1]);
|
|
1675
|
+
if (prev !== undefined && REL_SYNONYMS[prev])
|
|
1676
|
+
return undefined;
|
|
1677
|
+
const w = word(tokens[i]);
|
|
1678
|
+
if (w === 'weekend') {
|
|
1679
|
+
return { expr: weekendExpr('nearest'), consumed: 1, confidence: 0.85, role: 'date' };
|
|
1680
|
+
}
|
|
1681
|
+
const weekday = w !== undefined ? WEEKDAY_WORDS[w] : undefined;
|
|
1682
|
+
if (!weekday)
|
|
1683
|
+
return undefined;
|
|
1684
|
+
// Abbreviated forms ("sat", "tue") are too collision-prone without a
|
|
1685
|
+
// preposition ("(Tue)" annotations, "sat down", …).
|
|
1686
|
+
const isAbbrev = w !== undefined && w.length <= 5 && WEEKDAY_WORDS[w] !== undefined &&
|
|
1687
|
+
!['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].includes(w);
|
|
1688
|
+
if (isAbbrev && !['on', 'for', 'until', 'till', 'by', 'next', 'last', 'this'].includes(prev ?? '')) {
|
|
1689
|
+
return undefined;
|
|
1690
|
+
}
|
|
1691
|
+
return {
|
|
1692
|
+
expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday } },
|
|
1693
|
+
consumed: 1,
|
|
1694
|
+
confidence: 0.9,
|
|
1695
|
+
role: 'date',
|
|
1696
|
+
};
|
|
1697
|
+
};
|
|
1698
|
+
/** A bare year after a temporal preposition: "in 2150", "since 1990". */
|
|
1699
|
+
const ruleBareYear = (tokens, i) => {
|
|
1700
|
+
const y = readYear(tokens, i);
|
|
1701
|
+
if (!y || y.value < 1500 || y.value > 2199)
|
|
1702
|
+
return undefined;
|
|
1703
|
+
const prev = word(tokens[i - 1]);
|
|
1704
|
+
if (prev !== undefined && !['in', 'since', 'by', 'until', 'during', 'year', 'of', 'from'].includes(prev)) {
|
|
1705
|
+
return undefined;
|
|
1706
|
+
}
|
|
1707
|
+
return { expr: { op: 'literal', date: { year: y.value } }, consumed: y.consumed, confidence: 0.85, role: 'date' };
|
|
1708
|
+
};
|
|
1709
|
+
/** Decades ("1990s", "the 90s") and centuries ("21st century"). */
|
|
1710
|
+
const ruleDecadeCentury = (tokens, i) => {
|
|
1711
|
+
const t = tokens[i];
|
|
1712
|
+
const w = word(t);
|
|
1713
|
+
const dm = w?.match(/^(\d{2,4})'?s$/);
|
|
1714
|
+
if (dm) {
|
|
1715
|
+
let year = Number(dm[1]);
|
|
1716
|
+
if (year < 100)
|
|
1717
|
+
year += year >= 30 ? 1900 : 2000;
|
|
1718
|
+
return {
|
|
1719
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { year } }, amount: { years: 10 } },
|
|
1720
|
+
consumed: 1,
|
|
1721
|
+
confidence: 0.9,
|
|
1722
|
+
role: 'date',
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
if (t?.type === 'number' && word(tokens[i + 1]) === 's' && t.value >= 1000) {
|
|
1726
|
+
return {
|
|
1727
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { year: t.value } }, amount: { years: 10 } },
|
|
1728
|
+
consumed: 2,
|
|
1729
|
+
confidence: 0.9,
|
|
1730
|
+
role: 'date',
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
if (t?.type === 'number' && t.ordinal && word(tokens[i + 1]) === 'century') {
|
|
1734
|
+
return {
|
|
1735
|
+
expr: {
|
|
1736
|
+
op: 'span',
|
|
1737
|
+
anchor: { op: 'literal', date: { year: (t.value - 1) * 100 } },
|
|
1738
|
+
amount: { years: 100 },
|
|
1739
|
+
},
|
|
1740
|
+
consumed: 2,
|
|
1741
|
+
confidence: 0.95,
|
|
1742
|
+
role: 'date',
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
return undefined;
|
|
1746
|
+
};
|
|
1747
|
+
/** "May 29", "May 29, 2026", "29 May", "the 3rd", "May", "Q3 2026", "2017 april", "Oct/2", "mar4". */
|
|
1748
|
+
const ruleCalendarDate = (tokens, i, ctx) => {
|
|
1749
|
+
const w = word(tokens[i]);
|
|
1750
|
+
const hLead = w?.match(/^h([12])$/);
|
|
1751
|
+
if (hLead) {
|
|
1752
|
+
let yAt = i + 1;
|
|
1753
|
+
if (word(tokens[yAt]) === 'of')
|
|
1754
|
+
yAt += 1;
|
|
1755
|
+
const year = yearAt(tokens, yAt);
|
|
1756
|
+
if (year !== undefined) {
|
|
1757
|
+
return {
|
|
1758
|
+
expr: {
|
|
1759
|
+
op: 'span',
|
|
1760
|
+
anchor: { op: 'literal', date: { year, month: hLead[1] === '1' ? 1 : 7 } },
|
|
1761
|
+
amount: { months: 6 },
|
|
1762
|
+
},
|
|
1763
|
+
consumed: yAt + 1 - i,
|
|
1764
|
+
confidence: 1,
|
|
1765
|
+
role: 'date',
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
if (w && /^q[1-4]$/.test(w)) {
|
|
1770
|
+
const q = Number(w[1]);
|
|
1771
|
+
// "q1 2017" / "q1 of 2017"
|
|
1772
|
+
let yAt = i + 1;
|
|
1773
|
+
if (word(tokens[yAt]) === 'of')
|
|
1774
|
+
yAt += 1;
|
|
1775
|
+
const year = yearAt(tokens, yAt);
|
|
1776
|
+
const date = { month: (q - 1) * 3 + 1 };
|
|
1777
|
+
if (year !== undefined)
|
|
1778
|
+
date.year = year;
|
|
1779
|
+
return {
|
|
1780
|
+
expr: { op: 'snap', base: { op: 'literal', date }, unit: 'quarter' },
|
|
1781
|
+
consumed: year !== undefined ? yAt + 1 - i : 1,
|
|
1782
|
+
confidence: 1,
|
|
1783
|
+
role: 'date',
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
// "2017 april" / "2017 apr" / "2017 q1" / "2019-aug-1" (dash-split)
|
|
1787
|
+
const yr = yearAt(tokens, i);
|
|
1788
|
+
if (yr !== undefined) {
|
|
1789
|
+
let mAt = i + 1;
|
|
1790
|
+
if (word(tokens[mAt]) === '-')
|
|
1791
|
+
mAt += 1;
|
|
1792
|
+
const m = MONTH_WORDS[word(tokens[mAt]) ?? ''];
|
|
1793
|
+
if (m !== undefined) {
|
|
1794
|
+
let dAt = mAt + 1;
|
|
1795
|
+
if (word(tokens[dAt]) === '-')
|
|
1796
|
+
dAt += 1;
|
|
1797
|
+
const dTok = tokens[dAt];
|
|
1798
|
+
if (dTok?.type === 'number' && !dTok.ordinal && dTok.value >= 1 && dTok.value <= 31) {
|
|
1799
|
+
return {
|
|
1800
|
+
expr: { op: 'literal', date: { year: yr, month: m, day: dTok.value } },
|
|
1801
|
+
consumed: dAt + 1 - i,
|
|
1802
|
+
confidence: 1,
|
|
1803
|
+
role: 'date',
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
return { expr: { op: 'literal', date: { year: yr, month: m } }, consumed: mAt + 1 - i, confidence: 1, role: 'date' };
|
|
1807
|
+
}
|
|
1808
|
+
const hw = word(tokens[i + 1])?.match(/^h([12])$/);
|
|
1809
|
+
if (hw) {
|
|
1810
|
+
return {
|
|
1811
|
+
expr: {
|
|
1812
|
+
op: 'span',
|
|
1813
|
+
anchor: { op: 'literal', date: { year: yr, month: hw[1] === '1' ? 1 : 7 } },
|
|
1814
|
+
amount: { months: 6 },
|
|
1815
|
+
},
|
|
1816
|
+
consumed: 2,
|
|
1817
|
+
confidence: 1,
|
|
1818
|
+
role: 'date',
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
const qw = word(tokens[i + 1])?.match(/^q([1-4])$/);
|
|
1822
|
+
if (qw) {
|
|
1823
|
+
return {
|
|
1824
|
+
expr: {
|
|
1825
|
+
op: 'snap',
|
|
1826
|
+
base: { op: 'literal', date: { year: yr, month: (Number(qw[1]) - 1) * 3 + 1 } },
|
|
1827
|
+
unit: 'quarter',
|
|
1828
|
+
},
|
|
1829
|
+
consumed: 2,
|
|
1830
|
+
confidence: 1,
|
|
1831
|
+
role: 'date',
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
// "2019-h2" joined
|
|
1836
|
+
const yh = w?.match(/^(\d{4})-h([12])$/);
|
|
1837
|
+
if (yh) {
|
|
1838
|
+
return {
|
|
1839
|
+
expr: {
|
|
1840
|
+
op: 'span',
|
|
1841
|
+
anchor: { op: 'literal', date: { year: Number(yh[1]), month: yh[2] === '1' ? 1 : 7 } },
|
|
1842
|
+
amount: { months: 6 },
|
|
1843
|
+
},
|
|
1844
|
+
consumed: 1,
|
|
1845
|
+
confidence: 1,
|
|
1846
|
+
role: 'date',
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
// "q1-2019" joined
|
|
1850
|
+
const qy = w?.match(/^q([1-4])-(\d{4})$/);
|
|
1851
|
+
if (qy) {
|
|
1852
|
+
return {
|
|
1853
|
+
expr: {
|
|
1854
|
+
op: 'snap',
|
|
1855
|
+
base: { op: 'literal', date: { year: Number(qy[2]), month: (Number(qy[1]) - 1) * 3 + 1 } },
|
|
1856
|
+
unit: 'quarter',
|
|
1857
|
+
},
|
|
1858
|
+
consumed: 1,
|
|
1859
|
+
confidence: 1,
|
|
1860
|
+
role: 'date',
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
// "2017-q1" joined, "cy 2008", "cy18"
|
|
1864
|
+
const yq = w?.match(/^(\d{4})-q([1-4])$/);
|
|
1865
|
+
if (yq) {
|
|
1866
|
+
return {
|
|
1867
|
+
expr: {
|
|
1868
|
+
op: 'snap',
|
|
1869
|
+
base: { op: 'literal', date: { year: Number(yq[1]), month: (Number(yq[2]) - 1) * 3 + 1 } },
|
|
1870
|
+
unit: 'quarter',
|
|
1871
|
+
},
|
|
1872
|
+
consumed: 1,
|
|
1873
|
+
confidence: 1,
|
|
1874
|
+
role: 'date',
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
const cy = w?.match(/^cy(\d{2}|\d{4})?$/);
|
|
1878
|
+
if (cy) {
|
|
1879
|
+
if (cy[1] !== undefined) {
|
|
1880
|
+
const year = cy[1].length === 2 ? 2000 + Number(cy[1]) : Number(cy[1]);
|
|
1881
|
+
return { expr: { op: 'literal', date: { year } }, consumed: 1, confidence: 0.9, role: 'date' };
|
|
1882
|
+
}
|
|
1883
|
+
const yTok = yearAt(tokens, i + 1);
|
|
1884
|
+
if (yTok !== undefined) {
|
|
1885
|
+
return { expr: { op: 'literal', date: { year: yTok } }, consumed: 2, confidence: 0.9, role: 'date' };
|
|
1886
|
+
}
|
|
1887
|
+
return undefined;
|
|
1888
|
+
}
|
|
1889
|
+
// "2015-3" / "12-2015" → month+year
|
|
1890
|
+
const ymJoined = w?.match(/^(\d{4})-(\d{1,2})$/) ?? null;
|
|
1891
|
+
const myJoined = w?.match(/^(\d{1,2})-(\d{4})$/) ?? null;
|
|
1892
|
+
if (ymJoined || myJoined) {
|
|
1893
|
+
const year = Number(ymJoined ? ymJoined[1] : myJoined[2]);
|
|
1894
|
+
const m = Number(ymJoined ? ymJoined[2] : myJoined[1]);
|
|
1895
|
+
if (m >= 1 && m <= 12) {
|
|
1896
|
+
return {
|
|
1897
|
+
expr: { op: 'literal', date: { year, month: m } },
|
|
1898
|
+
consumed: 1,
|
|
1899
|
+
confidence: 0.9,
|
|
1900
|
+
role: 'date',
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
// "friday-jun-15" (weekday prefix is redundant), "this friday 7.6" (day.month or month.day)
|
|
1905
|
+
const wmd = w?.match(/^([a-z]{3,9})-([a-z]{3,9})-?(\d{1,2})$/);
|
|
1906
|
+
if (wmd && WEEKDAY_WORDS[wmd[1]] && MONTH_WORDS[wmd[2]] !== undefined) {
|
|
1907
|
+
return {
|
|
1908
|
+
expr: { op: 'literal', date: { month: MONTH_WORDS[wmd[2]], day: Number(wmd[3]) } },
|
|
1909
|
+
consumed: 1,
|
|
1910
|
+
confidence: 0.9,
|
|
1911
|
+
role: 'date',
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
const dotted = w?.match(/^(\d{1,2})\.(\d{1,2})$/);
|
|
1915
|
+
if (dotted) {
|
|
1916
|
+
const prev = word(tokens[i - 1]);
|
|
1917
|
+
const prevIsDay = prev !== undefined && (WEEKDAY_WORDS[prev] !== undefined || prev === 'on');
|
|
1918
|
+
if (prevIsDay) {
|
|
1919
|
+
const date = numericDate([Number(dotted[1]), Number(dotted[2])], ctx.dateOrder);
|
|
1920
|
+
if (date) {
|
|
1921
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.85, role: 'date' };
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
// "20161016"
|
|
1926
|
+
const packed = w?.match(/^(\d{4})(\d{2})(\d{2})$/);
|
|
1927
|
+
if (packed) {
|
|
1928
|
+
const date = valid({ year: Number(packed[1]), month: Number(packed[2]), day: Number(packed[3]) });
|
|
1929
|
+
if (date) {
|
|
1930
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.85, role: 'date' };
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
// "2016 10 16"
|
|
1934
|
+
const spacedY = yearAt(tokens, i);
|
|
1935
|
+
if (spacedY !== undefined && tokens[i + 1]?.type === 'number' && tokens[i + 2]?.type === 'number') {
|
|
1936
|
+
const m1 = tokens[i + 1].value;
|
|
1937
|
+
const d1 = tokens[i + 2].value;
|
|
1938
|
+
const date = valid({ year: spacedY, month: m1, day: d1 });
|
|
1939
|
+
if (date && m1 <= 12) {
|
|
1940
|
+
return { expr: { op: 'literal', date }, consumed: 3, confidence: 0.8, role: 'date' };
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
// "2020/23/Sep"
|
|
1944
|
+
const ydm = w?.match(/^(\d{4})\/(\d{1,2})\/([a-z]{3,9})$/);
|
|
1945
|
+
if (ydm && MONTH_WORDS[ydm[3]] !== undefined) {
|
|
1946
|
+
return {
|
|
1947
|
+
expr: { op: 'literal', date: { year: Number(ydm[1]), month: MONTH_WORDS[ydm[3]], day: Number(ydm[2]) } },
|
|
1948
|
+
consumed: 1,
|
|
1949
|
+
confidence: 0.9,
|
|
1950
|
+
role: 'date',
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
// "2019/aug/01"
|
|
1954
|
+
const ymd2 = w?.match(/^(\d{4})\/([a-z]{3,9})\/(\d{1,2})$/);
|
|
1955
|
+
if (ymd2 && MONTH_WORDS[ymd2[2]] !== undefined) {
|
|
1956
|
+
return {
|
|
1957
|
+
expr: { op: 'literal', date: { year: Number(ymd2[1]), month: MONTH_WORDS[ymd2[2]], day: Number(ymd2[3]) } },
|
|
1958
|
+
consumed: 1,
|
|
1959
|
+
confidence: 0.95,
|
|
1960
|
+
role: 'date',
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
// "22/Jan/2019"
|
|
1964
|
+
const dmy = w?.match(/^(\d{1,2})\/([a-z]{3,9})\/(\d{4})$/);
|
|
1965
|
+
if (dmy && MONTH_WORDS[dmy[2]] !== undefined) {
|
|
1966
|
+
return {
|
|
1967
|
+
expr: { op: 'literal', date: { year: Number(dmy[3]), month: MONTH_WORDS[dmy[2]], day: Number(dmy[1]) } },
|
|
1968
|
+
consumed: 1,
|
|
1969
|
+
confidence: 0.95,
|
|
1970
|
+
role: 'date',
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
// "oct/2", "mar4", "mar.4"
|
|
1974
|
+
// "Dec/2018" → month+year
|
|
1975
|
+
const attachedYear = w?.match(/^([a-z]{3,9})\/(\d{4})$/);
|
|
1976
|
+
if (attachedYear && MONTH_WORDS[attachedYear[1]] !== undefined) {
|
|
1977
|
+
return {
|
|
1978
|
+
expr: { op: 'literal', date: { month: MONTH_WORDS[attachedYear[1]], year: Number(attachedYear[2]) } },
|
|
1979
|
+
consumed: 1,
|
|
1980
|
+
confidence: 0.9,
|
|
1981
|
+
role: 'date',
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
const attached = w?.match(/^([a-z]{3,9})[/.]?(\d{1,2})$/);
|
|
1985
|
+
if (attached && MONTH_WORDS[attached[1]] !== undefined) {
|
|
1986
|
+
const day = Number(attached[2]);
|
|
1987
|
+
if (day >= 1 && day <= 31) {
|
|
1988
|
+
const year = yearAt(tokens, i + 1);
|
|
1989
|
+
const date = {
|
|
1990
|
+
month: MONTH_WORDS[attached[1]],
|
|
1991
|
+
day,
|
|
1992
|
+
};
|
|
1993
|
+
if (year !== undefined)
|
|
1994
|
+
date.year = year;
|
|
1995
|
+
return {
|
|
1996
|
+
expr: { op: 'literal', date },
|
|
1997
|
+
consumed: year !== undefined ? 2 : 1,
|
|
1998
|
+
confidence: 0.9,
|
|
1999
|
+
role: 'date',
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const month = w !== undefined ? MONTH_WORDS[w] : undefined;
|
|
2004
|
+
if (month !== undefined) {
|
|
2005
|
+
// "November 18-19": day range within the month.
|
|
2006
|
+
const dayPair = word(tokens[i + 1])?.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
2007
|
+
if (dayPair && Number(dayPair[1]) <= 31 && Number(dayPair[2]) <= 31 && Number(dayPair[1]) < Number(dayPair[2])) {
|
|
2008
|
+
return {
|
|
2009
|
+
expr: {
|
|
2010
|
+
op: 'between',
|
|
2011
|
+
start: { op: 'literal', date: { month, day: Number(dayPair[1]) } },
|
|
2012
|
+
end: { op: 'literal', date: { month, day: Number(dayPair[2]) } },
|
|
2013
|
+
},
|
|
2014
|
+
consumed: 2,
|
|
2015
|
+
confidence: 0.95,
|
|
2016
|
+
role: 'date',
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
// "Jun 15" / "Jun-15" (dash-split)
|
|
2020
|
+
const dashSkip = word(tokens[i + 1]) === '-' && tokens[i + 2]?.type === 'number' ? 1 : 0;
|
|
2021
|
+
const dayTok = tokens[i + 1 + dashSkip];
|
|
2022
|
+
let day;
|
|
2023
|
+
let dayConsumed = 0;
|
|
2024
|
+
if (dayTok?.type === 'number' && dayTok.value >= 1 && dayTok.value <= 31) {
|
|
2025
|
+
day = dayTok.value;
|
|
2026
|
+
dayConsumed = 1 + dashSkip;
|
|
2027
|
+
}
|
|
2028
|
+
else {
|
|
2029
|
+
const ord = readOrdinalDayWord(tokens, i + 1);
|
|
2030
|
+
if (ord) {
|
|
2031
|
+
day = ord.value;
|
|
2032
|
+
dayConsumed = ord.consumed;
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
const wn = readNumber(tokens, i + 1);
|
|
2036
|
+
const isWordNum = word(dayTok) !== undefined && !(word(dayTok) in FUZZY);
|
|
2037
|
+
if (wn && isWordNum && Number.isInteger(wn.value) && wn.value >= 1 && wn.value <= 31) {
|
|
2038
|
+
day = wn.value;
|
|
2039
|
+
dayConsumed = wn.consumed;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
if (day !== undefined) {
|
|
2044
|
+
const yearRead = readYear(tokens, i + 1 + dayConsumed);
|
|
2045
|
+
const date = { month, day };
|
|
2046
|
+
if (yearRead)
|
|
2047
|
+
date.year = yearRead.value;
|
|
2048
|
+
return {
|
|
2049
|
+
expr: { op: 'literal', date },
|
|
2050
|
+
consumed: 1 + dayConsumed + (yearRead ? yearRead.consumed : 0),
|
|
2051
|
+
confidence: 1,
|
|
2052
|
+
role: 'date',
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
// "Dec 2018" / "Dec-2018" (dash-split) / "april in 2016"
|
|
2056
|
+
let yAt = i + 1;
|
|
2057
|
+
if (word(tokens[yAt]) === '-' || word(tokens[yAt]) === 'in' || word(tokens[yAt]) === 'of')
|
|
2058
|
+
yAt += 1;
|
|
2059
|
+
const year = yearAt(tokens, yAt);
|
|
2060
|
+
if (year !== undefined) {
|
|
2061
|
+
return { expr: { op: 'literal', date: { month, year } }, consumed: yAt + 1 - i, confidence: 1, role: 'date' };
|
|
2062
|
+
}
|
|
2063
|
+
return { expr: { op: 'literal', date: { month } }, consumed: 1, confidence: 0.85, role: 'date' };
|
|
2064
|
+
}
|
|
2065
|
+
// "29 May [2026]" / "3rd of May" / "05-Aug-2016" (dash-split tokens)
|
|
2066
|
+
const n = tokens[i];
|
|
2067
|
+
if (n?.type === 'number' && n.value >= 1 && n.value <= 31) {
|
|
2068
|
+
let at = i + 1;
|
|
2069
|
+
if (word(tokens[at]) === 'of' || word(tokens[at]) === '-')
|
|
2070
|
+
at += 1;
|
|
2071
|
+
const m2 = MONTH_WORDS[word(tokens[at]) ?? ''];
|
|
2072
|
+
if (m2 !== undefined) {
|
|
2073
|
+
let yAt = at + 1;
|
|
2074
|
+
if (word(tokens[yAt]) === '-' || word(tokens[yAt]) === 'in' || word(tokens[yAt]) === 'of')
|
|
2075
|
+
yAt += 1;
|
|
2076
|
+
const year = yearAt(tokens, yAt);
|
|
2077
|
+
const date = { month: m2, day: n.value };
|
|
2078
|
+
if (year !== undefined)
|
|
2079
|
+
date.year = year;
|
|
2080
|
+
return {
|
|
2081
|
+
expr: { op: 'literal', date },
|
|
2082
|
+
consumed: (year !== undefined ? yAt + 1 : at + 1) - i,
|
|
2083
|
+
confidence: 1,
|
|
2084
|
+
role: 'date',
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
if (n.ordinal) {
|
|
2088
|
+
return { expr: { op: 'literal', date: { day: n.value } }, consumed: 1, confidence: 0.8, role: 'date' };
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
const nd = tokens[i];
|
|
2092
|
+
if (nd?.type === 'numdate') {
|
|
2093
|
+
const date = numericDate(nd.parts, ctx.dateOrder);
|
|
2094
|
+
if (date) {
|
|
2095
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.9, role: 'date' };
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return undefined;
|
|
2099
|
+
};
|
|
2100
|
+
function numericDate(parts, order) {
|
|
2101
|
+
const [a, b, c] = [parts[0], parts[1], parts[2]];
|
|
2102
|
+
if (a >= 1000) {
|
|
2103
|
+
if (b === undefined || c === undefined)
|
|
2104
|
+
return undefined;
|
|
2105
|
+
return valid({ year: a, month: b, day: c });
|
|
2106
|
+
}
|
|
2107
|
+
const withYear = (m, d) => {
|
|
2108
|
+
if (c === undefined)
|
|
2109
|
+
return valid({ month: m, day: d });
|
|
2110
|
+
const year = c < 100 ? 2000 + c : c;
|
|
2111
|
+
return valid({ year, month: m, day: d });
|
|
2112
|
+
};
|
|
2113
|
+
if (b === undefined)
|
|
2114
|
+
return undefined;
|
|
2115
|
+
// Primary order first; an impossible month falls back to the swapped
|
|
2116
|
+
// reading ("22/04" is April 22 even under MDY).
|
|
2117
|
+
const primary = order === 'DMY' ? withYear(b, a) : withYear(a, b);
|
|
2118
|
+
if (primary)
|
|
2119
|
+
return primary;
|
|
2120
|
+
return order === 'DMY' ? withYear(a, b) : withYear(b, a);
|
|
2121
|
+
}
|
|
2122
|
+
function valid(d) {
|
|
2123
|
+
if (d.month < 1 || d.month > 12 || d.day < 1 || d.day > 31)
|
|
2124
|
+
return undefined;
|
|
2125
|
+
return d;
|
|
2126
|
+
}
|
|
2127
|
+
/** Clock times, incl. "half past seven", "20 min past eight", "10 to eight", period suffixes. */
|
|
2128
|
+
const ruleClockTime = (tokens, i, ctx) => {
|
|
2129
|
+
void ctx;
|
|
2130
|
+
const t = tokens[i];
|
|
2131
|
+
const withPeriodSuffix = (time, consumed) => {
|
|
2132
|
+
const ps = readPeriodSuffix(tokens, i + consumed);
|
|
2133
|
+
if (ps && (time.meridiem === 'unknown' || time.meridiem === undefined)) {
|
|
2134
|
+
time.meridiem = meridiemFor(ps.period);
|
|
2135
|
+
consumed += ps.consumed;
|
|
2136
|
+
}
|
|
2137
|
+
return { expr: { op: 'literal', time }, consumed, confidence: 1, role: 'time' };
|
|
2138
|
+
};
|
|
2139
|
+
if (t?.type === 'clock') {
|
|
2140
|
+
const after = word(tokens[i + 1]);
|
|
2141
|
+
const meridiem = t.meridiem ?? (after === 'am' || after === 'pm' ? after : undefined);
|
|
2142
|
+
let consumed = !t.meridiem && (after === 'am' || after === 'pm') ? 2 : 1;
|
|
2143
|
+
const time = { hour: t.hour };
|
|
2144
|
+
if (t.explicitMinute)
|
|
2145
|
+
time.minute = t.minute;
|
|
2146
|
+
if (t.second !== undefined)
|
|
2147
|
+
time.second = t.second;
|
|
2148
|
+
if (meridiem)
|
|
2149
|
+
time.meridiem = meridiem;
|
|
2150
|
+
else if (t.hour <= 12)
|
|
2151
|
+
time.meridiem = 'unknown';
|
|
2152
|
+
return withPeriodSuffix(time, consumed);
|
|
2153
|
+
}
|
|
2154
|
+
const w = word(t);
|
|
2155
|
+
if (w === 'noon' || w === 'midday' || w === 'noonish') {
|
|
2156
|
+
return { expr: { op: 'literal', time: { hour: 12, meridiem: 'pm' } }, consumed: 1, confidence: 1, role: 'time' };
|
|
2157
|
+
}
|
|
2158
|
+
if (w === 'midnight') {
|
|
2159
|
+
return { expr: { op: 'literal', time: { hour: 12, meridiem: 'am' } }, consumed: 1, confidence: 1, role: 'time' };
|
|
2160
|
+
}
|
|
2161
|
+
// "11.00am" — dotted time with explicit meridiem.
|
|
2162
|
+
const dottedMer = w?.match(/^(\d{1,2})\.(\d{2})(am|pm)$/);
|
|
2163
|
+
if (dottedMer) {
|
|
2164
|
+
const h = Number(dottedMer[1]);
|
|
2165
|
+
const min = Number(dottedMer[2]);
|
|
2166
|
+
if (h >= 1 && h <= 12 && min <= 59) {
|
|
2167
|
+
return {
|
|
2168
|
+
expr: { op: 'literal', time: { hour: h, minute: min, meridiem: dottedMer[3] } },
|
|
2169
|
+
consumed: 1,
|
|
2170
|
+
confidence: 1,
|
|
2171
|
+
role: 'time',
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
// "at 6.45" / "8.10 pm" — dot as minute separator.
|
|
2176
|
+
const dottedTime = w?.match(/^(\d{1,2})\.(\d{2})$/);
|
|
2177
|
+
if (dottedTime) {
|
|
2178
|
+
const after = word(tokens[i + 1]);
|
|
2179
|
+
const prevOk = ['at', 'around', 'about'].includes(word(tokens[i - 1]) ?? '');
|
|
2180
|
+
const merAfter = after === 'am' || after === 'pm' ? after : undefined;
|
|
2181
|
+
if (prevOk || merAfter) {
|
|
2182
|
+
const h = Number(dottedTime[1]);
|
|
2183
|
+
const min = Number(dottedTime[2]);
|
|
2184
|
+
if (h >= 1 && h <= 23 && min <= 59) {
|
|
2185
|
+
const time = { hour: h, minute: min };
|
|
2186
|
+
if (merAfter)
|
|
2187
|
+
time.meridiem = merAfter;
|
|
2188
|
+
else if (h <= 12)
|
|
2189
|
+
time.meridiem = 'unknown';
|
|
2190
|
+
return { expr: { op: 'literal', time }, consumed: merAfter ? 2 : 1, confidence: 0.9, role: 'time' };
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
// "11ish", "7pmish"
|
|
2195
|
+
const ish = w?.match(/^(\d{1,2})(am|pm)?ish$/);
|
|
2196
|
+
if (ish) {
|
|
2197
|
+
const h = Number(ish[1]);
|
|
2198
|
+
if (h >= 1 && h <= 12) {
|
|
2199
|
+
return {
|
|
2200
|
+
expr: {
|
|
2201
|
+
op: 'literal',
|
|
2202
|
+
time: { hour: h, meridiem: ish[2] ?? 'unknown' },
|
|
2203
|
+
mod: 'approx',
|
|
2204
|
+
},
|
|
2205
|
+
consumed: 1,
|
|
2206
|
+
confidence: 0.9,
|
|
2207
|
+
role: 'time',
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
// "half past seven", "quarter past/to eight", "20 min(utes) past eight"
|
|
2212
|
+
let minutes;
|
|
2213
|
+
let mConsumed = 0;
|
|
2214
|
+
if (w === 'half') {
|
|
2215
|
+
minutes = 30;
|
|
2216
|
+
mConsumed = 1;
|
|
2217
|
+
}
|
|
2218
|
+
else if (w === 'quarter') {
|
|
2219
|
+
minutes = 15;
|
|
2220
|
+
mConsumed = 1;
|
|
2221
|
+
}
|
|
2222
|
+
else {
|
|
2223
|
+
const mn = readNumber(tokens, i);
|
|
2224
|
+
if (mn && Number.isInteger(mn.value) && mn.value >= 1 && mn.value <= 59) {
|
|
2225
|
+
const uw = word(tokens[i + mn.consumed]);
|
|
2226
|
+
if (uw === 'min' || uw === 'mins' || uw === 'minute' || uw === 'minutes') {
|
|
2227
|
+
minutes = mn.value;
|
|
2228
|
+
mConsumed = mn.consumed + 1;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
if (minutes !== undefined) {
|
|
2233
|
+
const dirWord = word(tokens[i + mConsumed]);
|
|
2234
|
+
if (dirWord === 'past' || dirWord === 'after' || dirWord === 'to' || dirWord === 'till') {
|
|
2235
|
+
const hn = readNumber(tokens, i + mConsumed + 1);
|
|
2236
|
+
if (hn && Number.isInteger(hn.value) && hn.value >= 1 && hn.value <= 12) {
|
|
2237
|
+
const toMode = dirWord === 'to' || dirWord === 'till';
|
|
2238
|
+
const hour = toMode ? (hn.value === 1 ? 12 : hn.value - 1) : hn.value;
|
|
2239
|
+
const time = {
|
|
2240
|
+
hour,
|
|
2241
|
+
minute: toMode ? 60 - minutes : minutes,
|
|
2242
|
+
meridiem: 'unknown',
|
|
2243
|
+
};
|
|
2244
|
+
let consumed = mConsumed + 1 + hn.consumed;
|
|
2245
|
+
const oc = word(tokens[i + consumed]);
|
|
2246
|
+
if (oc === "o'clock" || oc === 'oclock')
|
|
2247
|
+
consumed += 1;
|
|
2248
|
+
return withPeriodSuffix(time, consumed);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// Military/compact: "1140 am" → 11:40.
|
|
2253
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 100 && t.value <= 1259 && t.value % 100 < 60) {
|
|
2254
|
+
const after = word(tokens[i + 1]);
|
|
2255
|
+
if (after === 'am' || after === 'pm') {
|
|
2256
|
+
return {
|
|
2257
|
+
expr: {
|
|
2258
|
+
op: 'literal',
|
|
2259
|
+
time: { hour: Math.floor(t.value / 100), minute: t.value % 100, meridiem: after },
|
|
2260
|
+
},
|
|
2261
|
+
consumed: 2,
|
|
2262
|
+
confidence: 1,
|
|
2263
|
+
role: 'time',
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
// "eoy": the later half of the year (a range, unlike the point-like eod).
|
|
2268
|
+
if (w === 'eoy') {
|
|
2269
|
+
return {
|
|
2270
|
+
expr: {
|
|
2271
|
+
op: 'between',
|
|
2272
|
+
start: { op: 'offset', base: { op: 'snap', base: NOW, unit: 'year', edge: 'start' }, amount: 6, unit: 'month' },
|
|
2273
|
+
end: { op: 'snap', base: NOW, unit: 'year', edge: 'end' },
|
|
2274
|
+
},
|
|
2275
|
+
consumed: 1,
|
|
2276
|
+
confidence: 0.9,
|
|
2277
|
+
role: 'date',
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
// "eod"/"eow"/"eom": end-of-period points.
|
|
2281
|
+
if (w === 'eod' || w === 'eow' || w === 'eom') {
|
|
2282
|
+
const unit = w === 'eod' ? 'day' : w === 'eow' ? 'week' : 'month';
|
|
2283
|
+
return {
|
|
2284
|
+
expr: { op: 'snap', base: NOW, unit, edge: 'end' },
|
|
2285
|
+
consumed: 1,
|
|
2286
|
+
confidence: 0.9,
|
|
2287
|
+
role: w === 'eod' ? 'time' : 'date',
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
if (w === 'daytime') {
|
|
2291
|
+
return {
|
|
2292
|
+
expr: {
|
|
2293
|
+
op: 'between',
|
|
2294
|
+
start: { op: 'literal', time: { hour: 8, meridiem: 'am' } },
|
|
2295
|
+
end: { op: 'literal', time: { hour: 6, meridiem: 'pm' } },
|
|
2296
|
+
},
|
|
2297
|
+
consumed: 1,
|
|
2298
|
+
confidence: 0.9,
|
|
2299
|
+
role: 'time',
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
if (w === 'dinner' || w === 'dinnertime') {
|
|
2303
|
+
const prevOk = ['around', 'at', 'about', 'before', 'after'].includes(word(tokens[i - 1]) ?? '');
|
|
2304
|
+
if (prevOk) {
|
|
2305
|
+
return { expr: { op: 'literal', dayPeriod: 'evening' }, consumed: 1, confidence: 0.85, role: 'time' };
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
if (w === 'lunchtime') {
|
|
2309
|
+
return {
|
|
2310
|
+
expr: {
|
|
2311
|
+
op: 'between',
|
|
2312
|
+
start: { op: 'literal', time: { hour: 11, meridiem: 'am' } },
|
|
2313
|
+
end: { op: 'literal', time: { hour: 1, meridiem: 'pm' } },
|
|
2314
|
+
},
|
|
2315
|
+
consumed: 1,
|
|
2316
|
+
confidence: 0.9,
|
|
2317
|
+
role: 'time',
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
// Word-number or digit hours: "eight in the evening", "4 pm", "at 4", "10 tonight"
|
|
2321
|
+
const hn = t?.type === 'number' && !t.ordinal ? { value: t.value, consumed: 1 } :
|
|
2322
|
+
w !== undefined && w in ONES ? { value: ONES[w], consumed: 1 } : undefined;
|
|
2323
|
+
if (hn && hn.value >= 1 && hn.value <= 12) {
|
|
2324
|
+
// Word minutes: "one thirty [p m]" → 1:30.
|
|
2325
|
+
let minute;
|
|
2326
|
+
let mc = 0;
|
|
2327
|
+
const minWord = word(tokens[i + hn.consumed]);
|
|
2328
|
+
if (minWord !== undefined && (minWord in TENS || (minWord in ONES && ONES[minWord] >= 10))) {
|
|
2329
|
+
const mn = readNumber(tokens, i + hn.consumed);
|
|
2330
|
+
if (mn && mn.value >= 10 && mn.value <= 59) {
|
|
2331
|
+
minute = mn.value;
|
|
2332
|
+
mc = mn.consumed;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
let after = word(tokens[i + hn.consumed + mc]);
|
|
2336
|
+
let afterLen = 1;
|
|
2337
|
+
// "p m" / "a m" as two tokens.
|
|
2338
|
+
if ((after === 'p' || after === 'a') && word(tokens[i + hn.consumed + mc + 1]) === 'm') {
|
|
2339
|
+
after = after === 'p' ? 'pm' : 'am';
|
|
2340
|
+
afterLen = 2;
|
|
2341
|
+
}
|
|
2342
|
+
if (after === 'am' || after === 'pm') {
|
|
2343
|
+
const time = { hour: hn.value, meridiem: after };
|
|
2344
|
+
if (minute !== undefined)
|
|
2345
|
+
time.minute = minute;
|
|
2346
|
+
return {
|
|
2347
|
+
expr: { op: 'literal', time },
|
|
2348
|
+
consumed: hn.consumed + mc + afterLen,
|
|
2349
|
+
confidence: 1,
|
|
2350
|
+
role: 'time',
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
if (after === "o'clock" || after === 'oclock') {
|
|
2354
|
+
return withPeriodSuffix({ hour: hn.value, meridiem: 'unknown' }, hn.consumed + 1);
|
|
2355
|
+
}
|
|
2356
|
+
const ps = readPeriodSuffix(tokens, i + hn.consumed + mc);
|
|
2357
|
+
if (ps) {
|
|
2358
|
+
const time = { hour: hn.value, meridiem: meridiemFor(ps.period) };
|
|
2359
|
+
if (minute !== undefined)
|
|
2360
|
+
time.minute = minute;
|
|
2361
|
+
return {
|
|
2362
|
+
expr: { op: 'literal', time },
|
|
2363
|
+
consumed: hn.consumed + mc + ps.consumed,
|
|
2364
|
+
confidence: 0.95,
|
|
2365
|
+
role: 'time',
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
// An explicit compound like "three thirty" is a confident bare time.
|
|
2369
|
+
if (minute !== undefined) {
|
|
2370
|
+
return {
|
|
2371
|
+
expr: { op: 'literal', time: { hour: hn.value, minute, meridiem: 'unknown' } },
|
|
2372
|
+
consumed: hn.consumed + mc,
|
|
2373
|
+
confidence: 0.95,
|
|
2374
|
+
role: 'time',
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
// "10 tonight" / "10, tonight": keep the time; the deictic day merges next.
|
|
2378
|
+
if (word(tokens[i + hn.consumed]) === 'tonight') {
|
|
2379
|
+
return {
|
|
2380
|
+
expr: { op: 'literal', time: { hour: hn.value, meridiem: 'pm' } },
|
|
2381
|
+
consumed: hn.consumed,
|
|
2382
|
+
confidence: 0.95,
|
|
2383
|
+
role: 'time',
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
if (['at', 'from', 'around', 'about'].includes(word(tokens[i - 1]) ?? '')) {
|
|
2387
|
+
return {
|
|
2388
|
+
expr: { op: 'literal', time: { hour: hn.value, meridiem: 'unknown' } },
|
|
2389
|
+
consumed: hn.consumed,
|
|
2390
|
+
confidence: 0.9,
|
|
2391
|
+
role: 'time',
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
return undefined;
|
|
2396
|
+
};
|
|
2397
|
+
/** "in the morning" (anchors to context day; merges with adjacent dates). */
|
|
2398
|
+
const rulePeriodAlone = (tokens, i) => {
|
|
2399
|
+
const prev = word(tokens[i - 1]);
|
|
2400
|
+
if (prev === 'last' || prev === 'this' || prev === 'next')
|
|
2401
|
+
return undefined;
|
|
2402
|
+
const w = word(tokens[i]);
|
|
2403
|
+
const period = w !== undefined ? PERIOD_WORDS[w] : undefined;
|
|
2404
|
+
if (!period)
|
|
2405
|
+
return undefined;
|
|
2406
|
+
// "in the morning" / "at night" / "one morning" are confident; a bare
|
|
2407
|
+
// period word is not.
|
|
2408
|
+
const strong = (prev === 'the' && word(tokens[i - 2]) === 'in') ||
|
|
2409
|
+
prev === 'at' ||
|
|
2410
|
+
prev === 'one' || prev === 'a' ||
|
|
2411
|
+
tokens[i - 1]?.type === 'number';
|
|
2412
|
+
return {
|
|
2413
|
+
expr: { op: 'literal', dayPeriod: period },
|
|
2414
|
+
consumed: 1,
|
|
2415
|
+
confidence: strong ? 0.9 : 0.4,
|
|
2416
|
+
role: 'time',
|
|
2417
|
+
};
|
|
2418
|
+
};
|
|
2419
|
+
const DURATION_TRIGGERS = ['for', 'lasts', 'last', 'lasting', 'takes', 'take', 'than', 'over'];
|
|
2420
|
+
/** Trigger-less fractional durations: "one and a half hour(s)", "one hour and half", "½ hour". */
|
|
2421
|
+
const ruleFractionDuration = (tokens, i) => {
|
|
2422
|
+
const n = readNumber(tokens, i);
|
|
2423
|
+
if (!n)
|
|
2424
|
+
return undefined;
|
|
2425
|
+
let at = i + n.consumed;
|
|
2426
|
+
let value = n.value;
|
|
2427
|
+
if (!Number.isInteger(value)) {
|
|
2428
|
+
const fUnit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
2429
|
+
if (fUnit) {
|
|
2430
|
+
const secs = round2(value * unitSeconds(fUnit));
|
|
2431
|
+
return { expr: { op: 'duration', iso: `PT${secs}S` }, consumed: at + 1 - i, confidence: 0.95, role: 'duration' };
|
|
2432
|
+
}
|
|
2433
|
+
return undefined;
|
|
2434
|
+
}
|
|
2435
|
+
// "one and (a) half hour"
|
|
2436
|
+
if (word(tokens[at]) === 'and') {
|
|
2437
|
+
const skipA = word(tokens[at + 1]) === 'a' ? 1 : 0;
|
|
2438
|
+
const frac = word(tokens[at + 1 + skipA]);
|
|
2439
|
+
if (frac === 'half') {
|
|
2440
|
+
value += 0.5;
|
|
2441
|
+
at += 2 + skipA;
|
|
2442
|
+
}
|
|
2443
|
+
else if (frac === 'quarter') {
|
|
2444
|
+
value += 0.25;
|
|
2445
|
+
at += 2 + skipA;
|
|
2446
|
+
}
|
|
2447
|
+
else
|
|
2448
|
+
return undefined;
|
|
2449
|
+
const unit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
2450
|
+
if (!unit)
|
|
2451
|
+
return undefined;
|
|
2452
|
+
const secs = round2(value * unitSeconds(unit));
|
|
2453
|
+
return { expr: { op: 'duration', iso: `PT${secs}S` }, consumed: at + 1 - i, confidence: 0.95, role: 'duration' };
|
|
2454
|
+
}
|
|
2455
|
+
// "one hour and (a) half"
|
|
2456
|
+
const unit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
2457
|
+
if (!unit || word(tokens[at + 1]) !== 'and')
|
|
2458
|
+
return undefined;
|
|
2459
|
+
const skipA = word(tokens[at + 2]) === 'a' ? 1 : 0;
|
|
2460
|
+
const frac = word(tokens[at + 2 + skipA]);
|
|
2461
|
+
if (frac === 'half')
|
|
2462
|
+
value += 0.5;
|
|
2463
|
+
else if (frac === 'quarter')
|
|
2464
|
+
value += 0.25;
|
|
2465
|
+
else
|
|
2466
|
+
return undefined;
|
|
2467
|
+
const secs = round2(value * unitSeconds(unit));
|
|
2468
|
+
return {
|
|
2469
|
+
expr: { op: 'duration', iso: `PT${secs}S` },
|
|
2470
|
+
consumed: at + 3 + skipA - i,
|
|
2471
|
+
confidence: 0.95,
|
|
2472
|
+
role: 'duration',
|
|
2473
|
+
};
|
|
2474
|
+
};
|
|
2475
|
+
const COMPACT_DUR = {
|
|
2476
|
+
s: 'second', m: 'minute', h: 'hour', d: 'day', w: 'week',
|
|
2477
|
+
};
|
|
2478
|
+
/** "weekend(s)" as a duration = 2 days. */
|
|
2479
|
+
const DURATION_EXTRA_DAYS = { weekend: 2, weekends: 2 };
|
|
2480
|
+
/** Durations: "for 90 minutes / 2.5 hrs / 3 hours 30 minutes / all day / two nights / 3h", "lasts three weekends", "a one and a quarter year gap". */
|
|
2481
|
+
const ruleDuration = (tokens, i) => {
|
|
2482
|
+
if (!DURATION_TRIGGERS.includes(word(tokens[i]) ?? ''))
|
|
2483
|
+
return undefined;
|
|
2484
|
+
let at = i + 1;
|
|
2485
|
+
if (word(tokens[at]) === 'a' && word(tokens[at + 1]) !== undefined && readNumber(tokens, at + 1))
|
|
2486
|
+
at += 1;
|
|
2487
|
+
if (word(tokens[at]) === 'all' || (word(tokens[at]) === 'the' && word(tokens[at + 1]) === 'whole')) {
|
|
2488
|
+
const skip = word(tokens[at]) === 'all' ? 1 : 2;
|
|
2489
|
+
const unit = UNIT_WORDS[word(tokens[at + skip]) ?? ''];
|
|
2490
|
+
if (unit) {
|
|
2491
|
+
return {
|
|
2492
|
+
expr: { op: 'amount', amount: amountFor(unit, 1) },
|
|
2493
|
+
consumed: at + skip + 1 - i,
|
|
2494
|
+
confidence: 1,
|
|
2495
|
+
role: 'duration',
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
return undefined;
|
|
2499
|
+
}
|
|
2500
|
+
if (word(tokens[at]) === 'the') {
|
|
2501
|
+
const theUnit = UNIT_WORDS[word(tokens[at + 1]) ?? ''];
|
|
2502
|
+
if (theUnit === 'hour' || theUnit === 'minute' || theUnit === 'second') {
|
|
2503
|
+
return {
|
|
2504
|
+
expr: { op: 'amount', amount: amountFor(theUnit, 1) },
|
|
2505
|
+
consumed: at + 2 - i,
|
|
2506
|
+
confidence: 0.95,
|
|
2507
|
+
role: 'duration',
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (word(tokens[at]) === 'another') {
|
|
2512
|
+
const unit = UNIT_WORDS[word(tokens[at + 1]) ?? ''];
|
|
2513
|
+
if (unit) {
|
|
2514
|
+
return {
|
|
2515
|
+
expr: { op: 'amount', amount: amountFor(unit, 1) },
|
|
2516
|
+
consumed: at + 2 - i,
|
|
2517
|
+
confidence: 1,
|
|
2518
|
+
role: 'duration',
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
return undefined;
|
|
2522
|
+
}
|
|
2523
|
+
// Compact: "3h", "45min" as a single token.
|
|
2524
|
+
const compact = word(tokens[at])?.match(/^(\d+(?:\.\d+)?)(years?|yrs?|months?|weeks?|wks?|days?|hours?|hrs?|minutes?|mins?|seconds?|secs?|s|m|h|d|w)$/);
|
|
2525
|
+
if (compact) {
|
|
2526
|
+
const cUnit = COMPACT_DUR[compact[2]] ?? UNIT_WORDS[compact[2]];
|
|
2527
|
+
if (!cUnit)
|
|
2528
|
+
return undefined;
|
|
2529
|
+
const secs = round2(Number(compact[1]) * unitSeconds(cUnit));
|
|
2530
|
+
return { expr: { op: 'duration', iso: `PT${secs}S` }, consumed: at + 1 - i, confidence: 0.95, role: 'duration' };
|
|
2531
|
+
}
|
|
2532
|
+
const amount = {};
|
|
2533
|
+
let fractionalSeconds = 0;
|
|
2534
|
+
let any = false;
|
|
2535
|
+
while (true) {
|
|
2536
|
+
// Compound: "3 hours and 30 minutes".
|
|
2537
|
+
if (any && word(tokens[at]) === 'and')
|
|
2538
|
+
at += 1;
|
|
2539
|
+
const n = readNumber(tokens, at);
|
|
2540
|
+
if (!n)
|
|
2541
|
+
break;
|
|
2542
|
+
let value = n.value;
|
|
2543
|
+
let uAt = at + n.consumed;
|
|
2544
|
+
// "one and a quarter year"
|
|
2545
|
+
if (word(tokens[uAt]) === 'and') {
|
|
2546
|
+
const skipA = word(tokens[uAt + 1]) === 'a' ? 1 : 0;
|
|
2547
|
+
const frac = word(tokens[uAt + 1 + skipA]);
|
|
2548
|
+
if (frac === 'half') {
|
|
2549
|
+
value += 0.5;
|
|
2550
|
+
uAt += 2 + skipA;
|
|
2551
|
+
}
|
|
2552
|
+
else if (frac === 'quarter') {
|
|
2553
|
+
value += 0.25;
|
|
2554
|
+
uAt += 2 + skipA;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
const uw = word(tokens[uAt]) ?? '';
|
|
2558
|
+
const extraDays = DURATION_EXTRA_DAYS[uw];
|
|
2559
|
+
const unit = extraDays !== undefined ? 'day' : UNIT_WORDS[uw];
|
|
2560
|
+
if (!unit)
|
|
2561
|
+
break;
|
|
2562
|
+
if (extraDays !== undefined)
|
|
2563
|
+
value *= extraDays;
|
|
2564
|
+
let consumedUnit = uAt + 1;
|
|
2565
|
+
// "one year and a quarter"
|
|
2566
|
+
if (word(tokens[consumedUnit]) === 'and') {
|
|
2567
|
+
const skipA = word(tokens[consumedUnit + 1]) === 'a' ? 1 : 0;
|
|
2568
|
+
const frac = word(tokens[consumedUnit + 1 + skipA]);
|
|
2569
|
+
if (frac === 'half') {
|
|
2570
|
+
value += 0.5;
|
|
2571
|
+
consumedUnit += 2 + skipA;
|
|
2572
|
+
}
|
|
2573
|
+
else if (frac === 'quarter') {
|
|
2574
|
+
value += 0.25;
|
|
2575
|
+
consumedUnit += 2 + skipA;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (Number.isInteger(value)) {
|
|
2579
|
+
const f = unitField(unit);
|
|
2580
|
+
amount[f] = (amount[f] ?? 0) + (unit === 'quarter' ? 3 * value : value);
|
|
2581
|
+
}
|
|
2582
|
+
else {
|
|
2583
|
+
fractionalSeconds += round2(value * unitSeconds(unit));
|
|
2584
|
+
}
|
|
2585
|
+
any = true;
|
|
2586
|
+
at = consumedUnit;
|
|
2587
|
+
}
|
|
2588
|
+
if (!any)
|
|
2589
|
+
return undefined;
|
|
2590
|
+
if (fractionalSeconds > 0) {
|
|
2591
|
+
for (const [k, v] of Object.entries(amount))
|
|
2592
|
+
fractionalSeconds += v * unitSeconds(k.slice(0, -1));
|
|
2593
|
+
return {
|
|
2594
|
+
expr: { op: 'duration', iso: `PT${round2(fractionalSeconds)}S` },
|
|
2595
|
+
consumed: at - i,
|
|
2596
|
+
confidence: 1,
|
|
2597
|
+
role: 'duration',
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
return {
|
|
2601
|
+
expr: { op: 'amount', amount },
|
|
2602
|
+
consumed: at - i,
|
|
2603
|
+
confidence: 1,
|
|
2604
|
+
role: 'duration',
|
|
2605
|
+
};
|
|
2606
|
+
};
|
|
2607
|
+
function round2(x) {
|
|
2608
|
+
return Math.round(x * 100) / 100;
|
|
2609
|
+
}
|
|
2610
|
+
function unitSeconds(unit) {
|
|
2611
|
+
switch (unit) {
|
|
2612
|
+
case 'second': return 1;
|
|
2613
|
+
case 'minute': return 60;
|
|
2614
|
+
case 'hour': return 3600;
|
|
2615
|
+
case 'day': return 86400;
|
|
2616
|
+
case 'week': return 604800;
|
|
2617
|
+
case 'month': return 2592000;
|
|
2618
|
+
case 'quarter': return 7776000;
|
|
2619
|
+
case 'year': return 31536000;
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
/** "within 5 minutes", "within the next day and 5 hours" → forward span. */
|
|
2623
|
+
const ruleWithin = (tokens, i) => {
|
|
2624
|
+
if (word(tokens[i]) !== 'within')
|
|
2625
|
+
return undefined;
|
|
2626
|
+
let at = i + 1;
|
|
2627
|
+
if (word(tokens[at]) === 'the')
|
|
2628
|
+
at += 1;
|
|
2629
|
+
if (word(tokens[at]) === 'next')
|
|
2630
|
+
at += 1;
|
|
2631
|
+
const compactW = word(tokens[at])?.match(/^(\d+)(s|m|h|d|w)$/);
|
|
2632
|
+
if (compactW) {
|
|
2633
|
+
const cUnit = COMPACT_DUR[compactW[2]];
|
|
2634
|
+
return {
|
|
2635
|
+
expr: { op: 'span', anchor: NOW, amount: amountFor(cUnit, Number(compactW[1])) },
|
|
2636
|
+
consumed: at + 1 - i,
|
|
2637
|
+
confidence: 0.95,
|
|
2638
|
+
role: 'datetime',
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
const amount = {};
|
|
2642
|
+
let any = false;
|
|
2643
|
+
while (true) {
|
|
2644
|
+
if (any && word(tokens[at]) === 'and')
|
|
2645
|
+
at += 1;
|
|
2646
|
+
const n = readNumber(tokens, at);
|
|
2647
|
+
const nVal = n && Number.isInteger(n.value) ? n.value : 1;
|
|
2648
|
+
const uAt = at + (n ? n.consumed : 0);
|
|
2649
|
+
const unit = UNIT_WORDS[word(tokens[uAt]) ?? ''];
|
|
2650
|
+
if (!unit || (!n && any))
|
|
2651
|
+
break;
|
|
2652
|
+
if (!n && !any && word(tokens[uAt]) === undefined)
|
|
2653
|
+
break;
|
|
2654
|
+
const f = unitField(unit);
|
|
2655
|
+
amount[f] = (amount[f] ?? 0) + (unit === 'quarter' ? 3 * nVal : nVal);
|
|
2656
|
+
any = true;
|
|
2657
|
+
at = uAt + 1;
|
|
2658
|
+
}
|
|
2659
|
+
if (!any)
|
|
2660
|
+
return undefined;
|
|
2661
|
+
return {
|
|
2662
|
+
expr: { op: 'span', anchor: NOW, amount },
|
|
2663
|
+
consumed: at - i,
|
|
2664
|
+
confidence: 0.95,
|
|
2665
|
+
role: 'datetime',
|
|
2666
|
+
};
|
|
2667
|
+
};
|
|
2668
|
+
/** "all week"/"all day" without a trigger word → a one-unit duration. */
|
|
2669
|
+
const ruleAllUnit = (tokens, i) => {
|
|
2670
|
+
if (word(tokens[i]) !== 'all')
|
|
2671
|
+
return undefined;
|
|
2672
|
+
const unit = UNIT_WORDS[word(tokens[i + 1]) ?? ''];
|
|
2673
|
+
if (!unit || word(tokens[i + 1]) === 'night')
|
|
2674
|
+
return undefined;
|
|
2675
|
+
return { expr: { op: 'amount', amount: amountFor(unit, 1) }, consumed: 2, confidence: 0.9, role: 'duration' };
|
|
2676
|
+
};
|
|
2677
|
+
/** Rules in priority order (first match at a position wins ties by length then order). */
|
|
2678
|
+
export const EN_RULES = [
|
|
2679
|
+
ruleRange,
|
|
2680
|
+
ruleWeekOf,
|
|
2681
|
+
ruleNthWeekdayOf,
|
|
2682
|
+
ruleDayOfScope,
|
|
2683
|
+
ruleWeekendOf,
|
|
2684
|
+
ruleHoliday,
|
|
2685
|
+
ruleEarlyLate,
|
|
2686
|
+
ruleEndOf,
|
|
2687
|
+
ruleDeicticDay,
|
|
2688
|
+
ruleWithin,
|
|
2689
|
+
ruleNAfterDate,
|
|
2690
|
+
ruleUnitAfterNext,
|
|
2691
|
+
rulePastN,
|
|
2692
|
+
ruleNextN,
|
|
2693
|
+
ruleTheNPastNext,
|
|
2694
|
+
ruleTheUnit,
|
|
2695
|
+
ruleLastThisNext,
|
|
2696
|
+
ruleAgoIn,
|
|
2697
|
+
ruleDuration,
|
|
2698
|
+
ruleFractionDuration,
|
|
2699
|
+
ruleAllUnit,
|
|
2700
|
+
ruleDecadeCentury,
|
|
2701
|
+
ruleBareYear,
|
|
2702
|
+
ruleCalendarDate,
|
|
2703
|
+
ruleClockTime,
|
|
2704
|
+
ruleWeekdayDay,
|
|
2705
|
+
ruleWeekdayAlone,
|
|
2706
|
+
rulePeriodAlone,
|
|
2707
|
+
ruleNow,
|
|
2708
|
+
];
|