nl2time 0.1.0 → 0.3.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/README.md +43 -2
- package/dist/corpus/runner.d.ts +3 -0
- package/dist/corpus/runner.js +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/packs/index.d.ts +39 -0
- package/dist/packs/index.js +156 -0
- package/dist/parse/de.d.ts +9 -0
- package/dist/parse/de.js +1301 -0
- package/dist/parse/en.d.ts +9 -0
- package/dist/parse/en.js +80 -34
- package/dist/parse/es.d.ts +9 -0
- package/dist/parse/es.js +3286 -0
- package/dist/parse/fr.d.ts +9 -0
- package/dist/parse/fr.js +2358 -0
- package/dist/parse/index.d.ts +16 -1
- package/dist/parse/index.js +42 -11
- package/dist/parse/ja.d.ts +7 -0
- package/dist/parse/ja.js +1207 -0
- package/dist/parse/languages.d.ts +17 -0
- package/dist/parse/languages.js +25 -0
- package/dist/parse/latin.d.ts +97 -0
- package/dist/parse/latin.js +692 -0
- package/dist/parse/tokenizer.js +30 -1
- package/dist/parse/zh.d.ts +6 -0
- package/dist/parse/zh.js +1360 -0
- package/package.json +7 -3
package/dist/parse/es.js
ADDED
|
@@ -0,0 +1,3286 @@
|
|
|
1
|
+
import { makeLatinRules } from './latin.js';
|
|
2
|
+
const NOW = { op: 'now' };
|
|
3
|
+
function snapNow(unit) {
|
|
4
|
+
return { op: 'snap', base: NOW, unit };
|
|
5
|
+
}
|
|
6
|
+
function snapOffset(amount, unit) {
|
|
7
|
+
return { op: 'snap', base: { op: 'offset', base: NOW, amount, unit }, unit };
|
|
8
|
+
}
|
|
9
|
+
function word(t) {
|
|
10
|
+
return t?.type === 'word' ? t.value : undefined;
|
|
11
|
+
}
|
|
12
|
+
const MONTHS = {
|
|
13
|
+
enero: 1, febrero: 2, marzo: 3, abril: 4, mayo: 5, junio: 6,
|
|
14
|
+
julio: 7, agosto: 8, septiembre: 9, setiembre: 9, octubre: 10,
|
|
15
|
+
noviembre: 11, diciembre: 12,
|
|
16
|
+
ene: 1, feb: 2, mar: 3, abr: 4, jun: 6, jul: 7, ago: 8, sep: 9,
|
|
17
|
+
sept: 9, oct: 10, nov: 11, dic: 12,
|
|
18
|
+
};
|
|
19
|
+
const WEEKDAYS = {
|
|
20
|
+
lunes: 'mon', martes: 'tue', miércoles: 'wed', miercoles: 'wed',
|
|
21
|
+
jueves: 'thu', viernes: 'fri', sábado: 'sat', sabado: 'sat', domingo: 'sun',
|
|
22
|
+
lun: 'mon', mar: 'tue', mié: 'wed', mie: 'wed', jue: 'thu', vie: 'fri',
|
|
23
|
+
sáb: 'sat', sab: 'sat', dom: 'sun',
|
|
24
|
+
};
|
|
25
|
+
/** Recognizers-Spanish: mañana 8–12, "pasado mediodía" 12–16, tarde 16–20, noche 20–24. */
|
|
26
|
+
const PERIODS = {
|
|
27
|
+
mañana: 'morning', manana: 'morning',
|
|
28
|
+
tarde: 'evening',
|
|
29
|
+
noche: 'night',
|
|
30
|
+
};
|
|
31
|
+
const ONES = {
|
|
32
|
+
un: 1, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6,
|
|
33
|
+
siete: 7, ocho: 8, nueve: 9,
|
|
34
|
+
};
|
|
35
|
+
const TEENS = {
|
|
36
|
+
diez: 10, once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
|
|
37
|
+
dieciséis: 16, dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,
|
|
38
|
+
veintiuno: 21, veintiuna: 21, veintidós: 22, veintidos: 22, veintitrés: 23,
|
|
39
|
+
veintitres: 23, veinticuatro: 24, veinticinco: 25, veintiséis: 26,
|
|
40
|
+
veintiseis: 26, veintisiete: 27, veintiocho: 28, veintinueve: 29,
|
|
41
|
+
};
|
|
42
|
+
const TENS = {
|
|
43
|
+
veinte: 20, treinta: 30, cuarenta: 40, cincuenta: 50, sesenta: 60,
|
|
44
|
+
setenta: 70, ochenta: 80, noventa: 90,
|
|
45
|
+
};
|
|
46
|
+
const HUNDREDS = {
|
|
47
|
+
cien: 100, ciento: 100, doscientos: 200, trescientos: 300,
|
|
48
|
+
cuatrocientos: 400, quinientos: 500, seiscientos: 600, setecientos: 700,
|
|
49
|
+
ochocientos: 800, novecientos: 900,
|
|
50
|
+
};
|
|
51
|
+
const FUZZY = {
|
|
52
|
+
unos: 3, unas: 3, algunos: 3, algunas: 3, varios: 3, varias: 3,
|
|
53
|
+
};
|
|
54
|
+
/** "1er", "1ro", "2do", "3ra", "5to" → ordinal value. */
|
|
55
|
+
function ordinalWord(w) {
|
|
56
|
+
const m = w?.match(/^(\d{1,2})(?:er|ero|era|ro|ra|do|da|to|ta|mo|ma|vo|va|no|na)$/);
|
|
57
|
+
return m ? Number(m[1]) : undefined;
|
|
58
|
+
}
|
|
59
|
+
const ORDINAL_WORDS = {
|
|
60
|
+
primer: 1, primero: 1, primera: 1,
|
|
61
|
+
segundo: 2, segunda: 2,
|
|
62
|
+
tercer: 3, tercero: 3, tercera: 3,
|
|
63
|
+
cuarto: 4, cuarta: 4,
|
|
64
|
+
quinto: 5, quinta: 5,
|
|
65
|
+
sexto: 6, sexta: 6,
|
|
66
|
+
séptimo: 7, septimo: 7, séptima: 7, septima: 7,
|
|
67
|
+
octavo: 8, octava: 8,
|
|
68
|
+
noveno: 9, novena: 9,
|
|
69
|
+
décimo: 10, decimo: 10, décima: 10, decima: 10,
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Spanish number words including "treinta y cinco", "mil novecientos noventa
|
|
73
|
+
* y dos", "dos mil veintisiete". Digits pass through.
|
|
74
|
+
*/
|
|
75
|
+
function readWordNum(tokens, i) {
|
|
76
|
+
const t = tokens[i];
|
|
77
|
+
if (t?.type === 'number')
|
|
78
|
+
return { value: t.value, consumed: 1 };
|
|
79
|
+
const w = word(t);
|
|
80
|
+
if (w === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
const ow = ordinalWord(w);
|
|
83
|
+
if (ow !== undefined)
|
|
84
|
+
return { value: ow, consumed: 1 };
|
|
85
|
+
if (FUZZY[w] !== undefined)
|
|
86
|
+
return { value: FUZZY[w], consumed: 1 };
|
|
87
|
+
const readBelowThousand = (k) => {
|
|
88
|
+
let at = k;
|
|
89
|
+
let v = 0;
|
|
90
|
+
const hw = word(tokens[at]);
|
|
91
|
+
if (hw !== undefined && HUNDREDS[hw] !== undefined) {
|
|
92
|
+
v += HUNDREDS[hw];
|
|
93
|
+
at += 1;
|
|
94
|
+
}
|
|
95
|
+
const tw = word(tokens[at]);
|
|
96
|
+
if (tw !== undefined && TENS[tw] !== undefined) {
|
|
97
|
+
v += TENS[tw];
|
|
98
|
+
at += 1;
|
|
99
|
+
if (word(tokens[at]) === 'y' && ONES[word(tokens[at + 1]) ?? ''] !== undefined) {
|
|
100
|
+
v += ONES[word(tokens[at + 1])];
|
|
101
|
+
at += 2;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (tw !== undefined && TEENS[tw] !== undefined) {
|
|
105
|
+
v += TEENS[tw];
|
|
106
|
+
at += 1;
|
|
107
|
+
}
|
|
108
|
+
else if (tw !== undefined && ONES[tw] !== undefined) {
|
|
109
|
+
v += ONES[tw];
|
|
110
|
+
at += 1;
|
|
111
|
+
}
|
|
112
|
+
return at === k ? undefined : { value: v, consumed: at - k };
|
|
113
|
+
};
|
|
114
|
+
// "mil …" / "dos mil …"
|
|
115
|
+
let at = i;
|
|
116
|
+
let thousands = 0;
|
|
117
|
+
if (w === 'mil') {
|
|
118
|
+
thousands = 1;
|
|
119
|
+
at += 1;
|
|
120
|
+
}
|
|
121
|
+
else if (ONES[w] !== undefined && word(tokens[i + 1]) === 'mil') {
|
|
122
|
+
thousands = ONES[w];
|
|
123
|
+
at += 2;
|
|
124
|
+
}
|
|
125
|
+
if (thousands > 0) {
|
|
126
|
+
const rest = readBelowThousand(at);
|
|
127
|
+
return { value: thousands * 1000 + (rest?.value ?? 0), consumed: at + (rest?.consumed ?? 0) - i };
|
|
128
|
+
}
|
|
129
|
+
if (ORDINAL_WORDS[w] !== undefined)
|
|
130
|
+
return { value: ORDINAL_WORDS[w], consumed: 1 };
|
|
131
|
+
return readBelowThousand(i);
|
|
132
|
+
}
|
|
133
|
+
/** Month lookup tolerant of trailing punctuation kept by the tokenizer ("junio:"). */
|
|
134
|
+
function monthOf(w) {
|
|
135
|
+
if (w === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
return MONTHS[w] ?? MONTHS[w.replace(/[:;,]+$/, '')];
|
|
138
|
+
}
|
|
139
|
+
function yearAt(tokens, i) {
|
|
140
|
+
const t = tokens[i];
|
|
141
|
+
return t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 2999 ? t.value : undefined;
|
|
142
|
+
}
|
|
143
|
+
function century(y) {
|
|
144
|
+
return y >= 100 ? y : y >= 30 ? 1900 + y : 2000 + y;
|
|
145
|
+
}
|
|
146
|
+
/** numdate token → DMY partial date. */
|
|
147
|
+
function numdateToDate(t) {
|
|
148
|
+
if (t.type !== 'numdate')
|
|
149
|
+
return undefined;
|
|
150
|
+
const p = t.parts;
|
|
151
|
+
const valid = (d) => d.month !== undefined && d.month >= 1 && d.month <= 12 && d.day !== undefined && d.day >= 1 && d.day <= 31
|
|
152
|
+
? d
|
|
153
|
+
: undefined;
|
|
154
|
+
if (p.length === 2) {
|
|
155
|
+
return valid({ day: p[0], month: p[1] }) ?? valid({ day: p[1], month: p[0] });
|
|
156
|
+
}
|
|
157
|
+
const [a, b, c] = [p[0], p[1], p[2]];
|
|
158
|
+
if (a >= 1000)
|
|
159
|
+
return valid({ year: a, month: b, day: c });
|
|
160
|
+
return (valid({ day: a, month: b, year: century(c) }) ?? valid({ day: b, month: a, year: century(c) }));
|
|
161
|
+
}
|
|
162
|
+
function timeLiteral(time) {
|
|
163
|
+
return { op: 'literal', time };
|
|
164
|
+
}
|
|
165
|
+
function meridiemFor(period) {
|
|
166
|
+
return period === 'morning' || period === 'madrugada' ? 'am' : 'pm';
|
|
167
|
+
}
|
|
168
|
+
/** A period noun including the multi-word "pasado [el] mediodía" (afternoon). */
|
|
169
|
+
function readPeriodWord(tokens, i) {
|
|
170
|
+
const w = word(tokens[i]);
|
|
171
|
+
if (w === 'pasado') {
|
|
172
|
+
let at = i + 1;
|
|
173
|
+
if (word(tokens[at]) === 'el')
|
|
174
|
+
at += 1;
|
|
175
|
+
const mw = word(tokens[at]);
|
|
176
|
+
if (mw === 'mediodía' || mw === 'mediodia')
|
|
177
|
+
return { period: 'afternoon', consumed: at + 1 - i };
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const period = PERIODS[w ?? ''];
|
|
181
|
+
if (period !== undefined)
|
|
182
|
+
return { period, consumed: 1 };
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Period suffix after a time: "de la mañana", "de la tarde", "de la noche",
|
|
187
|
+
* "de la madrugada", "por la tarde", "a la tarde", "en la noche".
|
|
188
|
+
*/
|
|
189
|
+
function readPeriodSuffix(tokens, i) {
|
|
190
|
+
let at = i;
|
|
191
|
+
const w0 = word(tokens[at]);
|
|
192
|
+
if (w0 !== 'de' && w0 !== 'del' && w0 !== 'por' && w0 !== 'en' && w0 !== 'a')
|
|
193
|
+
return undefined;
|
|
194
|
+
at += 1;
|
|
195
|
+
if (word(tokens[at]) === 'la' || word(tokens[at]) === 'el')
|
|
196
|
+
at += 1;
|
|
197
|
+
const w = word(tokens[at]);
|
|
198
|
+
if (w === 'madrugada')
|
|
199
|
+
return { period: 'madrugada', consumed: at + 1 - i };
|
|
200
|
+
const pw = readPeriodWord(tokens, at);
|
|
201
|
+
if (pw === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
return { period: pw.period, consumed: at + pw.consumed - i };
|
|
204
|
+
}
|
|
205
|
+
/** Apply a period's meridiem to an ambiguous time. */
|
|
206
|
+
function applyPeriod(time, period) {
|
|
207
|
+
if (time.meridiem !== undefined && time.meridiem !== 'unknown')
|
|
208
|
+
return time;
|
|
209
|
+
if (time.hour !== undefined && time.hour > 12)
|
|
210
|
+
return time;
|
|
211
|
+
return { ...time, meridiem: meridiemFor(period) };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Clock-time phrase: clock tokens, "20.30", "8: 45", "N y media/cuarto/NN",
|
|
215
|
+
* "N menos cuarto/N", "1140 a.m.", "N en punto", "mediodía", "medianoche".
|
|
216
|
+
* `bare` marks forms that were just a plain number reading.
|
|
217
|
+
*/
|
|
218
|
+
function readTime(tokens, i) {
|
|
219
|
+
let at = i;
|
|
220
|
+
const t = tokens[at];
|
|
221
|
+
const w = word(t);
|
|
222
|
+
if (w === 'mediodía' || w === 'mediodia') {
|
|
223
|
+
return { time: { hour: 12, meridiem: 'pm' }, consumed: 1, bare: false };
|
|
224
|
+
}
|
|
225
|
+
if (w === 'medianoche')
|
|
226
|
+
return { time: { hour: 12, meridiem: 'am' }, consumed: 1, bare: false };
|
|
227
|
+
const finishClock = (time, consumed) => {
|
|
228
|
+
let c = consumed;
|
|
229
|
+
// "menos cuarto" / "menos N" after the hour.
|
|
230
|
+
if (word(tokens[i + c]) === 'menos') {
|
|
231
|
+
const fw = word(tokens[i + c + 1]);
|
|
232
|
+
if (fw === 'cuarto') {
|
|
233
|
+
time = { ...time, hour: (time.hour ?? 1) === 0 ? 23 : (time.hour ?? 1) - 1, minute: 45 };
|
|
234
|
+
c += 2;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const m = readWordNum(tokens, i + c + 1);
|
|
238
|
+
if (m && m.value >= 1 && m.value <= 59) {
|
|
239
|
+
time = { ...time, hour: (time.hour ?? 1) === 0 ? 23 : (time.hour ?? 1) - 1, minute: 60 - m.value };
|
|
240
|
+
c += 1 + m.consumed;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// "en punto"
|
|
245
|
+
if (word(tokens[i + c]) === 'en' && word(tokens[i + c + 1]) === 'punto')
|
|
246
|
+
c += 2;
|
|
247
|
+
// trailing am/pm word
|
|
248
|
+
const mw = word(tokens[i + c]);
|
|
249
|
+
if ((mw === 'am' || mw === 'pm') && (time.meridiem === undefined || time.meridiem === 'unknown')) {
|
|
250
|
+
time = { ...time, meridiem: mw };
|
|
251
|
+
c += 1;
|
|
252
|
+
}
|
|
253
|
+
return { time, consumed: c, bare: false };
|
|
254
|
+
};
|
|
255
|
+
if (t?.type === 'clock') {
|
|
256
|
+
const time = { hour: t.hour };
|
|
257
|
+
if (t.explicitMinute)
|
|
258
|
+
time.minute = t.minute;
|
|
259
|
+
if (t.second !== undefined)
|
|
260
|
+
time.second = t.second;
|
|
261
|
+
if (t.meridiem)
|
|
262
|
+
time.meridiem = t.meridiem;
|
|
263
|
+
else if (t.hour <= 12)
|
|
264
|
+
time.meridiem = 'unknown';
|
|
265
|
+
return finishClock(time, 1);
|
|
266
|
+
}
|
|
267
|
+
// "7h" — hour with a joined h suffix.
|
|
268
|
+
let m = w?.match(/^(\d{1,2})h$/);
|
|
269
|
+
if (m && Number(m[1]) <= 23) {
|
|
270
|
+
const hour = Number(m[1]);
|
|
271
|
+
const time = { hour };
|
|
272
|
+
if (hour <= 12)
|
|
273
|
+
time.meridiem = 'unknown';
|
|
274
|
+
return finishClock(time, 1);
|
|
275
|
+
}
|
|
276
|
+
// "20.30" / "8.45" — dot-separated time in a single word token.
|
|
277
|
+
m = w?.match(/^(\d{1,2})\.(\d{2})$/);
|
|
278
|
+
if (m && Number(m[1]) <= 23 && Number(m[2]) <= 59) {
|
|
279
|
+
const hour = Number(m[1]);
|
|
280
|
+
const time = { hour, minute: Number(m[2]) };
|
|
281
|
+
if (hour <= 12)
|
|
282
|
+
time.meridiem = 'unknown';
|
|
283
|
+
return finishClock(time, 1);
|
|
284
|
+
}
|
|
285
|
+
// "9.30pm" / "4.30pm"
|
|
286
|
+
m = w?.match(/^(\d{1,2})\.(\d{2})(am|pm)$/);
|
|
287
|
+
if (m && Number(m[1]) <= 12 && Number(m[2]) <= 59) {
|
|
288
|
+
return finishClock({ hour: Number(m[1]), minute: Number(m[2]), meridiem: m[3] }, 1);
|
|
289
|
+
}
|
|
290
|
+
// "8:" + "45" — a space crept in after the colon.
|
|
291
|
+
m = w?.match(/^(\d{1,2}):$/);
|
|
292
|
+
if (m) {
|
|
293
|
+
const mt = tokens[at + 1];
|
|
294
|
+
if (mt?.type === 'number' && !mt.ordinal && mt.value <= 59) {
|
|
295
|
+
const hour = Number(m[1]);
|
|
296
|
+
if (hour <= 23) {
|
|
297
|
+
const time = { hour, minute: mt.value };
|
|
298
|
+
if (hour <= 12)
|
|
299
|
+
time.meridiem = 'unknown';
|
|
300
|
+
return finishClock(time, 2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// "1140 a.m." — military-style number + meridiem word.
|
|
305
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 100 && t.value <= 1259) {
|
|
306
|
+
const mw = word(tokens[at + 1]);
|
|
307
|
+
if (mw === 'am' || mw === 'pm') {
|
|
308
|
+
const hour = Math.floor(t.value / 100);
|
|
309
|
+
const minute = t.value % 100;
|
|
310
|
+
if (minute < 60)
|
|
311
|
+
return { time: { hour, minute, meridiem: mw }, consumed: 2, bare: false };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Number/word hour [+ y M | menos M | en punto | am/pm].
|
|
315
|
+
const n = readWordNum(tokens, at);
|
|
316
|
+
if (!n || n.value > 23 || tokens[at]?.type === 'number' && tokens[at].type === 'number' && tokens[at].ordinal) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
at += n.consumed;
|
|
320
|
+
let hour = n.value;
|
|
321
|
+
let minute;
|
|
322
|
+
let explicit = false;
|
|
323
|
+
const nw = word(tokens[at]);
|
|
324
|
+
if (nw === 'y') {
|
|
325
|
+
const fw = word(tokens[at + 1]);
|
|
326
|
+
if (fw === 'media') {
|
|
327
|
+
minute = 30;
|
|
328
|
+
at += 2;
|
|
329
|
+
explicit = true;
|
|
330
|
+
}
|
|
331
|
+
else if (fw === 'cuarto') {
|
|
332
|
+
minute = 15;
|
|
333
|
+
at += 2;
|
|
334
|
+
explicit = true;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const mnum = readWordNum(tokens, at + 1);
|
|
338
|
+
if (mnum && mnum.value <= 59 && tokens[at + 1]?.type !== 'number') {
|
|
339
|
+
minute = mnum.value;
|
|
340
|
+
at += 1 + mnum.consumed;
|
|
341
|
+
explicit = true;
|
|
342
|
+
}
|
|
343
|
+
else if (mnum && mnum.value <= 59 && tokens[at + 1]?.type === 'number') {
|
|
344
|
+
minute = mnum.value;
|
|
345
|
+
at += 1 + mnum.consumed;
|
|
346
|
+
explicit = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (nw === 'menos') {
|
|
351
|
+
const fw = word(tokens[at + 1]);
|
|
352
|
+
if (fw === 'cuarto') {
|
|
353
|
+
hour = hour === 0 ? 23 : hour - 1;
|
|
354
|
+
minute = 45;
|
|
355
|
+
at += 2;
|
|
356
|
+
explicit = true;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const mnum = readWordNum(tokens, at + 1);
|
|
360
|
+
if (mnum && mnum.value >= 1 && mnum.value <= 59) {
|
|
361
|
+
hour = hour === 0 ? 23 : hour - 1;
|
|
362
|
+
minute = 60 - mnum.value;
|
|
363
|
+
at += 1 + mnum.consumed;
|
|
364
|
+
explicit = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (nw === 'en' && word(tokens[at + 1]) === 'punto') {
|
|
369
|
+
at += 2;
|
|
370
|
+
explicit = true;
|
|
371
|
+
}
|
|
372
|
+
else if (nw === 'am' || nw === 'pm') {
|
|
373
|
+
const time = { hour, meridiem: nw };
|
|
374
|
+
return finishClock(time, at + 1 - i);
|
|
375
|
+
}
|
|
376
|
+
else if (nw === 'horas' || nw === 'hs') {
|
|
377
|
+
// Colloquial "7 horas del próximo domingo" = 7 o'clock.
|
|
378
|
+
at += 1;
|
|
379
|
+
explicit = true;
|
|
380
|
+
}
|
|
381
|
+
else if (tokens[i]?.type === 'word' && nw !== undefined) {
|
|
382
|
+
// Word-hour with trailing word-number minutes: "dos treinta",
|
|
383
|
+
// "dos cuarenta y cinco" (minutes must be a word, not a digit).
|
|
384
|
+
if (tokens[at]?.type === 'word' && (TENS[nw] !== undefined || TEENS[nw] !== undefined)) {
|
|
385
|
+
const mnum = readWordNum(tokens, at);
|
|
386
|
+
if (mnum && mnum.value >= 10 && mnum.value <= 59) {
|
|
387
|
+
minute = mnum.value;
|
|
388
|
+
at += mnum.consumed;
|
|
389
|
+
explicit = true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!explicit && at === i + n.consumed) {
|
|
394
|
+
// Plain number — only usable by callers that provide a licensing lead.
|
|
395
|
+
const time = { hour };
|
|
396
|
+
if (hour <= 12)
|
|
397
|
+
time.meridiem = 'unknown';
|
|
398
|
+
return { time, consumed: at - i, bare: true };
|
|
399
|
+
}
|
|
400
|
+
const time = { hour };
|
|
401
|
+
if (minute !== undefined)
|
|
402
|
+
time.minute = minute;
|
|
403
|
+
if (hour <= 12)
|
|
404
|
+
time.meridiem = 'unknown';
|
|
405
|
+
const fin = finishClock(time, at - i);
|
|
406
|
+
return { ...fin, bare: false };
|
|
407
|
+
}
|
|
408
|
+
/* ------------------------------------------------------------------ */
|
|
409
|
+
/* Time rules */
|
|
410
|
+
/* ------------------------------------------------------------------ */
|
|
411
|
+
const TIME_LEADS = [
|
|
412
|
+
['alrededor', 'de', 'las'], ['alrededor', 'de', 'la'],
|
|
413
|
+
['a', 'eso', 'de', 'las'], ['hacia', 'las'], ['hacia', 'la'],
|
|
414
|
+
['a', 'las'], ['a', 'la'], ['las'], ['la'],
|
|
415
|
+
];
|
|
416
|
+
function matchPhraseAt(tokens, i, words) {
|
|
417
|
+
for (const [k, w] of words.entries()) {
|
|
418
|
+
if (word(tokens[i + k]) !== w)
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
return i + words.length;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* "a las 3 y media", "las siete menos cuarto de la tarde", "a las 8.30 de la
|
|
425
|
+
* noche hoy", "a las 7 del próximo domingo a la tarde", "a las cinco y media
|
|
426
|
+
* mañana por la tarde" — time with optional period suffix and day phrase.
|
|
427
|
+
*/
|
|
428
|
+
const esTime = (tokens, i) => {
|
|
429
|
+
let at = i;
|
|
430
|
+
let hasLead = false;
|
|
431
|
+
for (const lead of TIME_LEADS) {
|
|
432
|
+
const end = matchPhraseAt(tokens, i, lead);
|
|
433
|
+
if (end !== undefined) {
|
|
434
|
+
at = end;
|
|
435
|
+
hasLead = true;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const read = readTime(tokens, at);
|
|
440
|
+
if (!read)
|
|
441
|
+
return undefined;
|
|
442
|
+
if (read.bare && !hasLead)
|
|
443
|
+
return undefined;
|
|
444
|
+
at += read.consumed;
|
|
445
|
+
let time = read.time;
|
|
446
|
+
// Period suffix fixes the meridiem: "de la mañana/tarde/noche/madrugada".
|
|
447
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
448
|
+
if (ps) {
|
|
449
|
+
time = applyPeriod(time, ps.period);
|
|
450
|
+
at += ps.consumed;
|
|
451
|
+
// "a las 8 de la noche menos cuarto"
|
|
452
|
+
if (word(tokens[at]) === 'menos') {
|
|
453
|
+
const fw = word(tokens[at + 1]);
|
|
454
|
+
if (fw === 'cuarto') {
|
|
455
|
+
time = { ...time, hour: (time.hour ?? 1) - 1 || 23, minute: 45 };
|
|
456
|
+
at += 2;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const mnum = readWordNum(tokens, at + 1);
|
|
460
|
+
if (mnum && mnum.value >= 1 && mnum.value <= 59) {
|
|
461
|
+
time = { ...time, hour: (time.hour ?? 1) - 1 || 23, minute: 60 - mnum.value };
|
|
462
|
+
at += 1 + mnum.consumed;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Optional day phrase: deictic ("mañana", "hoy") or "del próximo domingo",
|
|
468
|
+
// then optionally a trailing period marker resolving the meridiem.
|
|
469
|
+
let dayExpr;
|
|
470
|
+
let dayEnd = at;
|
|
471
|
+
const dw = word(tokens[at]);
|
|
472
|
+
if (dw !== undefined && ES_DEICTIC[dw] !== undefined) {
|
|
473
|
+
dayExpr = snapOffset(ES_DEICTIC[dw], 'day');
|
|
474
|
+
dayEnd = at + 1;
|
|
475
|
+
}
|
|
476
|
+
else if (dw === 'del' || dw === 'de' || dw === 'el') {
|
|
477
|
+
let k = at + 1;
|
|
478
|
+
if (word(tokens[k]) === 'el')
|
|
479
|
+
k += 1;
|
|
480
|
+
let delta;
|
|
481
|
+
const mw = word(tokens[k]);
|
|
482
|
+
if (mw === 'próximo' || mw === 'proximo' || mw === 'siguiente') {
|
|
483
|
+
delta = 1;
|
|
484
|
+
k += 1;
|
|
485
|
+
}
|
|
486
|
+
else if (mw === 'este') {
|
|
487
|
+
delta = 0;
|
|
488
|
+
k += 1;
|
|
489
|
+
}
|
|
490
|
+
else if (mw === 'pasado' || mw === 'último' || mw === 'ultimo') {
|
|
491
|
+
delta = -1;
|
|
492
|
+
k += 1;
|
|
493
|
+
}
|
|
494
|
+
const wd = WEEKDAYS[word(tokens[k]) ?? ''];
|
|
495
|
+
if (wd !== undefined && delta !== undefined) {
|
|
496
|
+
// "próximo domingo" follows the context nextWeekday policy (no n).
|
|
497
|
+
const dir = delta === -1 ? 'prev' : delta === 1 ? 'next' : 'nearest';
|
|
498
|
+
dayExpr = { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: wd } };
|
|
499
|
+
dayEnd = k + 1;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (dayExpr !== undefined) {
|
|
503
|
+
at = dayEnd;
|
|
504
|
+
const ps2 = readPeriodSuffix(tokens, at);
|
|
505
|
+
if (ps2) {
|
|
506
|
+
time = applyPeriod(time, ps2.period);
|
|
507
|
+
at += ps2.consumed;
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
expr: { op: 'intersect', parts: [dayExpr, timeLiteral(time)] },
|
|
511
|
+
consumed: at - i,
|
|
512
|
+
confidence: 1,
|
|
513
|
+
role: 'datetime',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return { expr: timeLiteral(time), consumed: at - i, confidence: 1, role: 'time' };
|
|
517
|
+
};
|
|
518
|
+
/* ------------------------------------------------------------------ */
|
|
519
|
+
/* Numeric dates and joined forms */
|
|
520
|
+
/* ------------------------------------------------------------------ */
|
|
521
|
+
/** Quarter/semester regex forms: "2017-t1", "2019 t1", "t1 2019". */
|
|
522
|
+
function readQuarterToken(tokens, i) {
|
|
523
|
+
const w = word(tokens[i]);
|
|
524
|
+
let m = w?.match(/^([12]\d{3})[-]?t([1-4])$/);
|
|
525
|
+
if (m)
|
|
526
|
+
return { year: Number(m[1]), start: (Number(m[2]) - 1) * 3 + 1, months: 3, consumed: 1 };
|
|
527
|
+
m = w?.match(/^t([1-4])(?:-([12]\d{3}))?$/);
|
|
528
|
+
if (m) {
|
|
529
|
+
if (m[2] !== undefined) {
|
|
530
|
+
return { year: Number(m[2]), start: (Number(m[1]) - 1) * 3 + 1, months: 3, consumed: 1 };
|
|
531
|
+
}
|
|
532
|
+
let at = i + 1;
|
|
533
|
+
if (word(tokens[at]) === 'de' || word(tokens[at]) === 'del' || word(tokens[at]) === '-')
|
|
534
|
+
at += 1;
|
|
535
|
+
const y = yearAt(tokens, at);
|
|
536
|
+
if (y !== undefined)
|
|
537
|
+
return { year: y, start: (Number(m[1]) - 1) * 3 + 1, months: 3, consumed: at + 1 - i };
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
const y0 = yearAt(tokens, i);
|
|
541
|
+
if (y0 !== undefined) {
|
|
542
|
+
let at = i + 1;
|
|
543
|
+
if (word(tokens[at]) === '-')
|
|
544
|
+
at += 1;
|
|
545
|
+
const qm = word(tokens[at])?.match(/^t([1-4])$/);
|
|
546
|
+
if (qm)
|
|
547
|
+
return { year: y0, start: (Number(qm[1]) - 1) * 3 + 1, months: 3, consumed: at + 1 - i };
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
function quarterExpr(q) {
|
|
552
|
+
return {
|
|
553
|
+
op: 'span',
|
|
554
|
+
anchor: { op: 'literal', date: { year: q.year, month: q.start } },
|
|
555
|
+
amount: { months: q.months },
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const esQuarter = (tokens, i) => {
|
|
559
|
+
const q = readQuarterToken(tokens, i);
|
|
560
|
+
if (!q)
|
|
561
|
+
return undefined;
|
|
562
|
+
return { expr: quarterExpr(q), consumed: q.consumed, confidence: 0.95, role: 'date' };
|
|
563
|
+
};
|
|
564
|
+
/**
|
|
565
|
+
* Ordinal quarters/semesters/months of a year scope: "primer trimestre de
|
|
566
|
+
* 2017", "segundo semestre", "el tercer mes de 2021", "1º trimestre-2019",
|
|
567
|
+
* "el noveno mes del próximo año", "el primer trimestre del año".
|
|
568
|
+
*/
|
|
569
|
+
function readOrdinalQuarter(tokens, i) {
|
|
570
|
+
let at = i;
|
|
571
|
+
if (['el', 'la', 'del'].includes(word(tokens[at]) ?? ''))
|
|
572
|
+
at += 1;
|
|
573
|
+
let nth;
|
|
574
|
+
const w0 = word(tokens[at]);
|
|
575
|
+
if (w0 !== undefined && ORDINAL_WORDS[w0] !== undefined)
|
|
576
|
+
nth = ORDINAL_WORDS[w0];
|
|
577
|
+
else if (ordinalWord(w0) !== undefined)
|
|
578
|
+
nth = ordinalWord(w0);
|
|
579
|
+
else if (tokens[at]?.type === 'number' && tokens[at].value <= 9) {
|
|
580
|
+
nth = tokens[at].value;
|
|
581
|
+
}
|
|
582
|
+
if (nth === undefined)
|
|
583
|
+
return undefined;
|
|
584
|
+
at += 1;
|
|
585
|
+
const uw = word(tokens[at]);
|
|
586
|
+
let months;
|
|
587
|
+
let unit;
|
|
588
|
+
if (uw === 'trimestre') {
|
|
589
|
+
months = 3;
|
|
590
|
+
unit = 'quarter';
|
|
591
|
+
}
|
|
592
|
+
else if (uw === 'semestre') {
|
|
593
|
+
months = 6;
|
|
594
|
+
unit = 'semester';
|
|
595
|
+
}
|
|
596
|
+
else if (uw === 'mes') {
|
|
597
|
+
months = 1;
|
|
598
|
+
unit = 'month';
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
const maxN = unit === 'quarter' ? 4 : unit === 'semester' ? 2 : 12;
|
|
604
|
+
if (nth < 1 || nth > maxN)
|
|
605
|
+
return undefined;
|
|
606
|
+
at += 1;
|
|
607
|
+
const startMonth = (nth - 1) * months + 1;
|
|
608
|
+
// Scope: "de 2017", "-2019", "del año", "del próximo año", or none (current year).
|
|
609
|
+
let scopeYear;
|
|
610
|
+
let scopeExpr;
|
|
611
|
+
let scopeModified = false;
|
|
612
|
+
let cursor = at;
|
|
613
|
+
const sw = word(tokens[cursor]);
|
|
614
|
+
if (sw === 'de' || sw === 'del' || sw === '-') {
|
|
615
|
+
let k = cursor + 1;
|
|
616
|
+
if (word(tokens[k]) === 'el')
|
|
617
|
+
k += 1;
|
|
618
|
+
const y = yearAt(tokens, k);
|
|
619
|
+
if (y !== undefined) {
|
|
620
|
+
scopeYear = y;
|
|
621
|
+
cursor = k + 1;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
let delta = 0;
|
|
625
|
+
const mw = word(tokens[k]);
|
|
626
|
+
if (mw === 'próximo' || mw === 'proximo' || mw === 'siguiente') {
|
|
627
|
+
delta = 1;
|
|
628
|
+
k += 1;
|
|
629
|
+
}
|
|
630
|
+
else if (mw === 'este') {
|
|
631
|
+
k += 1;
|
|
632
|
+
scopeModified = true;
|
|
633
|
+
}
|
|
634
|
+
else if (mw === 'pasado' || mw === 'último' || mw === 'ultimo') {
|
|
635
|
+
delta = -1;
|
|
636
|
+
k += 1;
|
|
637
|
+
}
|
|
638
|
+
if (word(tokens[k]) === 'año' || word(tokens[k]) === 'ano') {
|
|
639
|
+
scopeExpr = delta === 0 ? snapNow('year') : snapOffset(delta, 'year');
|
|
640
|
+
if (delta !== 0)
|
|
641
|
+
scopeModified = true;
|
|
642
|
+
cursor = k + 1;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
const y = yearAt(tokens, cursor);
|
|
648
|
+
if (y !== undefined) {
|
|
649
|
+
scopeYear = y;
|
|
650
|
+
cursor += 1;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (scopeYear !== undefined) {
|
|
654
|
+
if (unit === 'month') {
|
|
655
|
+
return {
|
|
656
|
+
expr: { op: 'literal', date: { year: scopeYear, month: startMonth } },
|
|
657
|
+
consumed: cursor - i,
|
|
658
|
+
year: scopeYear,
|
|
659
|
+
startMonth,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
expr: {
|
|
664
|
+
op: 'span',
|
|
665
|
+
anchor: { op: 'literal', date: { year: scopeYear, month: startMonth } },
|
|
666
|
+
amount: { months },
|
|
667
|
+
},
|
|
668
|
+
consumed: cursor - i,
|
|
669
|
+
year: scopeYear,
|
|
670
|
+
startMonth,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (!scopeModified) {
|
|
674
|
+
// Unanchored year ("2º semestre", "primer trimestre del año") → the month
|
|
675
|
+
// literal supplies dual current/adjacent-year candidates.
|
|
676
|
+
if (unit === 'month') {
|
|
677
|
+
return { expr: { op: 'literal', date: { month: startMonth } }, consumed: cursor - i, startMonth };
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { month: startMonth } }, amount: { months } },
|
|
681
|
+
consumed: cursor - i,
|
|
682
|
+
startMonth,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const base = scopeExpr ?? snapNow('year');
|
|
686
|
+
if (unit === 'month') {
|
|
687
|
+
return {
|
|
688
|
+
expr: { op: 'intersect', parts: [base, { op: 'literal', date: { month: startMonth } }] },
|
|
689
|
+
consumed: cursor - i,
|
|
690
|
+
startMonth,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const startPoint = startMonth === 1
|
|
694
|
+
? { op: 'snap', base, unit: 'year', edge: 'start' }
|
|
695
|
+
: { op: 'offset', base: { op: 'snap', base, unit: 'year', edge: 'start' }, amount: startMonth - 1, unit: 'month' };
|
|
696
|
+
return { expr: { op: 'span', anchor: startPoint, amount: { months } }, consumed: cursor - i, startMonth };
|
|
697
|
+
}
|
|
698
|
+
const esOrdinalQuarter = (tokens, i) => {
|
|
699
|
+
const q = readOrdinalQuarter(tokens, i);
|
|
700
|
+
if (!q)
|
|
701
|
+
return undefined;
|
|
702
|
+
return { expr: q.expr, consumed: q.consumed, confidence: 1, role: 'date' };
|
|
703
|
+
};
|
|
704
|
+
/** Joined numeric forms in a single word token. */
|
|
705
|
+
const esNumdate = (tokens, i) => {
|
|
706
|
+
const t = tokens[i];
|
|
707
|
+
if (t?.type === 'numdate') {
|
|
708
|
+
const date = numdateToDate(t);
|
|
709
|
+
if (!date)
|
|
710
|
+
return undefined;
|
|
711
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 1, role: 'date' };
|
|
712
|
+
}
|
|
713
|
+
const w = word(t);
|
|
714
|
+
if (w === undefined)
|
|
715
|
+
return undefined;
|
|
716
|
+
// "2014-2018" / "2014~2018" → year range.
|
|
717
|
+
let m = w.match(/^([12]\d{3})[-~]([12]\d{3})$/);
|
|
718
|
+
if (m && Number(m[1]) < Number(m[2])) {
|
|
719
|
+
return {
|
|
720
|
+
expr: {
|
|
721
|
+
op: 'between',
|
|
722
|
+
start: { op: 'literal', date: { year: Number(m[1]) } },
|
|
723
|
+
end: { op: 'literal', date: { year: Number(m[2]) } },
|
|
724
|
+
},
|
|
725
|
+
consumed: 1,
|
|
726
|
+
confidence: 0.95,
|
|
727
|
+
role: 'date',
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
// "12-2015" / "2015-3" → month-year.
|
|
731
|
+
m = w.match(/^(\d{1,2})-([12]\d{3})$/);
|
|
732
|
+
if (m && Number(m[1]) >= 1 && Number(m[1]) <= 12) {
|
|
733
|
+
return {
|
|
734
|
+
expr: { op: 'literal', date: { year: Number(m[2]), month: Number(m[1]) } },
|
|
735
|
+
consumed: 1,
|
|
736
|
+
confidence: 0.95,
|
|
737
|
+
role: 'date',
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
m = w.match(/^([12]\d{3})-(\d{1,2})$/);
|
|
741
|
+
if (m && Number(m[2]) >= 1 && Number(m[2]) <= 12) {
|
|
742
|
+
return {
|
|
743
|
+
expr: { op: 'literal', date: { year: Number(m[1]), month: Number(m[2]) } },
|
|
744
|
+
consumed: 1,
|
|
745
|
+
confidence: 0.95,
|
|
746
|
+
role: 'date',
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
// "10/1-11/2/2017" / "28/2-1/3" → joined numeric-date range (D/M order).
|
|
750
|
+
m = w.match(/^(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
|
751
|
+
if (m) {
|
|
752
|
+
const year = m[5] !== undefined ? century(Number(m[5])) : yearAt(tokens, i + 1);
|
|
753
|
+
const mk = (d, mo) => mo >= 1 && mo <= 12 && d >= 1 && d <= 31
|
|
754
|
+
? year !== undefined
|
|
755
|
+
? { day: d, month: mo, year }
|
|
756
|
+
: { day: d, month: mo }
|
|
757
|
+
: undefined;
|
|
758
|
+
const d1 = mk(Number(m[1]), Number(m[2]));
|
|
759
|
+
const d2 = mk(Number(m[3]), Number(m[4]));
|
|
760
|
+
if (d1 && d2) {
|
|
761
|
+
const extra = m[5] === undefined && year !== undefined ? 1 : 0;
|
|
762
|
+
return {
|
|
763
|
+
expr: { op: 'between', start: { op: 'literal', date: d1 }, end: { op: 'literal', date: d2 } },
|
|
764
|
+
consumed: 1 + extra,
|
|
765
|
+
confidence: 0.95,
|
|
766
|
+
role: 'date',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// "1-10-2018-7-10-2018" → joined D-M-Y date range.
|
|
771
|
+
m = w.match(/^(\d{1,2})-(\d{1,2})-(\d{4})-(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
772
|
+
if (m) {
|
|
773
|
+
const mk = (d, mo, y) => mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? { year: y, month: mo, day: d } : undefined;
|
|
774
|
+
const d1 = mk(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
775
|
+
const d2 = mk(Number(m[4]), Number(m[5]), Number(m[6]));
|
|
776
|
+
if (d1 && d2) {
|
|
777
|
+
return {
|
|
778
|
+
expr: { op: 'between', start: { op: 'literal', date: d1 }, end: { op: 'literal', date: d2 } },
|
|
779
|
+
consumed: 1,
|
|
780
|
+
confidence: 0.9,
|
|
781
|
+
role: 'date',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// "diciembre/2018" (month/year), "01/agosto/2019", "2020/sep/23", "23/sep/2020".
|
|
786
|
+
if (w.includes('/') && !/^[\d/]+$/.test(w)) {
|
|
787
|
+
const segments = w.split('/');
|
|
788
|
+
if (segments.length >= 2 && segments.length <= 3) {
|
|
789
|
+
let month;
|
|
790
|
+
let day;
|
|
791
|
+
let year;
|
|
792
|
+
let ok = true;
|
|
793
|
+
for (const seg of segments) {
|
|
794
|
+
const mo = MONTHS[seg];
|
|
795
|
+
if (mo !== undefined && month === undefined)
|
|
796
|
+
month = mo;
|
|
797
|
+
else if (/^\d{4}$/.test(seg) && year === undefined)
|
|
798
|
+
year = Number(seg);
|
|
799
|
+
else if (/^\d{1,2}$/.test(seg) && day === undefined && Number(seg) >= 1 && Number(seg) <= 31)
|
|
800
|
+
day = Number(seg);
|
|
801
|
+
else {
|
|
802
|
+
ok = false;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (ok && month !== undefined && (day !== undefined || year !== undefined)) {
|
|
807
|
+
const date = { month };
|
|
808
|
+
if (day !== undefined)
|
|
809
|
+
date.day = day;
|
|
810
|
+
if (year !== undefined)
|
|
811
|
+
date.year = year;
|
|
812
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.95, role: 'date' };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// "sales_report-2002-10-09.xlsx" — embedded ISO date.
|
|
817
|
+
m = w.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
|
818
|
+
if (m && /[a-z]/.test(w)) {
|
|
819
|
+
const mo = Number(m[2]);
|
|
820
|
+
const d = Number(m[3]);
|
|
821
|
+
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
|
|
822
|
+
return {
|
|
823
|
+
expr: { op: 'literal', date: { year: Number(m[1]), month: mo, day: d } },
|
|
824
|
+
consumed: 1,
|
|
825
|
+
confidence: 0.85,
|
|
826
|
+
role: 'date',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// "0930-0730" → military time pair (both readings am/pm).
|
|
831
|
+
m = w.match(/^(\d{3,4})-(\d{3,4})$/);
|
|
832
|
+
if (m && Number(m[1]) >= 100 && Number(m[2]) >= 100) {
|
|
833
|
+
const mk = (v) => {
|
|
834
|
+
const hour = Math.floor(v / 100);
|
|
835
|
+
const minute = v % 100;
|
|
836
|
+
if (hour > 23 || minute > 59)
|
|
837
|
+
return undefined;
|
|
838
|
+
const time = { hour, minute };
|
|
839
|
+
if (hour <= 12)
|
|
840
|
+
time.meridiem = 'unknown';
|
|
841
|
+
return time;
|
|
842
|
+
};
|
|
843
|
+
const t1 = mk(Number(m[1]));
|
|
844
|
+
const t2 = mk(Number(m[2]));
|
|
845
|
+
if (t1 && t2) {
|
|
846
|
+
return {
|
|
847
|
+
expr: { op: 'between', start: timeLiteral(t1), end: timeLiteral(t2) },
|
|
848
|
+
consumed: 1,
|
|
849
|
+
confidence: 0.9,
|
|
850
|
+
role: 'time',
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// "07:30-09:30" → clock pair in one token.
|
|
855
|
+
m = w.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
|
856
|
+
if (m) {
|
|
857
|
+
const mk = (h, mi) => {
|
|
858
|
+
if (h > 23 || mi > 59)
|
|
859
|
+
return undefined;
|
|
860
|
+
const time = { hour: h, minute: mi };
|
|
861
|
+
if (h <= 12)
|
|
862
|
+
time.meridiem = 'unknown';
|
|
863
|
+
return time;
|
|
864
|
+
};
|
|
865
|
+
const t1 = mk(Number(m[1]), Number(m[2]));
|
|
866
|
+
const t2 = mk(Number(m[3]), Number(m[4]));
|
|
867
|
+
if (t1 && t2) {
|
|
868
|
+
return {
|
|
869
|
+
expr: { op: 'between', start: timeLiteral(t1), end: timeLiteral(t2) },
|
|
870
|
+
consumed: 1,
|
|
871
|
+
confidence: 0.9,
|
|
872
|
+
role: 'time',
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// "9.30-4.30pm" → dot-time pair.
|
|
877
|
+
m = w.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})(am|pm)?$/);
|
|
878
|
+
if (m) {
|
|
879
|
+
const t1 = { hour: Number(m[1]), minute: Number(m[2]) };
|
|
880
|
+
const t2 = { hour: Number(m[3]), minute: Number(m[4]) };
|
|
881
|
+
if (t1.hour <= 23 && t2.hour <= 23 && t1.minute <= 59 && t2.minute <= 59) {
|
|
882
|
+
if (m[5] !== undefined) {
|
|
883
|
+
t2.meridiem = m[5];
|
|
884
|
+
if (t1.hour <= 12)
|
|
885
|
+
t1.meridiem = 'unknown';
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
if (t1.hour <= 12)
|
|
889
|
+
t1.meridiem = 'unknown';
|
|
890
|
+
if (t2.hour <= 12)
|
|
891
|
+
t2.meridiem = 'unknown';
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
expr: { op: 'between', start: timeLiteral(t1), end: timeLiteral(t2) },
|
|
895
|
+
consumed: 1,
|
|
896
|
+
confidence: 0.9,
|
|
897
|
+
role: 'time',
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// "3-8" ascending → hour pair; "12-5" descending → D-M date.
|
|
902
|
+
m = w.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
903
|
+
if (m) {
|
|
904
|
+
const a = Number(m[1]);
|
|
905
|
+
const b = Number(m[2]);
|
|
906
|
+
if (a < b && a >= 1 && b <= 23) {
|
|
907
|
+
const mk = (h) => (h <= 12 ? { hour: h, meridiem: 'unknown' } : { hour: h });
|
|
908
|
+
// A trailing period suffix fixes both meridiems: "6-8 de la mañana".
|
|
909
|
+
const ps = readPeriodSuffix(tokens, i + 1);
|
|
910
|
+
const s = ps ? applyPeriod(mk(a), ps.period) : mk(a);
|
|
911
|
+
const e = ps ? applyPeriod(mk(b), ps.period) : mk(b);
|
|
912
|
+
return {
|
|
913
|
+
expr: { op: 'between', start: timeLiteral(s), end: timeLiteral(e) },
|
|
914
|
+
consumed: 1 + (ps?.consumed ?? 0),
|
|
915
|
+
confidence: 0.85,
|
|
916
|
+
role: 'time',
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (a > b && a <= 31 && b >= 1 && b <= 12) {
|
|
920
|
+
return {
|
|
921
|
+
expr: { op: 'literal', date: { day: a, month: b } },
|
|
922
|
+
consumed: 1,
|
|
923
|
+
confidence: 0.8,
|
|
924
|
+
role: 'date',
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return undefined;
|
|
929
|
+
};
|
|
930
|
+
/**
|
|
931
|
+
* Word dates: "15 de marzo de 2026" is factory territory; this covers word
|
|
932
|
+
* ordinals ("1er de octubre"), the "día" infix ("el primer día de enero de
|
|
933
|
+
* 2015"), numeric months ("el 9 de 8 de 1971", "el 17 del 11"), word-number
|
|
934
|
+
* years ("veintiocho de marzo de mil novecientos noventa y dos"), 2-digit
|
|
935
|
+
* years ("4-Jul-95"), and typo separators ("22 do febrero").
|
|
936
|
+
*/
|
|
937
|
+
const esDayMonth = (tokens, i) => {
|
|
938
|
+
let at = i;
|
|
939
|
+
const w0 = word(tokens[at]);
|
|
940
|
+
if (w0 === 'el' || w0 === 'este' || w0 === 'del')
|
|
941
|
+
at += 1;
|
|
942
|
+
const day = readWordNum(tokens, at);
|
|
943
|
+
if (!day || day.value < 1 || day.value > 31)
|
|
944
|
+
return undefined;
|
|
945
|
+
let cursor = at + day.consumed;
|
|
946
|
+
if (word(tokens[cursor]) === 'día' || word(tokens[cursor]) === 'dia')
|
|
947
|
+
cursor += 1;
|
|
948
|
+
let sepWord;
|
|
949
|
+
if (['de', 'del', 'do', '-'].includes(word(tokens[cursor]) ?? '')) {
|
|
950
|
+
sepWord = word(tokens[cursor]);
|
|
951
|
+
cursor += 1;
|
|
952
|
+
}
|
|
953
|
+
const mw = word(tokens[cursor]);
|
|
954
|
+
let month = monthOf(mw);
|
|
955
|
+
let monthConsumed = 1;
|
|
956
|
+
if (month === undefined) {
|
|
957
|
+
// Numeric month: "9 de 8 de 1971", "17 del 11" — only with a separator.
|
|
958
|
+
const mt = tokens[cursor];
|
|
959
|
+
if (sepWord !== undefined &&
|
|
960
|
+
mt?.type === 'number' && !mt.ordinal && mt.value >= 1 && mt.value <= 12 &&
|
|
961
|
+
(sepWord === 'del' || ['de', 'del'].includes(word(tokens[cursor + 1]) ?? ''))) {
|
|
962
|
+
month = mt.value;
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
return undefined;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
cursor += monthConsumed;
|
|
969
|
+
// Year: "de 2015", "del 87", "- 95", "de mil novecientos noventa y dos".
|
|
970
|
+
let yAt = cursor;
|
|
971
|
+
let ySep;
|
|
972
|
+
if (['de', 'del', '-'].includes(word(tokens[yAt]) ?? '')) {
|
|
973
|
+
ySep = word(tokens[yAt]);
|
|
974
|
+
yAt += 1;
|
|
975
|
+
}
|
|
976
|
+
let year;
|
|
977
|
+
let yEnd = yAt;
|
|
978
|
+
const y4 = yearAt(tokens, yAt);
|
|
979
|
+
if (y4 !== undefined) {
|
|
980
|
+
year = y4;
|
|
981
|
+
yEnd = yAt + 1;
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
const yn = readWordNum(tokens, yAt);
|
|
985
|
+
if (yn && yn.value >= 1000 && yn.value <= 2999) {
|
|
986
|
+
year = yn.value;
|
|
987
|
+
yEnd = yAt + yn.consumed;
|
|
988
|
+
}
|
|
989
|
+
else if (ySep !== undefined && tokens[yAt]?.type === 'number') {
|
|
990
|
+
const v = tokens[yAt].value;
|
|
991
|
+
if (v >= 32 && v <= 99) {
|
|
992
|
+
year = century(v);
|
|
993
|
+
yEnd = yAt + 1;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const date = { month, day: day.value };
|
|
998
|
+
if (year !== undefined) {
|
|
999
|
+
date.year = year;
|
|
1000
|
+
cursor = yEnd;
|
|
1001
|
+
}
|
|
1002
|
+
// Validate rough day/month fit (Feb 30 must not parse).
|
|
1003
|
+
const maxDay = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
1004
|
+
if (day.value > maxDay)
|
|
1005
|
+
return undefined;
|
|
1006
|
+
// Redundant parenthesized weekday: "el 22 de mayo (martes)".
|
|
1007
|
+
if (WEEKDAYS[word(tokens[cursor]) ?? ''] !== undefined)
|
|
1008
|
+
cursor += 1;
|
|
1009
|
+
return { expr: { op: 'literal', date }, consumed: cursor - i, confidence: 1, role: 'date' };
|
|
1010
|
+
};
|
|
1011
|
+
/** Month-first: "sep-23-2020", "septiembre-2020-23", "noviembre 19-20", "abril del 87". */
|
|
1012
|
+
const esMonthFirst = (tokens, i) => {
|
|
1013
|
+
const w = word(tokens[i]);
|
|
1014
|
+
const month = MONTHS[w ?? ''];
|
|
1015
|
+
if (month === undefined)
|
|
1016
|
+
return undefined;
|
|
1017
|
+
let at = i + 1;
|
|
1018
|
+
if (word(tokens[at]) === '-')
|
|
1019
|
+
at += 1;
|
|
1020
|
+
// "noviembre 19-20" → day range within the month.
|
|
1021
|
+
const pair = word(tokens[at])?.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
1022
|
+
if (pair && Number(pair[1]) >= 1 && Number(pair[1]) <= 31 && Number(pair[2]) >= 1 && Number(pair[2]) <= 31) {
|
|
1023
|
+
return {
|
|
1024
|
+
expr: {
|
|
1025
|
+
op: 'between',
|
|
1026
|
+
start: { op: 'literal', date: { month, day: Number(pair[1]) } },
|
|
1027
|
+
end: { op: 'literal', date: { month, day: Number(pair[2]) } },
|
|
1028
|
+
},
|
|
1029
|
+
consumed: at + 1 - i,
|
|
1030
|
+
confidence: 0.9,
|
|
1031
|
+
role: 'date',
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
// "abril del 87" → April 1987.
|
|
1035
|
+
if (word(tokens[i + 1]) === 'del' || word(tokens[i + 1]) === 'de') {
|
|
1036
|
+
const t = tokens[i + 2];
|
|
1037
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 32 && t.value <= 99) {
|
|
1038
|
+
return {
|
|
1039
|
+
expr: { op: 'literal', date: { month, year: century(t.value) } },
|
|
1040
|
+
consumed: 3,
|
|
1041
|
+
confidence: 0.9,
|
|
1042
|
+
role: 'date',
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const t0 = tokens[at];
|
|
1047
|
+
if (t0?.type !== 'number' || t0.ordinal)
|
|
1048
|
+
return undefined;
|
|
1049
|
+
// "septiembre-2020-23" → month · year · day; "sep 2020" → month-year.
|
|
1050
|
+
if (t0.value >= 1000 && t0.value <= 2999) {
|
|
1051
|
+
const year = t0.value;
|
|
1052
|
+
let dAt = at + 1;
|
|
1053
|
+
if (word(tokens[dAt]) === '-')
|
|
1054
|
+
dAt += 1;
|
|
1055
|
+
const dTok = tokens[dAt];
|
|
1056
|
+
if (dTok?.type === 'number' && !dTok.ordinal && dTok.value >= 1 && dTok.value <= 31) {
|
|
1057
|
+
return {
|
|
1058
|
+
expr: { op: 'literal', date: { year, month, day: dTok.value } },
|
|
1059
|
+
consumed: dAt + 1 - i,
|
|
1060
|
+
confidence: 0.9,
|
|
1061
|
+
role: 'date',
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
return { expr: { op: 'literal', date: { year, month } }, consumed: at + 1 - i, confidence: 0.9, role: 'date' };
|
|
1065
|
+
}
|
|
1066
|
+
// "sep-23-2020" → month · day [· year].
|
|
1067
|
+
if (t0.value >= 1 && t0.value <= 31) {
|
|
1068
|
+
let cursor = at + 1;
|
|
1069
|
+
let yAt = cursor;
|
|
1070
|
+
if (word(tokens[yAt]) === '-' || word(tokens[yAt]) === 'de')
|
|
1071
|
+
yAt += 1;
|
|
1072
|
+
const year = yearAt(tokens, yAt);
|
|
1073
|
+
const date = { month, day: t0.value };
|
|
1074
|
+
if (year !== undefined) {
|
|
1075
|
+
date.year = year;
|
|
1076
|
+
cursor = yAt + 1;
|
|
1077
|
+
}
|
|
1078
|
+
return { expr: { op: 'literal', date }, consumed: cursor - i, confidence: 0.9, role: 'date' };
|
|
1079
|
+
}
|
|
1080
|
+
return undefined;
|
|
1081
|
+
};
|
|
1082
|
+
/** "23-2020-septiembre" → day · year · month (dash-split tokens). */
|
|
1083
|
+
const esSpacedDate = (tokens, i) => {
|
|
1084
|
+
const t1 = tokens[i];
|
|
1085
|
+
if (t1?.type === 'number' && !t1.ordinal && t1.value >= 1 && t1.value <= 31 && word(tokens[i + 1]) === '-') {
|
|
1086
|
+
const yTok = tokens[i + 2];
|
|
1087
|
+
if (yTok?.type === 'number' && yTok.value >= 1000 && yTok.value <= 2999 && word(tokens[i + 3]) === '-') {
|
|
1088
|
+
const month = MONTHS[word(tokens[i + 4]) ?? ''];
|
|
1089
|
+
if (month !== undefined) {
|
|
1090
|
+
return {
|
|
1091
|
+
expr: { op: 'literal', date: { day: t1.value, month, year: yTok.value } },
|
|
1092
|
+
consumed: 5,
|
|
1093
|
+
confidence: 0.9,
|
|
1094
|
+
role: 'date',
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return undefined;
|
|
1100
|
+
};
|
|
1101
|
+
/** "el 15", "el día 18", "para el día 21 de este mes" — bare day-of-month. */
|
|
1102
|
+
const esBareDay = (tokens, i) => {
|
|
1103
|
+
let at = i;
|
|
1104
|
+
if (word(tokens[at]) === 'para')
|
|
1105
|
+
at += 1;
|
|
1106
|
+
const art = word(tokens[at]);
|
|
1107
|
+
if (art !== 'el' && art !== 'del' && art !== 'este')
|
|
1108
|
+
return undefined;
|
|
1109
|
+
at += 1;
|
|
1110
|
+
if (word(tokens[at]) === 'día' || word(tokens[at]) === 'dia')
|
|
1111
|
+
at += 1;
|
|
1112
|
+
const t = tokens[at];
|
|
1113
|
+
let day;
|
|
1114
|
+
let dayConsumed = 1;
|
|
1115
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 1 && t.value <= 31)
|
|
1116
|
+
day = t.value;
|
|
1117
|
+
else {
|
|
1118
|
+
day = ordinalWord(word(t));
|
|
1119
|
+
if (day === undefined) {
|
|
1120
|
+
const wn = readWordNum(tokens, at);
|
|
1121
|
+
if (wn && wn.value >= 1 && wn.value <= 31 && tokens[at]?.type === 'word') {
|
|
1122
|
+
day = wn.value;
|
|
1123
|
+
dayConsumed = wn.consumed;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (day === undefined || day < 1 || day > 31)
|
|
1128
|
+
return undefined;
|
|
1129
|
+
let cursor = at + dayConsumed;
|
|
1130
|
+
// Month, weekday, or unit follows → other rules own it ("el primer lunes",
|
|
1131
|
+
// "el tercer mes de 2021").
|
|
1132
|
+
const next = word(tokens[cursor]);
|
|
1133
|
+
if (next !== undefined && (MONTHS[next] !== undefined || next === 'do'))
|
|
1134
|
+
return undefined;
|
|
1135
|
+
if (next !== undefined && (WEEKDAYS[next] !== undefined || ES_UNITS[next] !== undefined)) {
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
if (next === 'de' || next === 'del') {
|
|
1139
|
+
// "el día 21 de este mes" / "el 15 del próximo mes".
|
|
1140
|
+
let k = cursor + 1;
|
|
1141
|
+
if (word(tokens[k]) === 'el')
|
|
1142
|
+
k += 1;
|
|
1143
|
+
let delta;
|
|
1144
|
+
const mw = word(tokens[k]);
|
|
1145
|
+
if (mw === 'este') {
|
|
1146
|
+
delta = 0;
|
|
1147
|
+
k += 1;
|
|
1148
|
+
}
|
|
1149
|
+
else if (mw === 'próximo' || mw === 'proximo' || mw === 'siguiente') {
|
|
1150
|
+
delta = 1;
|
|
1151
|
+
k += 1;
|
|
1152
|
+
}
|
|
1153
|
+
else if (mw === 'pasado' || mw === 'último' || mw === 'ultimo') {
|
|
1154
|
+
delta = -1;
|
|
1155
|
+
k += 1;
|
|
1156
|
+
}
|
|
1157
|
+
if (word(tokens[k]) === 'mes' && delta !== undefined) {
|
|
1158
|
+
const monthExpr = delta === 0 ? snapNow('month') : snapOffset(delta, 'month');
|
|
1159
|
+
return {
|
|
1160
|
+
expr: { op: 'intersect', parts: [monthExpr, { op: 'literal', date: { day } }] },
|
|
1161
|
+
consumed: k + 1 - i,
|
|
1162
|
+
confidence: 1,
|
|
1163
|
+
role: 'date',
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (MONTHS[word(tokens[k]) ?? ''] !== undefined)
|
|
1167
|
+
return undefined;
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
return { expr: { op: 'literal', date: { day } }, consumed: cursor - i, confidence: 0.8, role: 'date' };
|
|
1171
|
+
};
|
|
1172
|
+
/** "lunes 21", "el martes 25", "el lunes el veintisiete", "el miércoles el día treinta y uno". */
|
|
1173
|
+
const esWeekdayDay = (tokens, i) => {
|
|
1174
|
+
let at = i;
|
|
1175
|
+
if (word(tokens[at]) === 'el' || word(tokens[at]) === 'este')
|
|
1176
|
+
at += 1;
|
|
1177
|
+
const wd = WEEKDAYS[word(tokens[at]) ?? ''];
|
|
1178
|
+
if (!wd)
|
|
1179
|
+
return undefined;
|
|
1180
|
+
at += 1;
|
|
1181
|
+
if (word(tokens[at]) === 'el')
|
|
1182
|
+
at += 1;
|
|
1183
|
+
if (word(tokens[at]) === 'día' || word(tokens[at]) === 'dia')
|
|
1184
|
+
at += 1;
|
|
1185
|
+
const t = tokens[at];
|
|
1186
|
+
let day;
|
|
1187
|
+
let consumed = 1;
|
|
1188
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 1 && t.value <= 31)
|
|
1189
|
+
day = t.value;
|
|
1190
|
+
else {
|
|
1191
|
+
const wn = readWordNum(tokens, at);
|
|
1192
|
+
if (wn && wn.value >= 1 && wn.value <= 31 && t?.type === 'word') {
|
|
1193
|
+
day = wn.value;
|
|
1194
|
+
consumed = wn.consumed;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (day === undefined)
|
|
1198
|
+
return undefined;
|
|
1199
|
+
// Month or numdate follows → full-date rules own it ("el martes 9 de mayo").
|
|
1200
|
+
const next = word(tokens[at + consumed]);
|
|
1201
|
+
if (next !== undefined && MONTHS[next] !== undefined)
|
|
1202
|
+
return undefined;
|
|
1203
|
+
if ((next === 'de' || next === 'del') && monthOf(word(tokens[at + consumed + 1])) !== undefined) {
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
if (tokens[at + consumed]?.type === 'numdate')
|
|
1207
|
+
return undefined;
|
|
1208
|
+
if (tokens[at]?.type === 'numdate')
|
|
1209
|
+
return undefined;
|
|
1210
|
+
return {
|
|
1211
|
+
expr: { op: 'literal', date: { day } },
|
|
1212
|
+
consumed: at + consumed - i,
|
|
1213
|
+
confidence: 0.9,
|
|
1214
|
+
role: 'date',
|
|
1215
|
+
};
|
|
1216
|
+
};
|
|
1217
|
+
/* ------------------------------------------------------------------ */
|
|
1218
|
+
/* Durations and spans */
|
|
1219
|
+
/* ------------------------------------------------------------------ */
|
|
1220
|
+
const DUR_UNITS = {
|
|
1221
|
+
segundo: 'seconds', segundos: 'seconds', seg: 'seconds', segs: 'seconds', s: 'seconds',
|
|
1222
|
+
minuto: 'minutes', minutos: 'minutes', min: 'minutes', mins: 'minutes',
|
|
1223
|
+
hora: 'hours', horas: 'hours', h: 'hours', hs: 'hours', hr: 'hours', hrs: 'hours',
|
|
1224
|
+
día: 'days', días: 'days', dia: 'days', dias: 'days',
|
|
1225
|
+
noche: 'days', noches: 'days',
|
|
1226
|
+
semana: 'weeks', semanas: 'weeks',
|
|
1227
|
+
mes: 'months', meses: 'months',
|
|
1228
|
+
año: 'years', años: 'years', ano: 'years', anos: 'years',
|
|
1229
|
+
};
|
|
1230
|
+
/** One duration component: "3 horas", "media hora", "una y media hora", "3,5 años", "½ hora". */
|
|
1231
|
+
function readDurComponent(tokens, i) {
|
|
1232
|
+
let at = i;
|
|
1233
|
+
const w0 = word(tokens[at]);
|
|
1234
|
+
// "media hora" / "½ hora"
|
|
1235
|
+
if ((w0 === 'media' || w0 === '½') && (word(tokens[at + 1]) === 'hora' || word(tokens[at + 1]) === 'horas')) {
|
|
1236
|
+
return { amount: { minutes: 30 }, consumed: 2 };
|
|
1237
|
+
}
|
|
1238
|
+
// "todo el día" / "toda la semana" → one whole unit.
|
|
1239
|
+
if (w0 === 'todo' || w0 === 'toda') {
|
|
1240
|
+
let k = at + 1;
|
|
1241
|
+
if (word(tokens[k]) === 'el' || word(tokens[k]) === 'la')
|
|
1242
|
+
k += 1;
|
|
1243
|
+
const unit = DUR_UNITS[word(tokens[k]) ?? ''];
|
|
1244
|
+
if (unit)
|
|
1245
|
+
return { amount: { [unit]: 1 }, consumed: k + 1 - i };
|
|
1246
|
+
return undefined;
|
|
1247
|
+
}
|
|
1248
|
+
// "otro día" / "otra hora"
|
|
1249
|
+
if (w0 === 'otro' || w0 === 'otra') {
|
|
1250
|
+
const unit = DUR_UNITS[word(tokens[at + 1]) ?? ''];
|
|
1251
|
+
if (unit)
|
|
1252
|
+
return { amount: { [unit]: 1 }, consumed: 2 };
|
|
1253
|
+
return undefined;
|
|
1254
|
+
}
|
|
1255
|
+
// Decimal in one token: "123.45 segundos", "3.5 años".
|
|
1256
|
+
const dm = w0?.match(/^(\d+)\.(\d+)$/);
|
|
1257
|
+
if (dm) {
|
|
1258
|
+
const unit = DUR_UNITS[word(tokens[at + 1]) ?? ''];
|
|
1259
|
+
if (unit) {
|
|
1260
|
+
const value = Number(`${dm[1]}.${dm[2]}`);
|
|
1261
|
+
return { amount: { [unit]: value }, consumed: 2 };
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const n = readWordNum(tokens, at);
|
|
1265
|
+
if (!n)
|
|
1266
|
+
return undefined;
|
|
1267
|
+
at += n.consumed;
|
|
1268
|
+
let value = n.value;
|
|
1269
|
+
// "3,5 años" — the comma vanished, leaving two adjacent number tokens.
|
|
1270
|
+
const t1 = tokens[at - 1];
|
|
1271
|
+
const t2 = tokens[at];
|
|
1272
|
+
if (t1?.type === 'number' && t2?.type === 'number' && !t2.ordinal &&
|
|
1273
|
+
t2.start === t1.end + 1 && DUR_UNITS[word(tokens[at + 1]) ?? ''] !== undefined) {
|
|
1274
|
+
value = n.value + t2.value / 10 ** (t2.end - t2.start);
|
|
1275
|
+
at += 1;
|
|
1276
|
+
}
|
|
1277
|
+
// "uno y cuarto año" / "una y media hora" — fraction before the unit word.
|
|
1278
|
+
if (word(tokens[at]) === 'y') {
|
|
1279
|
+
const fw = word(tokens[at + 1]);
|
|
1280
|
+
if ((fw === 'media' || fw === 'medio') && DUR_UNITS[word(tokens[at + 2]) ?? ''] !== undefined) {
|
|
1281
|
+
const unit = DUR_UNITS[word(tokens[at + 2]) ?? ''];
|
|
1282
|
+
return { amount: { [unit]: value + 0.5 }, consumed: at + 3 - i };
|
|
1283
|
+
}
|
|
1284
|
+
if (fw === 'cuarto' && DUR_UNITS[word(tokens[at + 2]) ?? ''] !== undefined) {
|
|
1285
|
+
const unit = DUR_UNITS[word(tokens[at + 2]) ?? ''];
|
|
1286
|
+
return { amount: { [unit]: value + 0.25 }, consumed: at + 3 - i };
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
// "tres fines de semana" → 3 × 2 days.
|
|
1290
|
+
if ((word(tokens[at]) === 'fines' || word(tokens[at]) === 'fin') && word(tokens[at + 1]) === 'de' && word(tokens[at + 2]) === 'semana') {
|
|
1291
|
+
return { amount: { days: 2 * value }, consumed: at + 3 - i };
|
|
1292
|
+
}
|
|
1293
|
+
const unit = DUR_UNITS[word(tokens[at]) ?? ''];
|
|
1294
|
+
if (!unit)
|
|
1295
|
+
return undefined;
|
|
1296
|
+
let end = at + 1;
|
|
1297
|
+
// "un mes entero"
|
|
1298
|
+
if (['entero', 'entera', 'enteros', 'enteras', 'completo', 'completa'].includes(word(tokens[end]) ?? ''))
|
|
1299
|
+
end += 1;
|
|
1300
|
+
return { amount: { [unit]: value }, consumed: end - i };
|
|
1301
|
+
}
|
|
1302
|
+
function finishDuration(tokens, i, at, comp) {
|
|
1303
|
+
let cursor = at + comp.consumed;
|
|
1304
|
+
const amount = { ...comp.amount };
|
|
1305
|
+
// "y media" / "y medio" / "y cuarto" / a second component ("y tres meses",
|
|
1306
|
+
// "y 30 minutos", "y 2 semanas y 2 días").
|
|
1307
|
+
let guard = 0;
|
|
1308
|
+
while (guard < 3) {
|
|
1309
|
+
guard += 1;
|
|
1310
|
+
let extraAt = cursor;
|
|
1311
|
+
let sawY = false;
|
|
1312
|
+
if (word(tokens[extraAt]) === 'y') {
|
|
1313
|
+
extraAt += 1;
|
|
1314
|
+
sawY = true;
|
|
1315
|
+
}
|
|
1316
|
+
const fw = word(tokens[extraAt]);
|
|
1317
|
+
if (sawY && (fw === 'media' || fw === 'medio')) {
|
|
1318
|
+
if (amount['years'] !== undefined)
|
|
1319
|
+
amount['years'] += 0.5;
|
|
1320
|
+
else if (amount['hours'] !== undefined)
|
|
1321
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 30;
|
|
1322
|
+
else
|
|
1323
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 30;
|
|
1324
|
+
cursor = extraAt + 1;
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
if (sawY && fw === 'cuarto') {
|
|
1328
|
+
if (amount['years'] !== undefined)
|
|
1329
|
+
amount['years'] += 0.25;
|
|
1330
|
+
else
|
|
1331
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 15;
|
|
1332
|
+
cursor = extraAt + 1;
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
if (sawY) {
|
|
1336
|
+
const second = readDurComponent(tokens, extraAt);
|
|
1337
|
+
if (second) {
|
|
1338
|
+
for (const [k, v] of Object.entries(second.amount)) {
|
|
1339
|
+
// Recognizers folds months into fractional years when combined:
|
|
1340
|
+
// "un año y tres meses" = 1.25 years.
|
|
1341
|
+
if (k === 'months' && amount['years'] !== undefined) {
|
|
1342
|
+
amount['years'] += v / 12;
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
amount[k] = (amount[k] ?? 0) + v;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
cursor = extraAt + second.consumed;
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
return { expr: { op: 'amount', amount }, consumed: cursor - i, confidence: 1, role: 'duration' };
|
|
1355
|
+
}
|
|
1356
|
+
const DUR_MARKERS = [
|
|
1357
|
+
['por'], ['durante'], ['para'], ['dura'], ['durará'], ['durara'], ['duró'],
|
|
1358
|
+
['duro'], ['durar'], ['tomará'], ['tomara'], ['toma'], ['más', 'de'],
|
|
1359
|
+
['mas', 'de'], ['de'], ['que'],
|
|
1360
|
+
];
|
|
1361
|
+
/** "por tres horas", "durará dos horas", "de ½ hora", "más de 1 hora y media". */
|
|
1362
|
+
const esDuration = (tokens, i) => {
|
|
1363
|
+
let at;
|
|
1364
|
+
for (const marker of DUR_MARKERS) {
|
|
1365
|
+
const end = matchPhraseAt(tokens, i, marker);
|
|
1366
|
+
if (end !== undefined) {
|
|
1367
|
+
at = end;
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
if (at === undefined) {
|
|
1372
|
+
// Marker-less "30 minutos o más".
|
|
1373
|
+
const comp = readDurComponent(tokens, i);
|
|
1374
|
+
if (comp && word(tokens[i + comp.consumed]) === 'o' &&
|
|
1375
|
+
(word(tokens[i + comp.consumed + 1]) === 'más' || word(tokens[i + comp.consumed + 1]) === 'mas')) {
|
|
1376
|
+
const fin = finishDuration(tokens, i, i, comp);
|
|
1377
|
+
return { ...fin, consumed: comp.consumed + 2 };
|
|
1378
|
+
}
|
|
1379
|
+
return undefined;
|
|
1380
|
+
}
|
|
1381
|
+
const w = word(tokens[at]);
|
|
1382
|
+
// "por el día" / "por el mes" → the current unit interval, not a duration.
|
|
1383
|
+
if ((w === 'el' || w === 'la') && word(tokens[i]) === 'por') {
|
|
1384
|
+
const unitWord = word(tokens[at + 1]);
|
|
1385
|
+
const unit = unitWord === 'día' || unitWord === 'dia'
|
|
1386
|
+
? 'day'
|
|
1387
|
+
: unitWord === 'semana'
|
|
1388
|
+
? 'week'
|
|
1389
|
+
: unitWord === 'mes'
|
|
1390
|
+
? 'month'
|
|
1391
|
+
: unitWord === 'año' || unitWord === 'ano'
|
|
1392
|
+
? 'year'
|
|
1393
|
+
: undefined;
|
|
1394
|
+
if (unit) {
|
|
1395
|
+
const next = word(tokens[at + 2]);
|
|
1396
|
+
if (next === undefined || (WEEKDAYS[next] === undefined && MONTHS[next] === undefined &&
|
|
1397
|
+
!['pasado', 'pasada', 'próximo', 'próxima', 'proximo', 'proxima', 'siguiente', 'anterior', 'que'].includes(next))) {
|
|
1398
|
+
return { expr: snapNow(unit), consumed: at + 2 - i, confidence: 0.9, role: 'date' };
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return undefined;
|
|
1402
|
+
}
|
|
1403
|
+
if (w === 'un' || w === 'una') {
|
|
1404
|
+
const comp = readDurComponent(tokens, at + 1);
|
|
1405
|
+
if (comp) {
|
|
1406
|
+
const merged = finishDuration(tokens, i, at + 1, comp);
|
|
1407
|
+
return merged;
|
|
1408
|
+
}
|
|
1409
|
+
const unit = DUR_UNITS[word(tokens[at + 1]) ?? ''];
|
|
1410
|
+
if (unit)
|
|
1411
|
+
return finishDuration(tokens, i, at + 1, { amount: { [unit]: 1 }, consumed: 1 });
|
|
1412
|
+
// "una y media hora" via component reader with the leading "una".
|
|
1413
|
+
const comp2 = readDurComponent(tokens, at);
|
|
1414
|
+
if (comp2)
|
|
1415
|
+
return finishDuration(tokens, i, at, comp2);
|
|
1416
|
+
return undefined;
|
|
1417
|
+
}
|
|
1418
|
+
const comp = readDurComponent(tokens, at);
|
|
1419
|
+
if (!comp)
|
|
1420
|
+
return undefined;
|
|
1421
|
+
return finishDuration(tokens, i, at, comp);
|
|
1422
|
+
};
|
|
1423
|
+
/** "toda la semana" / "todo el día" standalone → whole-unit duration. */
|
|
1424
|
+
const esAllUnit = (tokens, i) => {
|
|
1425
|
+
const w0 = word(tokens[i]);
|
|
1426
|
+
if (w0 !== 'todo' && w0 !== 'toda')
|
|
1427
|
+
return undefined;
|
|
1428
|
+
let at = i + 1;
|
|
1429
|
+
if (word(tokens[at]) === 'el' || word(tokens[at]) === 'la')
|
|
1430
|
+
at += 1;
|
|
1431
|
+
const unit = DUR_UNITS[word(tokens[at]) ?? ''];
|
|
1432
|
+
if (!unit)
|
|
1433
|
+
return undefined;
|
|
1434
|
+
return { expr: { op: 'amount', amount: { [unit]: 1 } }, consumed: at + 1 - i, confidence: 1, role: 'duration' };
|
|
1435
|
+
};
|
|
1436
|
+
const BUSINESS_QUALIFIERS = [
|
|
1437
|
+
['hábiles'], ['habiles'], ['laborables'], ['laborales'], ['de', 'trabajo'],
|
|
1438
|
+
];
|
|
1439
|
+
/**
|
|
1440
|
+
* "[en] los 2 días siguientes", "las 10 semanas anteriores", "los 4 días
|
|
1441
|
+
* hábiles siguientes", "los últimos 3 meses y 2 semanas y 2 días".
|
|
1442
|
+
*/
|
|
1443
|
+
const esNUnitSpan = (tokens, i) => {
|
|
1444
|
+
let at = i;
|
|
1445
|
+
if (word(tokens[at]) === 'en' || word(tokens[at]) === 'durante')
|
|
1446
|
+
at += 1;
|
|
1447
|
+
if (['los', 'las', 'el', 'la'].includes(word(tokens[at]) ?? ''))
|
|
1448
|
+
at += 1;
|
|
1449
|
+
let sign;
|
|
1450
|
+
const adj0 = word(tokens[at]) ?? '';
|
|
1451
|
+
if (ES_LAST_ADJS.includes(adj0)) {
|
|
1452
|
+
sign = -1;
|
|
1453
|
+
at += 1;
|
|
1454
|
+
}
|
|
1455
|
+
else if (ES_NEXT_ADJS.includes(adj0)) {
|
|
1456
|
+
sign = 1;
|
|
1457
|
+
at += 1;
|
|
1458
|
+
}
|
|
1459
|
+
const n = readWordNum(tokens, at);
|
|
1460
|
+
if (!n)
|
|
1461
|
+
return undefined;
|
|
1462
|
+
at += n.consumed;
|
|
1463
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
1464
|
+
if (!unit)
|
|
1465
|
+
return undefined;
|
|
1466
|
+
at += 1;
|
|
1467
|
+
let business = false;
|
|
1468
|
+
for (const q of BUSINESS_QUALIFIERS) {
|
|
1469
|
+
const end = matchPhraseAt(tokens, at, q);
|
|
1470
|
+
if (end !== undefined) {
|
|
1471
|
+
business = true;
|
|
1472
|
+
at = end;
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (sign === undefined) {
|
|
1477
|
+
const post = word(tokens[at]) ?? '';
|
|
1478
|
+
if (ES_LAST_ADJS.includes(post)) {
|
|
1479
|
+
sign = -1;
|
|
1480
|
+
at += 1;
|
|
1481
|
+
}
|
|
1482
|
+
else if (ES_NEXT_ADJS.includes(post)) {
|
|
1483
|
+
sign = 1;
|
|
1484
|
+
at += 1;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (sign === undefined)
|
|
1488
|
+
return undefined;
|
|
1489
|
+
const amount = unit === 'quarter' ? { months: 3 * sign * n.value } : { [`${unit}s`]: sign * n.value };
|
|
1490
|
+
// Compound tail: "y 2 semanas y 2 días".
|
|
1491
|
+
let cursor = at;
|
|
1492
|
+
let guard = 0;
|
|
1493
|
+
while (word(tokens[cursor]) === 'y' && guard < 3) {
|
|
1494
|
+
guard += 1;
|
|
1495
|
+
const n2 = readWordNum(tokens, cursor + 1);
|
|
1496
|
+
if (!n2)
|
|
1497
|
+
break;
|
|
1498
|
+
const u2 = ES_UNITS[word(tokens[cursor + 1 + n2.consumed]) ?? ''];
|
|
1499
|
+
if (!u2)
|
|
1500
|
+
break;
|
|
1501
|
+
const key = u2 === 'quarter' ? 'months' : `${u2}s`;
|
|
1502
|
+
const add = u2 === 'quarter' ? 3 * sign * n2.value : sign * n2.value;
|
|
1503
|
+
amount[key] = (amount[key] ?? 0) + add;
|
|
1504
|
+
cursor += 2 + n2.consumed;
|
|
1505
|
+
}
|
|
1506
|
+
const expr = { op: 'span', anchor: NOW, amount };
|
|
1507
|
+
if (business)
|
|
1508
|
+
expr.business = true;
|
|
1509
|
+
return { expr, consumed: cursor - i, confidence: 1, role: 'date' };
|
|
1510
|
+
};
|
|
1511
|
+
/**
|
|
1512
|
+
* "las primeras 2 semanas de 2021", "las últimas 3 semanas de este año",
|
|
1513
|
+
* "los últimos cuatro meses de 2021", "los primeros diez días del último año".
|
|
1514
|
+
*/
|
|
1515
|
+
const esFirstLastNOfScope = (tokens, i) => {
|
|
1516
|
+
let at = i;
|
|
1517
|
+
if (['los', 'las'].includes(word(tokens[at]) ?? ''))
|
|
1518
|
+
at += 1;
|
|
1519
|
+
const adj = word(tokens[at]) ?? '';
|
|
1520
|
+
let first;
|
|
1521
|
+
if (['primeras', 'primeros'].includes(adj))
|
|
1522
|
+
first = true;
|
|
1523
|
+
else if (['últimas', 'ultimos', 'últimos', 'ultimas'].includes(adj))
|
|
1524
|
+
first = false;
|
|
1525
|
+
else
|
|
1526
|
+
return undefined;
|
|
1527
|
+
at += 1;
|
|
1528
|
+
const n = readWordNum(tokens, at);
|
|
1529
|
+
if (!n)
|
|
1530
|
+
return undefined;
|
|
1531
|
+
at += n.consumed;
|
|
1532
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
1533
|
+
if (!unit || unit === 'hour' || unit === 'minute' || unit === 'second')
|
|
1534
|
+
return undefined;
|
|
1535
|
+
at += 1;
|
|
1536
|
+
const sw = word(tokens[at]);
|
|
1537
|
+
if (sw !== 'de' && sw !== 'del')
|
|
1538
|
+
return undefined;
|
|
1539
|
+
at += 1;
|
|
1540
|
+
if (word(tokens[at]) === 'el')
|
|
1541
|
+
at += 1;
|
|
1542
|
+
// Scope: numeric year or (este|último|próximo) año.
|
|
1543
|
+
let scope;
|
|
1544
|
+
let scopeYear;
|
|
1545
|
+
const y = yearAt(tokens, at);
|
|
1546
|
+
if (y !== undefined) {
|
|
1547
|
+
scopeYear = y;
|
|
1548
|
+
scope = { op: 'literal', date: { year: y } };
|
|
1549
|
+
at += 1;
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
let delta = 0;
|
|
1553
|
+
const mw = word(tokens[at]);
|
|
1554
|
+
if (mw === 'este')
|
|
1555
|
+
at += 1;
|
|
1556
|
+
else if (mw === 'último' || mw === 'ultimo' || mw === 'pasado') {
|
|
1557
|
+
delta = -1;
|
|
1558
|
+
at += 1;
|
|
1559
|
+
}
|
|
1560
|
+
else if (mw === 'próximo' || mw === 'proximo') {
|
|
1561
|
+
delta = 1;
|
|
1562
|
+
at += 1;
|
|
1563
|
+
}
|
|
1564
|
+
if (word(tokens[at]) !== 'año' && word(tokens[at]) !== 'ano')
|
|
1565
|
+
return undefined;
|
|
1566
|
+
scope = delta === 0 ? snapNow('year') : snapOffset(delta, 'year');
|
|
1567
|
+
at += 1;
|
|
1568
|
+
}
|
|
1569
|
+
const key = unit === 'quarter' ? 'months' : `${unit}s`;
|
|
1570
|
+
const value = unit === 'quarter' ? 3 * n.value : n.value;
|
|
1571
|
+
let expr;
|
|
1572
|
+
if (unit === 'week') {
|
|
1573
|
+
// Week-aligned: first weeks anchor at the ISO first week (week of Jan 4);
|
|
1574
|
+
// last weeks end at the end of the week containing Dec 28.
|
|
1575
|
+
const jan4 = scopeYear !== undefined
|
|
1576
|
+
? { op: 'literal', date: { year: scopeYear, month: 1, day: 4 } }
|
|
1577
|
+
: { op: 'intersect', parts: [scope, { op: 'literal', date: { month: 1, day: 4 } }] };
|
|
1578
|
+
const dec28 = scopeYear !== undefined
|
|
1579
|
+
? { op: 'literal', date: { year: scopeYear, month: 12, day: 28 } }
|
|
1580
|
+
: { op: 'intersect', parts: [scope, { op: 'literal', date: { month: 12, day: 28 } }] };
|
|
1581
|
+
expr = first
|
|
1582
|
+
? { op: 'span', anchor: { op: 'snap', base: jan4, unit: 'week' }, amount: { [key]: value } }
|
|
1583
|
+
: { op: 'span', anchor: { op: 'snap', base: dec28, unit: 'week' }, amount: { [key]: -value } };
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
expr = first
|
|
1587
|
+
? { op: 'span', anchor: scope, amount: { [key]: value } }
|
|
1588
|
+
: { op: 'span', anchor: scope, amount: { [key]: -value } };
|
|
1589
|
+
}
|
|
1590
|
+
return { expr, consumed: at - i, confidence: 1, role: 'date' };
|
|
1591
|
+
};
|
|
1592
|
+
/** "dentro de 5 minutos" / "dentro de dos años" → the rolling span from now. */
|
|
1593
|
+
const esDentroDe = (tokens, i) => {
|
|
1594
|
+
if (word(tokens[i]) !== 'dentro' || word(tokens[i + 1]) !== 'de')
|
|
1595
|
+
return undefined;
|
|
1596
|
+
const n = readWordNum(tokens, i + 2);
|
|
1597
|
+
if (!n)
|
|
1598
|
+
return undefined;
|
|
1599
|
+
const unit = ES_UNITS[word(tokens[i + 2 + n.consumed]) ?? ''];
|
|
1600
|
+
if (!unit)
|
|
1601
|
+
return undefined;
|
|
1602
|
+
const subDay = unit === 'second' || unit === 'minute' || unit === 'hour';
|
|
1603
|
+
// A day-grain anchor (today) sidesteps the partial-period exclusion that a
|
|
1604
|
+
// bare `now` anchor triggers: "dentro de dos años" starts today.
|
|
1605
|
+
const anchor = subDay ? NOW : snapNow('day');
|
|
1606
|
+
const amount = unit === 'quarter' ? { months: 3 * n.value } : { [`${unit}s`]: n.value };
|
|
1607
|
+
return {
|
|
1608
|
+
expr: { op: 'span', anchor, amount },
|
|
1609
|
+
consumed: n.consumed + 3,
|
|
1610
|
+
confidence: 1,
|
|
1611
|
+
role: 'datetime',
|
|
1612
|
+
};
|
|
1613
|
+
};
|
|
1614
|
+
/* ------------------------------------------------------------------ */
|
|
1615
|
+
/* N units before/after a date */
|
|
1616
|
+
/* ------------------------------------------------------------------ */
|
|
1617
|
+
/**
|
|
1618
|
+
* "dos días después de hoy", "3 días a partir de hoy", "2 días a partir de
|
|
1619
|
+
* ahora", "dos semanas desde el año 2011", "dentro de tres semanas a partir
|
|
1620
|
+
* de mañana", "menos de 3 días posteriores de mañana", "30 minutos después".
|
|
1621
|
+
*/
|
|
1622
|
+
const esNAfterDate = (tokens, i, ctx) => {
|
|
1623
|
+
let at = i;
|
|
1624
|
+
let asSpan = false;
|
|
1625
|
+
if (word(tokens[at]) === 'menos' && word(tokens[at + 1]) === 'de') {
|
|
1626
|
+
asSpan = true;
|
|
1627
|
+
at += 2;
|
|
1628
|
+
}
|
|
1629
|
+
else if (word(tokens[at]) === 'dentro' && word(tokens[at + 1]) === 'de') {
|
|
1630
|
+
at += 2;
|
|
1631
|
+
}
|
|
1632
|
+
else if (word(tokens[at]) === 'en') {
|
|
1633
|
+
at += 1;
|
|
1634
|
+
}
|
|
1635
|
+
const n = readWordNum(tokens, at);
|
|
1636
|
+
if (!n)
|
|
1637
|
+
return undefined;
|
|
1638
|
+
at += n.consumed;
|
|
1639
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
1640
|
+
if (!unit)
|
|
1641
|
+
return undefined;
|
|
1642
|
+
at += 1;
|
|
1643
|
+
let sign;
|
|
1644
|
+
const cw = word(tokens[at]);
|
|
1645
|
+
if (cw === 'después' || cw === 'despues' || cw === 'posteriores' || cw === 'desde') {
|
|
1646
|
+
sign = 1;
|
|
1647
|
+
at += 1;
|
|
1648
|
+
}
|
|
1649
|
+
else if (cw === 'antes') {
|
|
1650
|
+
sign = -1;
|
|
1651
|
+
at += 1;
|
|
1652
|
+
}
|
|
1653
|
+
else if (cw === 'a' && word(tokens[at + 1]) === 'partir') {
|
|
1654
|
+
sign = 1;
|
|
1655
|
+
at += 2;
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
return undefined;
|
|
1659
|
+
}
|
|
1660
|
+
const subDay = unit === 'hour' || unit === 'minute' || unit === 'second';
|
|
1661
|
+
const baseStart = at;
|
|
1662
|
+
if (word(tokens[at]) === 'de' || word(tokens[at]) === 'del')
|
|
1663
|
+
at += 1;
|
|
1664
|
+
if (word(tokens[at]) === 'el')
|
|
1665
|
+
at += 1;
|
|
1666
|
+
// "desde el año 2011"
|
|
1667
|
+
if ((word(tokens[at]) === 'año' || word(tokens[at]) === 'ano') && yearAt(tokens, at + 1) !== undefined)
|
|
1668
|
+
at += 1;
|
|
1669
|
+
let base;
|
|
1670
|
+
let consumed = 0;
|
|
1671
|
+
const bw = word(tokens[at]);
|
|
1672
|
+
if (bw === 'ahora') {
|
|
1673
|
+
base = NOW;
|
|
1674
|
+
consumed = 1;
|
|
1675
|
+
}
|
|
1676
|
+
else if (bw !== undefined && ES_DEICTIC[bw] !== undefined) {
|
|
1677
|
+
base = snapOffset(ES_DEICTIC[bw], 'day');
|
|
1678
|
+
consumed = 1;
|
|
1679
|
+
}
|
|
1680
|
+
else if (WEEKDAYS[bw ?? '']) {
|
|
1681
|
+
base = { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: WEEKDAYS[bw ?? ''] } };
|
|
1682
|
+
consumed = 1;
|
|
1683
|
+
}
|
|
1684
|
+
else {
|
|
1685
|
+
const baseYear = yearAt(tokens, at);
|
|
1686
|
+
if (baseYear !== undefined && MONTHS[word(tokens[at + 1]) ?? ''] === undefined) {
|
|
1687
|
+
base = { op: 'literal', date: { year: baseYear } };
|
|
1688
|
+
consumed = 1;
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
const inner = esHoliday(tokens, at, ctx) ?? esDayMonth(tokens, at, ctx) ?? esNumdate(tokens, at, ctx);
|
|
1692
|
+
if (inner && inner.role === 'date') {
|
|
1693
|
+
base = inner.expr;
|
|
1694
|
+
consumed = inner.consumed;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
if (!base) {
|
|
1699
|
+
// Marker-less "30 minutos después" → offset from now.
|
|
1700
|
+
if ((cw === 'después' || cw === 'despues') && baseStart === at) {
|
|
1701
|
+
const shifted = { op: 'offset', base: NOW, amount: sign * n.value, unit };
|
|
1702
|
+
return {
|
|
1703
|
+
expr: subDay ? shifted : { op: 'snap', base: shifted, unit: 'day' },
|
|
1704
|
+
consumed: baseStart - i,
|
|
1705
|
+
confidence: 0.9,
|
|
1706
|
+
role: 'datetime',
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
return undefined;
|
|
1710
|
+
}
|
|
1711
|
+
if (asSpan) {
|
|
1712
|
+
return {
|
|
1713
|
+
expr: { op: 'span', anchor: base, amount: { [`${unit}s`]: sign * n.value } },
|
|
1714
|
+
consumed: at + consumed - i,
|
|
1715
|
+
confidence: 1,
|
|
1716
|
+
role: 'date',
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const shifted = { op: 'offset', base, amount: sign * n.value, unit };
|
|
1720
|
+
return {
|
|
1721
|
+
expr: subDay || bw === 'ahora' && subDay ? shifted : subDay ? shifted : { op: 'snap', base: shifted, unit: 'day' },
|
|
1722
|
+
consumed: at + consumed - i,
|
|
1723
|
+
confidence: 1,
|
|
1724
|
+
role: 'date',
|
|
1725
|
+
};
|
|
1726
|
+
};
|
|
1727
|
+
/* ------------------------------------------------------------------ */
|
|
1728
|
+
/* Holidays */
|
|
1729
|
+
/* ------------------------------------------------------------------ */
|
|
1730
|
+
const HOLIDAYS = [
|
|
1731
|
+
{ words: ['navidad'], name: 'christmas' },
|
|
1732
|
+
{ words: ['navidades'], name: 'christmas' },
|
|
1733
|
+
{ words: ['año', 'nuevo'], name: 'new-year' },
|
|
1734
|
+
{ words: ['ano', 'nuevo'], name: 'new-year' },
|
|
1735
|
+
{ words: ['viernes', 'negro'], name: 'black-friday' },
|
|
1736
|
+
{ words: ['día', 'de', 'la', 'tierra'], name: 'earth-day' },
|
|
1737
|
+
{ words: ['dia', 'de', 'la', 'tierra'], name: 'earth-day' },
|
|
1738
|
+
{ words: ['día', 'de', 'san', 'patricio'], name: 'st-patricks' },
|
|
1739
|
+
{ words: ['dia', 'de', 'san', 'patricio'], name: 'st-patricks' },
|
|
1740
|
+
{ words: ['san', 'patricio'], name: 'st-patricks' },
|
|
1741
|
+
{ words: ['semana', 'santa'], name: 'easter' },
|
|
1742
|
+
{ words: ['pascua'], name: 'easter' },
|
|
1743
|
+
{ words: ['pascuas'], name: 'easter' },
|
|
1744
|
+
{ words: ['día', 'internacional', 'de', 'los', 'trabajadores'], name: 'workers-day' },
|
|
1745
|
+
{ words: ['dia', 'internacional', 'de', 'los', 'trabajadores'], name: 'workers-day' },
|
|
1746
|
+
{ words: ['día', 'del', 'trabajador'], name: 'workers-day' },
|
|
1747
|
+
{ words: ['dia', 'del', 'trabajador'], name: 'workers-day' },
|
|
1748
|
+
{ words: ['día', 'del', 'trabajo'], name: 'workers-day' },
|
|
1749
|
+
{ words: ['dia', 'del', 'trabajo'], name: 'workers-day' },
|
|
1750
|
+
{ words: ['día', 'de', 'la', 'independencia'], name: 'independence-day' },
|
|
1751
|
+
{ words: ['día', 'de', 'independencia'], name: 'independence-day' },
|
|
1752
|
+
{ words: ['dia', 'de', 'la', 'independencia'], name: 'independence-day' },
|
|
1753
|
+
{ words: ['dia', 'de', 'independencia'], name: 'independence-day' },
|
|
1754
|
+
];
|
|
1755
|
+
/** "Navidad", "la semana santa 2018", "día de independencia de este año". */
|
|
1756
|
+
const esHoliday = (tokens, i) => {
|
|
1757
|
+
let at = i;
|
|
1758
|
+
let dir;
|
|
1759
|
+
const w0 = word(tokens[at]);
|
|
1760
|
+
if (w0 === 'el' || w0 === 'la')
|
|
1761
|
+
at += 1;
|
|
1762
|
+
const mod0 = word(tokens[at]);
|
|
1763
|
+
if (mod0 === 'próximo' || mod0 === 'próxima' || mod0 === 'proximo' || mod0 === 'proxima') {
|
|
1764
|
+
dir = 'next';
|
|
1765
|
+
at += 1;
|
|
1766
|
+
}
|
|
1767
|
+
else if (mod0 === 'pasado' || mod0 === 'pasada' || mod0 === 'último' || mod0 === 'última') {
|
|
1768
|
+
dir = 'prev';
|
|
1769
|
+
at += 1;
|
|
1770
|
+
}
|
|
1771
|
+
for (const h of HOLIDAYS) {
|
|
1772
|
+
const end0 = matchPhraseAt(tokens, at, h.words);
|
|
1773
|
+
if (end0 === undefined)
|
|
1774
|
+
continue;
|
|
1775
|
+
let end = end0;
|
|
1776
|
+
const post = word(tokens[end]);
|
|
1777
|
+
if (dir === undefined) {
|
|
1778
|
+
if (post === 'próximo' || post === 'próxima' || post === 'siguiente') {
|
|
1779
|
+
dir = 'next';
|
|
1780
|
+
end += 1;
|
|
1781
|
+
}
|
|
1782
|
+
else if (post === 'pasado' || post === 'pasada' || post === 'anterior') {
|
|
1783
|
+
dir = 'prev';
|
|
1784
|
+
end += 1;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const year = yearAt(tokens, end);
|
|
1788
|
+
const expr = { op: 'holiday', name: h.name };
|
|
1789
|
+
if (year !== undefined) {
|
|
1790
|
+
expr.year = year;
|
|
1791
|
+
end += 1;
|
|
1792
|
+
}
|
|
1793
|
+
if (dir !== undefined)
|
|
1794
|
+
expr.dir = dir;
|
|
1795
|
+
// "de este año" → constrain to the current year.
|
|
1796
|
+
if (word(tokens[end]) === 'de' && word(tokens[end + 1]) === 'este' &&
|
|
1797
|
+
(word(tokens[end + 2]) === 'año' || word(tokens[end + 2]) === 'ano')) {
|
|
1798
|
+
return {
|
|
1799
|
+
expr: { op: 'intersect', parts: [snapNow('year'), expr] },
|
|
1800
|
+
consumed: end + 3 - i,
|
|
1801
|
+
confidence: 1,
|
|
1802
|
+
role: 'date',
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
return { expr, consumed: end - i, confidence: 1, role: 'date' };
|
|
1806
|
+
}
|
|
1807
|
+
return undefined;
|
|
1808
|
+
};
|
|
1809
|
+
/* ------------------------------------------------------------------ */
|
|
1810
|
+
/* Decades / centuries / years */
|
|
1811
|
+
/* ------------------------------------------------------------------ */
|
|
1812
|
+
/** "la década de 1990", "los años 90" (dual is inexpressible; single decade). */
|
|
1813
|
+
const esDecade = (tokens, i) => {
|
|
1814
|
+
let at = i;
|
|
1815
|
+
if (word(tokens[at]) === 'la')
|
|
1816
|
+
at += 1;
|
|
1817
|
+
const w = word(tokens[at]);
|
|
1818
|
+
if (w !== 'década' && w !== 'decada' && w !== 'decenio')
|
|
1819
|
+
return undefined;
|
|
1820
|
+
at += 1;
|
|
1821
|
+
if (word(tokens[at]) !== 'de')
|
|
1822
|
+
return undefined;
|
|
1823
|
+
at += 1;
|
|
1824
|
+
if (word(tokens[at]) === 'los')
|
|
1825
|
+
at += 1;
|
|
1826
|
+
const t = tokens[at];
|
|
1827
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value % 10 === 0) {
|
|
1828
|
+
return {
|
|
1829
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { year: t.value } }, amount: { years: 10 } },
|
|
1830
|
+
consumed: at + 1 - i,
|
|
1831
|
+
confidence: 1,
|
|
1832
|
+
role: 'date',
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
return undefined;
|
|
1836
|
+
};
|
|
1837
|
+
const ROMAN = { i: 1, v: 5, x: 10 };
|
|
1838
|
+
function romanValue(w) {
|
|
1839
|
+
if (!/^[ivx]+$/.test(w))
|
|
1840
|
+
return undefined;
|
|
1841
|
+
let total = 0;
|
|
1842
|
+
for (let k = 0; k < w.length; k += 1) {
|
|
1843
|
+
const v = ROMAN[w[k]];
|
|
1844
|
+
const next = k + 1 < w.length ? ROMAN[w[k + 1]] : 0;
|
|
1845
|
+
total += v < next ? -v : v;
|
|
1846
|
+
}
|
|
1847
|
+
return total >= 1 && total <= 30 ? total : undefined;
|
|
1848
|
+
}
|
|
1849
|
+
/** "el siglo XV" → 1400–1500; "siglo XXI" → 2000–2100. */
|
|
1850
|
+
const esCentury = (tokens, i) => {
|
|
1851
|
+
let at = i;
|
|
1852
|
+
if (word(tokens[at]) === 'el')
|
|
1853
|
+
at += 1;
|
|
1854
|
+
if (word(tokens[at]) !== 'siglo')
|
|
1855
|
+
return undefined;
|
|
1856
|
+
at += 1;
|
|
1857
|
+
const w = word(tokens[at]);
|
|
1858
|
+
const n = w !== undefined ? romanValue(w) : undefined;
|
|
1859
|
+
if (n === undefined)
|
|
1860
|
+
return undefined;
|
|
1861
|
+
return {
|
|
1862
|
+
expr: {
|
|
1863
|
+
op: 'span',
|
|
1864
|
+
anchor: { op: 'literal', date: { year: (n - 1) * 100 } },
|
|
1865
|
+
amount: { years: 100 },
|
|
1866
|
+
},
|
|
1867
|
+
consumed: at + 1 - i,
|
|
1868
|
+
confidence: 1,
|
|
1869
|
+
role: 'date',
|
|
1870
|
+
};
|
|
1871
|
+
};
|
|
1872
|
+
/** "el año 2008", "el año calendario 2008", "año calendario 18", "este calendario año". */
|
|
1873
|
+
const esCalYear = (tokens, i) => {
|
|
1874
|
+
let at = i;
|
|
1875
|
+
if (word(tokens[at]) === 'el' || word(tokens[at]) === 'en' || word(tokens[at]) === 'este') {
|
|
1876
|
+
if (word(tokens[at]) === 'en' && word(tokens[at + 1]) === 'el')
|
|
1877
|
+
at += 1;
|
|
1878
|
+
at += 1;
|
|
1879
|
+
}
|
|
1880
|
+
// "calendario año" (calque word order).
|
|
1881
|
+
if (word(tokens[at]) === 'calendario' && (word(tokens[at + 1]) === 'año' || word(tokens[at + 1]) === 'ano')) {
|
|
1882
|
+
const y = yearAt(tokens, at + 2);
|
|
1883
|
+
if (y !== undefined) {
|
|
1884
|
+
return { expr: { op: 'literal', date: { year: y } }, consumed: at + 3 - i, confidence: 1, role: 'date' };
|
|
1885
|
+
}
|
|
1886
|
+
return { expr: snapNow('year'), consumed: at + 2 - i, confidence: 0.9, role: 'date' };
|
|
1887
|
+
}
|
|
1888
|
+
if (word(tokens[at]) !== 'año' && word(tokens[at]) !== 'ano')
|
|
1889
|
+
return undefined;
|
|
1890
|
+
at += 1;
|
|
1891
|
+
if (word(tokens[at]) === 'calendario') {
|
|
1892
|
+
at += 1;
|
|
1893
|
+
const t = tokens[at];
|
|
1894
|
+
if (t?.type === 'number' && !t.ordinal) {
|
|
1895
|
+
if (t.value >= 1000 && t.value <= 2999) {
|
|
1896
|
+
return { expr: { op: 'literal', date: { year: t.value } }, consumed: at + 1 - i, confidence: 1, role: 'date' };
|
|
1897
|
+
}
|
|
1898
|
+
if (t.value <= 99) {
|
|
1899
|
+
return {
|
|
1900
|
+
expr: { op: 'literal', date: { year: century(t.value) } },
|
|
1901
|
+
consumed: at + 1 - i,
|
|
1902
|
+
confidence: 0.95,
|
|
1903
|
+
role: 'date',
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return { expr: snapNow('year'), consumed: at - i, confidence: 0.9, role: 'date' };
|
|
1908
|
+
}
|
|
1909
|
+
const y = yearAt(tokens, at);
|
|
1910
|
+
if (y !== undefined) {
|
|
1911
|
+
return { expr: { op: 'literal', date: { year: y } }, consumed: at + 1 - i, confidence: 1, role: 'date' };
|
|
1912
|
+
}
|
|
1913
|
+
return undefined;
|
|
1914
|
+
};
|
|
1915
|
+
/** "en el año hasta la fecha" → year-to-date. */
|
|
1916
|
+
const esYearToDate = (tokens, i) => {
|
|
1917
|
+
let at = i;
|
|
1918
|
+
if (word(tokens[at]) === 'el')
|
|
1919
|
+
at += 1;
|
|
1920
|
+
if (word(tokens[at]) !== 'año' && word(tokens[at]) !== 'ano')
|
|
1921
|
+
return undefined;
|
|
1922
|
+
at += 1;
|
|
1923
|
+
if (word(tokens[at]) !== 'hasta' || word(tokens[at + 1]) !== 'la' || word(tokens[at + 2]) !== 'fecha') {
|
|
1924
|
+
return undefined;
|
|
1925
|
+
}
|
|
1926
|
+
return {
|
|
1927
|
+
expr: {
|
|
1928
|
+
op: 'between',
|
|
1929
|
+
start: snapNow('year'),
|
|
1930
|
+
end: { op: 'snap', base: snapNow('day'), unit: 'day', edge: 'start' },
|
|
1931
|
+
},
|
|
1932
|
+
consumed: at + 3 - i,
|
|
1933
|
+
confidence: 1,
|
|
1934
|
+
role: 'date',
|
|
1935
|
+
};
|
|
1936
|
+
};
|
|
1937
|
+
/** Bare year licensed by a preceding preposition, or standing alone. */
|
|
1938
|
+
const esBareYear = (tokens, i) => {
|
|
1939
|
+
const t = tokens[i];
|
|
1940
|
+
if (t?.type !== 'number' || t.ordinal || t.value < 1900 || t.value > 2159)
|
|
1941
|
+
return undefined;
|
|
1942
|
+
return { expr: { op: 'literal', date: { year: t.value } }, consumed: 1, confidence: 0.6, role: 'date' };
|
|
1943
|
+
};
|
|
1944
|
+
/** "el año anterior a la revolución" — Recognizers tags just "el año". */
|
|
1945
|
+
const esYearAnteriorA = (tokens, i) => {
|
|
1946
|
+
let at = i;
|
|
1947
|
+
if (word(tokens[at]) === 'el')
|
|
1948
|
+
at += 1;
|
|
1949
|
+
if (word(tokens[at]) !== 'año' && word(tokens[at]) !== 'ano')
|
|
1950
|
+
return undefined;
|
|
1951
|
+
if (word(tokens[at + 1]) !== 'anterior' || word(tokens[at + 2]) !== 'a')
|
|
1952
|
+
return undefined;
|
|
1953
|
+
let end = at + 3;
|
|
1954
|
+
if (word(tokens[end]) === 'la' || word(tokens[end]) === 'el')
|
|
1955
|
+
end += 1;
|
|
1956
|
+
const nw = word(tokens[end]);
|
|
1957
|
+
if (nw === undefined || MONTHS[nw] !== undefined || WEEKDAYS[nw] !== undefined || ES_UNITS[nw] !== undefined) {
|
|
1958
|
+
return undefined;
|
|
1959
|
+
}
|
|
1960
|
+
return { expr: snapNow('year'), consumed: end + 1 - i, confidence: 0.95, role: 'date' };
|
|
1961
|
+
};
|
|
1962
|
+
/* ------------------------------------------------------------------ */
|
|
1963
|
+
/* Bare units, rest-of, week-after-next */
|
|
1964
|
+
/* ------------------------------------------------------------------ */
|
|
1965
|
+
/** "del año", "de la semana", "en el año", "socio del mes" → current unit. */
|
|
1966
|
+
const esBareUnit = (tokens, i) => {
|
|
1967
|
+
let at = i;
|
|
1968
|
+
const w0 = word(tokens[at]);
|
|
1969
|
+
if (w0 === 'del')
|
|
1970
|
+
at += 1;
|
|
1971
|
+
else if ((w0 === 'de' || w0 === 'en' || w0 === 'por') && ['el', 'la'].includes(word(tokens[at + 1]) ?? ''))
|
|
1972
|
+
at += 2;
|
|
1973
|
+
else
|
|
1974
|
+
return undefined;
|
|
1975
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
1976
|
+
if (!unit || unit === 'hour' || unit === 'minute' || unit === 'second')
|
|
1977
|
+
return undefined;
|
|
1978
|
+
const next = word(tokens[at + 1]);
|
|
1979
|
+
if (next !== undefined &&
|
|
1980
|
+
(['de', 'del', 'que', 'después', 'despues', 'pasada', 'pasado', 'anterior', 'siguiente',
|
|
1981
|
+
'próxima', 'proxima', 'próximo', 'proximo', 'hasta', 'santa', 'calendario', 'y', 'nuevo'].includes(next) ||
|
|
1982
|
+
MONTHS[next] !== undefined)) {
|
|
1983
|
+
return undefined;
|
|
1984
|
+
}
|
|
1985
|
+
if (tokens[at + 1]?.type === 'number')
|
|
1986
|
+
return undefined;
|
|
1987
|
+
return { expr: snapNow(unit), consumed: at + 1 - i, confidence: 0.6, role: 'date' };
|
|
1988
|
+
};
|
|
1989
|
+
/** "el resto de la semana" → today through the end of Saturday. */
|
|
1990
|
+
const esRestOf = (tokens, i) => {
|
|
1991
|
+
let at = i;
|
|
1992
|
+
if (word(tokens[at]) === 'el')
|
|
1993
|
+
at += 1;
|
|
1994
|
+
if (word(tokens[at]) !== 'resto')
|
|
1995
|
+
return undefined;
|
|
1996
|
+
at += 1;
|
|
1997
|
+
const sw = word(tokens[at]);
|
|
1998
|
+
if (sw === 'de' || sw === 'del')
|
|
1999
|
+
at += 1;
|
|
2000
|
+
if (word(tokens[at]) === 'la' || word(tokens[at]) === 'el')
|
|
2001
|
+
at += 1;
|
|
2002
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
2003
|
+
if (!unit || unit === 'hour' || unit === 'minute' || unit === 'second' || unit === 'day')
|
|
2004
|
+
return undefined;
|
|
2005
|
+
at += 1;
|
|
2006
|
+
const end = unit === 'week'
|
|
2007
|
+
? { op: 'offset', base: { op: 'snap', base: snapNow('week'), unit: 'week', edge: 'end' }, amount: -1, unit: 'day' }
|
|
2008
|
+
: { op: 'snap', base: snapNow(unit), unit, edge: 'end' };
|
|
2009
|
+
return {
|
|
2010
|
+
expr: { op: 'between', start: snapNow('day'), end },
|
|
2011
|
+
consumed: at - i,
|
|
2012
|
+
confidence: 1,
|
|
2013
|
+
role: 'date',
|
|
2014
|
+
};
|
|
2015
|
+
};
|
|
2016
|
+
/** "la semana después de la próxima" → the week after next. */
|
|
2017
|
+
const esWeekAfterNext = (tokens, i) => {
|
|
2018
|
+
let at = i;
|
|
2019
|
+
if (word(tokens[at]) === 'la')
|
|
2020
|
+
at += 1;
|
|
2021
|
+
const unit = ES_UNITS[word(tokens[at]) ?? ''];
|
|
2022
|
+
if (!unit)
|
|
2023
|
+
return undefined;
|
|
2024
|
+
const end = matchPhraseAt(tokens, at + 1, ['después', 'de', 'la', 'próxima']) ??
|
|
2025
|
+
matchPhraseAt(tokens, at + 1, ['despues', 'de', 'la', 'proxima']) ??
|
|
2026
|
+
matchPhraseAt(tokens, at + 1, ['después', 'del', 'próximo']) ??
|
|
2027
|
+
matchPhraseAt(tokens, at + 1, ['despues', 'del', 'proximo']);
|
|
2028
|
+
if (end === undefined)
|
|
2029
|
+
return undefined;
|
|
2030
|
+
return { expr: snapOffset(2, unit), consumed: end - i, confidence: 1, role: 'date' };
|
|
2031
|
+
};
|
|
2032
|
+
/** "este próximo viernes" → the strictly-next Friday (n bypasses the policy). */
|
|
2033
|
+
const esEsteProximo = (tokens, i) => {
|
|
2034
|
+
let at = i;
|
|
2035
|
+
if (word(tokens[at]) === 'para')
|
|
2036
|
+
at += 1;
|
|
2037
|
+
if (word(tokens[at]) !== 'este' && word(tokens[at]) !== 'esta')
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const mw = word(tokens[at + 1]);
|
|
2040
|
+
if (mw !== 'próximo' && mw !== 'proximo' && mw !== 'próxima' && mw !== 'proxima')
|
|
2041
|
+
return undefined;
|
|
2042
|
+
const wd = WEEKDAYS[word(tokens[at + 2]) ?? ''];
|
|
2043
|
+
if (!wd)
|
|
2044
|
+
return undefined;
|
|
2045
|
+
return {
|
|
2046
|
+
expr: { op: 'seek', base: NOW, dir: 'next', target: { kind: 'weekday', weekday: wd }, n: 1 },
|
|
2047
|
+
consumed: at + 3 - i,
|
|
2048
|
+
confidence: 1,
|
|
2049
|
+
role: 'date',
|
|
2050
|
+
};
|
|
2051
|
+
};
|
|
2052
|
+
/** "el miércoles [por la tarde] la semana que viene" → that weekday next week. */
|
|
2053
|
+
const esWeekdayOfNextWeek = (tokens, i) => {
|
|
2054
|
+
let at = i;
|
|
2055
|
+
if (word(tokens[at]) === 'el')
|
|
2056
|
+
at += 1;
|
|
2057
|
+
const wd = WEEKDAYS[word(tokens[at]) ?? ''];
|
|
2058
|
+
if (!wd)
|
|
2059
|
+
return undefined;
|
|
2060
|
+
at += 1;
|
|
2061
|
+
let period;
|
|
2062
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
2063
|
+
if (ps && ps.period !== 'madrugada') {
|
|
2064
|
+
period = ps.period;
|
|
2065
|
+
at += ps.consumed;
|
|
2066
|
+
}
|
|
2067
|
+
const end = matchPhraseAt(tokens, at, ['la', 'semana', 'que', 'viene']) ??
|
|
2068
|
+
matchPhraseAt(tokens, at, ['de', 'la', 'semana', 'que', 'viene']) ??
|
|
2069
|
+
matchPhraseAt(tokens, at, ['la', 'próxima', 'semana']) ??
|
|
2070
|
+
matchPhraseAt(tokens, at, ['la', 'proxima', 'semana']);
|
|
2071
|
+
if (end === undefined)
|
|
2072
|
+
return undefined;
|
|
2073
|
+
const seek = {
|
|
2074
|
+
op: 'seek', base: snapOffset(1, 'week'), dir: 'next', target: { kind: 'weekday', weekday: wd }, n: 1,
|
|
2075
|
+
};
|
|
2076
|
+
const expr = period === undefined
|
|
2077
|
+
? seek
|
|
2078
|
+
: { op: 'intersect', parts: [seek, { op: 'literal', dayPeriod: period }] };
|
|
2079
|
+
return { expr, consumed: end - i, confidence: 1, role: period === undefined ? 'date' : 'datetime' };
|
|
2080
|
+
};
|
|
2081
|
+
/**
|
|
2082
|
+
* "el primer lunes de próximo mes", "el primer lunes por la tarde de próximo
|
|
2083
|
+
* mes", "el primer lunes de 13:00 a 15:00 de próximo mes".
|
|
2084
|
+
*/
|
|
2085
|
+
const esNthWeekdayOfMonth = (tokens, i, ctx) => {
|
|
2086
|
+
let at = i;
|
|
2087
|
+
if (word(tokens[at]) === 'para')
|
|
2088
|
+
at += 1;
|
|
2089
|
+
if (word(tokens[at]) === 'el')
|
|
2090
|
+
at += 1;
|
|
2091
|
+
const nth = ORDINAL_WORDS[word(tokens[at]) ?? ''] ?? ordinalWord(word(tokens[at]));
|
|
2092
|
+
if (nth === undefined || nth > 5)
|
|
2093
|
+
return undefined;
|
|
2094
|
+
at += 1;
|
|
2095
|
+
const wd = WEEKDAYS[word(tokens[at]) ?? ''];
|
|
2096
|
+
if (!wd)
|
|
2097
|
+
return undefined;
|
|
2098
|
+
at += 1;
|
|
2099
|
+
let period;
|
|
2100
|
+
let range;
|
|
2101
|
+
let progressed = true;
|
|
2102
|
+
while (progressed) {
|
|
2103
|
+
progressed = false;
|
|
2104
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
2105
|
+
if (ps && ps.period !== 'madrugada' && period === undefined) {
|
|
2106
|
+
period = ps.period;
|
|
2107
|
+
at += ps.consumed;
|
|
2108
|
+
progressed = true;
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
if (range === undefined) {
|
|
2112
|
+
const r = esRange(tokens, at, ctx);
|
|
2113
|
+
if (r && r.role === 'time' && r.expr.op === 'between') {
|
|
2114
|
+
range = r.expr;
|
|
2115
|
+
at += r.consumed;
|
|
2116
|
+
progressed = true;
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
// Scope: "(de|del) [el] (próximo|este) mes" or a month name.
|
|
2122
|
+
const sw = word(tokens[at]);
|
|
2123
|
+
if (sw !== 'de' && sw !== 'del')
|
|
2124
|
+
return undefined;
|
|
2125
|
+
let k = at + 1;
|
|
2126
|
+
if (word(tokens[k]) === 'el')
|
|
2127
|
+
k += 1;
|
|
2128
|
+
let monthExpr;
|
|
2129
|
+
const mw = word(tokens[k]);
|
|
2130
|
+
if (MONTHS[mw ?? ''] !== undefined) {
|
|
2131
|
+
monthExpr = { op: 'literal', date: { month: MONTHS[mw ?? ''] } };
|
|
2132
|
+
k += 1;
|
|
2133
|
+
}
|
|
2134
|
+
else {
|
|
2135
|
+
let delta;
|
|
2136
|
+
if (mw === 'próximo' || mw === 'proximo' || mw === 'siguiente') {
|
|
2137
|
+
delta = 1;
|
|
2138
|
+
k += 1;
|
|
2139
|
+
}
|
|
2140
|
+
else if (mw === 'este') {
|
|
2141
|
+
delta = 0;
|
|
2142
|
+
k += 1;
|
|
2143
|
+
}
|
|
2144
|
+
else if (mw === 'pasado' || mw === 'último' || mw === 'ultimo') {
|
|
2145
|
+
delta = -1;
|
|
2146
|
+
k += 1;
|
|
2147
|
+
}
|
|
2148
|
+
if (word(tokens[k]) !== 'mes' || delta === undefined)
|
|
2149
|
+
return undefined;
|
|
2150
|
+
monthExpr = delta === 0 ? snapNow('month') : snapOffset(delta, 'month');
|
|
2151
|
+
k += 1;
|
|
2152
|
+
}
|
|
2153
|
+
const seek = { op: 'seek', base: monthExpr, dir: 'next', target: { kind: 'weekday', weekday: wd }, n: nth };
|
|
2154
|
+
let expr = seek;
|
|
2155
|
+
if (range !== undefined)
|
|
2156
|
+
expr = { op: 'intersect', parts: [seek, range] };
|
|
2157
|
+
else if (period !== undefined)
|
|
2158
|
+
expr = { op: 'intersect', parts: [seek, { op: 'literal', dayPeriod: period }] };
|
|
2159
|
+
return {
|
|
2160
|
+
expr,
|
|
2161
|
+
consumed: k - i,
|
|
2162
|
+
confidence: 1,
|
|
2163
|
+
role: period === undefined && range === undefined ? 'date' : 'datetime',
|
|
2164
|
+
};
|
|
2165
|
+
};
|
|
2166
|
+
/* ------------------------------------------------------------------ */
|
|
2167
|
+
/* Weeks */
|
|
2168
|
+
/* ------------------------------------------------------------------ */
|
|
2169
|
+
/** The week containing `scope`'s 4th day, plus n−1 weeks (Recognizers style). */
|
|
2170
|
+
function nthWeekExpr(scope, scopeUnit, nth) {
|
|
2171
|
+
if (nth === 'last') {
|
|
2172
|
+
return {
|
|
2173
|
+
op: 'snap',
|
|
2174
|
+
base: {
|
|
2175
|
+
op: 'offset',
|
|
2176
|
+
base: { op: 'snap', base: scope, unit: scopeUnit, edge: 'end' },
|
|
2177
|
+
amount: -4,
|
|
2178
|
+
unit: 'day',
|
|
2179
|
+
},
|
|
2180
|
+
unit: 'week',
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
const first = {
|
|
2184
|
+
op: 'snap',
|
|
2185
|
+
base: {
|
|
2186
|
+
op: 'offset',
|
|
2187
|
+
base: { op: 'snap', base: scope, unit: scopeUnit, edge: 'start' },
|
|
2188
|
+
amount: 3,
|
|
2189
|
+
unit: 'day',
|
|
2190
|
+
},
|
|
2191
|
+
unit: 'week',
|
|
2192
|
+
};
|
|
2193
|
+
return nth === 1 ? first : { op: 'offset', base: first, amount: nth - 1, unit: 'week' };
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* "la semana del 15", "la semana de 10 de abril", "la semana que inicia el 4
|
|
2197
|
+
* de febrero", "la primera semana de 2021", "la tercera semana de enero",
|
|
2198
|
+
* "semana 31", "la semana 27 del año pasado", "semana 3 de dos mil veintisiete".
|
|
2199
|
+
*/
|
|
2200
|
+
const esWeekOf = (tokens, i, ctx) => {
|
|
2201
|
+
let at = i;
|
|
2202
|
+
if (word(tokens[at]) === 'la')
|
|
2203
|
+
at += 1;
|
|
2204
|
+
let nth;
|
|
2205
|
+
const nw = word(tokens[at]);
|
|
2206
|
+
if (nw !== undefined && ORDINAL_WORDS[nw] !== undefined) {
|
|
2207
|
+
nth = ORDINAL_WORDS[nw];
|
|
2208
|
+
at += 1;
|
|
2209
|
+
}
|
|
2210
|
+
else if (nw === 'última' || nw === 'ultima') {
|
|
2211
|
+
nth = 'last';
|
|
2212
|
+
at += 1;
|
|
2213
|
+
}
|
|
2214
|
+
else {
|
|
2215
|
+
const ow = ordinalWord(nw);
|
|
2216
|
+
if (ow !== undefined && ow <= 5) {
|
|
2217
|
+
nth = ow;
|
|
2218
|
+
at += 1;
|
|
2219
|
+
}
|
|
2220
|
+
else if (tokens[at]?.type === 'number' && !(tokens[at].ordinal)) {
|
|
2221
|
+
const v = tokens[at].value;
|
|
2222
|
+
// "3ª semana" arrives as a plain number (º/ª are outside the token class).
|
|
2223
|
+
if (v >= 1 && v <= 5 && word(tokens[at + 1]) === 'semana') {
|
|
2224
|
+
nth = v;
|
|
2225
|
+
at += 1;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (word(tokens[at]) !== 'semana')
|
|
2230
|
+
return undefined;
|
|
2231
|
+
at += 1;
|
|
2232
|
+
// "la semana que inicia/comienza/va del 4 de febrero" / "a partir del 4 …".
|
|
2233
|
+
if (word(tokens[at]) === 'que') {
|
|
2234
|
+
let k = at + 1;
|
|
2235
|
+
const vw = word(tokens[k]);
|
|
2236
|
+
if (!['inicia', 'comienza', 'empieza', 'va', 'comience', 'inicie'].includes(vw ?? ''))
|
|
2237
|
+
return undefined;
|
|
2238
|
+
k += 1;
|
|
2239
|
+
if (['el', 'del', 'de'].includes(word(tokens[k]) ?? ''))
|
|
2240
|
+
k += 1;
|
|
2241
|
+
const inner = esDayMonth(tokens, k, ctx) ?? esNumdate(tokens, k, ctx);
|
|
2242
|
+
if (inner) {
|
|
2243
|
+
return {
|
|
2244
|
+
expr: { op: 'snap', base: inner.expr, unit: 'week' },
|
|
2245
|
+
consumed: k + inner.consumed - i,
|
|
2246
|
+
confidence: 1,
|
|
2247
|
+
role: 'date',
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
return undefined;
|
|
2251
|
+
}
|
|
2252
|
+
if (word(tokens[at]) === 'a' && word(tokens[at + 1]) === 'partir') {
|
|
2253
|
+
let k = at + 2;
|
|
2254
|
+
if (['del', 'de'].includes(word(tokens[k]) ?? ''))
|
|
2255
|
+
k += 1;
|
|
2256
|
+
if (word(tokens[k]) === 'el')
|
|
2257
|
+
k += 1;
|
|
2258
|
+
const inner = esDayMonth(tokens, k, ctx) ?? esNumdate(tokens, k, ctx);
|
|
2259
|
+
if (inner) {
|
|
2260
|
+
return {
|
|
2261
|
+
expr: { op: 'snap', base: inner.expr, unit: 'week' },
|
|
2262
|
+
consumed: k + inner.consumed - i,
|
|
2263
|
+
confidence: 1,
|
|
2264
|
+
role: 'date',
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
return undefined;
|
|
2268
|
+
}
|
|
2269
|
+
// "semana 31 [de <year> | del año pasado]" — ISO week number.
|
|
2270
|
+
const numTok = tokens[at];
|
|
2271
|
+
if (nth === undefined && numTok?.type === 'number' && !numTok.ordinal && numTok.value >= 1 && numTok.value <= 53) {
|
|
2272
|
+
const n = numTok.value;
|
|
2273
|
+
let k = at + 1;
|
|
2274
|
+
let yearScope;
|
|
2275
|
+
let jan4;
|
|
2276
|
+
if (['de', 'del'].includes(word(tokens[k]) ?? '')) {
|
|
2277
|
+
let k2 = k + 1;
|
|
2278
|
+
if (word(tokens[k2]) === 'el')
|
|
2279
|
+
k2 += 1;
|
|
2280
|
+
const y = yearAt(tokens, k2) ?? (() => {
|
|
2281
|
+
const wn = readWordNum(tokens, k2);
|
|
2282
|
+
return wn && wn.value >= 1000 && wn.value <= 2999 ? wn.value : undefined;
|
|
2283
|
+
})();
|
|
2284
|
+
if (y !== undefined) {
|
|
2285
|
+
const wn = readWordNum(tokens, k2);
|
|
2286
|
+
jan4 = { op: 'literal', date: { year: y, month: 1, day: 4 } };
|
|
2287
|
+
k = k2 + (yearAt(tokens, k2) !== undefined ? 1 : wn.consumed);
|
|
2288
|
+
}
|
|
2289
|
+
else {
|
|
2290
|
+
let delta = 0;
|
|
2291
|
+
let k3 = k2;
|
|
2292
|
+
const mw = word(tokens[k3]);
|
|
2293
|
+
if (mw === 'año' || mw === 'ano') {
|
|
2294
|
+
k3 += 1;
|
|
2295
|
+
const pm = word(tokens[k3]);
|
|
2296
|
+
if (pm === 'pasado' || pm === 'anterior') {
|
|
2297
|
+
delta = -1;
|
|
2298
|
+
k3 += 1;
|
|
2299
|
+
}
|
|
2300
|
+
else if (pm === 'próximo' || pm === 'proximo' || pm === 'siguiente') {
|
|
2301
|
+
delta = 1;
|
|
2302
|
+
k3 += 1;
|
|
2303
|
+
}
|
|
2304
|
+
yearScope = delta === 0 ? snapNow('year') : snapOffset(delta, 'year');
|
|
2305
|
+
k = k3;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (jan4 === undefined) {
|
|
2310
|
+
const base = yearScope ?? snapNow('year');
|
|
2311
|
+
jan4 = { op: 'intersect', parts: [base, { op: 'literal', date: { month: 1, day: 4 } }] };
|
|
2312
|
+
}
|
|
2313
|
+
const week1 = { op: 'snap', base: jan4, unit: 'week' };
|
|
2314
|
+
const expr = n === 1 ? week1 : { op: 'offset', base: week1, amount: n - 1, unit: 'week' };
|
|
2315
|
+
return { expr, consumed: k - i, confidence: 0.95, role: 'date' };
|
|
2316
|
+
}
|
|
2317
|
+
// Scope after "de/del": a date, month [year], or year.
|
|
2318
|
+
const dw = word(tokens[at]);
|
|
2319
|
+
if (dw === 'de' || dw === 'del') {
|
|
2320
|
+
let k = at + 1;
|
|
2321
|
+
if (word(tokens[k]) === 'el')
|
|
2322
|
+
k += 1;
|
|
2323
|
+
const month = MONTHS[word(tokens[k]) ?? ''];
|
|
2324
|
+
if (month !== undefined && nth !== undefined) {
|
|
2325
|
+
const y = yearAt(tokens, k + 1);
|
|
2326
|
+
const scope = { op: 'literal', date: { month, ...(y !== undefined ? { year: y } : {}) } };
|
|
2327
|
+
return {
|
|
2328
|
+
expr: nthWeekExpr(scope, 'month', nth),
|
|
2329
|
+
consumed: (y !== undefined ? k + 2 : k + 1) - i,
|
|
2330
|
+
confidence: 1,
|
|
2331
|
+
role: 'date',
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
const y0 = yearAt(tokens, k);
|
|
2335
|
+
if (y0 !== undefined && nth !== undefined) {
|
|
2336
|
+
return {
|
|
2337
|
+
expr: nthWeekExpr({ op: 'literal', date: { year: y0 } }, 'year', nth),
|
|
2338
|
+
consumed: k + 1 - i,
|
|
2339
|
+
confidence: 1,
|
|
2340
|
+
role: 'date',
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
if (nth === undefined) {
|
|
2344
|
+
// "la semana del 15" / "la semana de 10 de abril" → the containing week.
|
|
2345
|
+
const inner = esDayMonth(tokens, k, ctx) ?? esNumdate(tokens, k, ctx);
|
|
2346
|
+
if (inner) {
|
|
2347
|
+
return {
|
|
2348
|
+
expr: { op: 'snap', base: inner.expr, unit: 'week' },
|
|
2349
|
+
consumed: k + inner.consumed - i,
|
|
2350
|
+
confidence: 1,
|
|
2351
|
+
role: 'date',
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
const t0 = tokens[k];
|
|
2355
|
+
if (t0?.type === 'number' && !t0.ordinal && t0.value >= 1 && t0.value <= 31) {
|
|
2356
|
+
return {
|
|
2357
|
+
expr: { op: 'snap', base: { op: 'literal', date: { day: t0.value } }, unit: 'week' },
|
|
2358
|
+
consumed: k + 1 - i,
|
|
2359
|
+
confidence: 0.9,
|
|
2360
|
+
role: 'date',
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return undefined;
|
|
2365
|
+
}
|
|
2366
|
+
return undefined;
|
|
2367
|
+
};
|
|
2368
|
+
/** Scope after "principios/finales de": unit, deictic day, weekday, month, year, date, period. */
|
|
2369
|
+
function readEdgeScope(tokens, i, ctx) {
|
|
2370
|
+
let at = i;
|
|
2371
|
+
const w0 = word(tokens[at]);
|
|
2372
|
+
if (w0 === 'la' || w0 === 'el' || w0 === 'este' || w0 === 'esta')
|
|
2373
|
+
at += 1;
|
|
2374
|
+
const w = word(tokens[at]);
|
|
2375
|
+
// "esta mañana" / "la mañana" = the morning period, not tomorrow.
|
|
2376
|
+
if ((w0 === 'esta' || w0 === 'la') && (w === 'mañana' || w === 'manana')) {
|
|
2377
|
+
return {
|
|
2378
|
+
expr: { op: 'intersect', parts: [snapNow('day'), { op: 'literal', dayPeriod: 'morning' }] },
|
|
2379
|
+
kind: 'period',
|
|
2380
|
+
consumed: at + 1 - i,
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
const deictic = ES_DEICTIC[w ?? ''];
|
|
2384
|
+
if (deictic !== undefined) {
|
|
2385
|
+
return { expr: snapOffset(deictic, 'day'), kind: 'day', consumed: at + 1 - i };
|
|
2386
|
+
}
|
|
2387
|
+
const wd = WEEKDAYS[w ?? ''];
|
|
2388
|
+
if (wd) {
|
|
2389
|
+
return {
|
|
2390
|
+
expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd } },
|
|
2391
|
+
kind: 'day',
|
|
2392
|
+
consumed: at + 1 - i,
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
const year = yearAt(tokens, at);
|
|
2396
|
+
if (year !== undefined) {
|
|
2397
|
+
return { expr: { op: 'literal', date: { year } }, kind: 'year', year, consumed: at + 1 - i };
|
|
2398
|
+
}
|
|
2399
|
+
const month = MONTHS[w ?? ''];
|
|
2400
|
+
if (month !== undefined) {
|
|
2401
|
+
// A full date ("7 de enero") is read below via esDayMonth; bare month here.
|
|
2402
|
+
return { expr: { op: 'literal', date: { month } }, kind: 'month', month, consumed: at + 1 - i };
|
|
2403
|
+
}
|
|
2404
|
+
const pw = readPeriodWord(tokens, at);
|
|
2405
|
+
if (pw !== undefined) {
|
|
2406
|
+
return {
|
|
2407
|
+
expr: { op: 'intersect', parts: [snapNow('day'), { op: 'literal', dayPeriod: pw.period }] },
|
|
2408
|
+
kind: 'period',
|
|
2409
|
+
consumed: at + pw.consumed - i,
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
const dm = esDayMonth(tokens, at, ctx);
|
|
2413
|
+
if (dm && dm.expr.op === 'literal' && dm.expr.date?.day !== undefined) {
|
|
2414
|
+
return { expr: dm.expr, kind: 'date', consumed: at + dm.consumed - i };
|
|
2415
|
+
}
|
|
2416
|
+
const unit = ES_UNITS[w ?? ''];
|
|
2417
|
+
if (unit !== undefined && unit !== 'hour' && unit !== 'minute' && unit !== 'second') {
|
|
2418
|
+
let delta = 0;
|
|
2419
|
+
let consumed = at + 1 - i;
|
|
2420
|
+
const mod = word(tokens[at + 1]);
|
|
2421
|
+
if (mod !== undefined && ES_POST_MODS[mod] !== undefined) {
|
|
2422
|
+
delta = ES_POST_MODS[mod];
|
|
2423
|
+
consumed += 1;
|
|
2424
|
+
}
|
|
2425
|
+
else if (matchPhraseAt(tokens, at + 1, ['que', 'viene']) !== undefined) {
|
|
2426
|
+
delta = 1;
|
|
2427
|
+
consumed += 2;
|
|
2428
|
+
}
|
|
2429
|
+
return {
|
|
2430
|
+
expr: delta === 0 ? snapNow(unit) : snapOffset(delta, unit),
|
|
2431
|
+
kind: 'unit',
|
|
2432
|
+
unit,
|
|
2433
|
+
consumed,
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
return undefined;
|
|
2437
|
+
}
|
|
2438
|
+
function edgeExpr(mode, scope) {
|
|
2439
|
+
if (scope.kind === 'day' || scope.kind === 'date') {
|
|
2440
|
+
if (mode === 'end')
|
|
2441
|
+
return { op: 'snap', base: scope.expr, unit: 'day', edge: 'end' };
|
|
2442
|
+
return { ...scope.expr, mod: mode };
|
|
2443
|
+
}
|
|
2444
|
+
if (scope.kind === 'year' && scope.year !== undefined) {
|
|
2445
|
+
const startMonth = mode === 'start' ? 1 : mode === 'mid' ? 5 : 9;
|
|
2446
|
+
return {
|
|
2447
|
+
op: 'span',
|
|
2448
|
+
anchor: { op: 'literal', date: { year: scope.year, month: startMonth } },
|
|
2449
|
+
amount: { months: 4 },
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
// Fixed half-splits of the current week/month/year (not reference-clamped).
|
|
2453
|
+
if (scope.kind === 'unit' && scope.unit !== undefined && scope.unit !== 'day') {
|
|
2454
|
+
const unit = scope.unit;
|
|
2455
|
+
const startPoint = { op: 'snap', base: scope.expr, unit, edge: 'start' };
|
|
2456
|
+
const endPoint = { op: 'snap', base: scope.expr, unit, edge: 'end' };
|
|
2457
|
+
const midOffset = unit === 'week'
|
|
2458
|
+
? { op: 'offset', base: startPoint, amount: 3, unit: 'day' }
|
|
2459
|
+
: unit === 'month'
|
|
2460
|
+
? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
|
|
2461
|
+
: unit === 'year'
|
|
2462
|
+
? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
|
|
2463
|
+
: { op: 'offset', base: startPoint, amount: 45, unit: 'day' };
|
|
2464
|
+
if (mode === 'end')
|
|
2465
|
+
return { op: 'between', start: midOffset, end: endPoint };
|
|
2466
|
+
if (mode === 'start')
|
|
2467
|
+
return { op: 'between', start: startPoint, end: midOffset };
|
|
2468
|
+
return { ...scope.expr, mod: 'mid' };
|
|
2469
|
+
}
|
|
2470
|
+
if (scope.kind === 'unit' && scope.unit === 'day') {
|
|
2471
|
+
if (mode === 'end')
|
|
2472
|
+
return { op: 'snap', base: scope.expr, unit: 'day', edge: 'end' };
|
|
2473
|
+
return { ...scope.expr, mod: mode };
|
|
2474
|
+
}
|
|
2475
|
+
return { ...scope.expr, mod: mode === 'mid' ? 'mid' : mode };
|
|
2476
|
+
}
|
|
2477
|
+
const EDGE_MODES = {
|
|
2478
|
+
principios: 'start', principio: 'start', comienzos: 'start', comienzo: 'start',
|
|
2479
|
+
inicios: 'start', inicio: 'start',
|
|
2480
|
+
mediados: 'mid',
|
|
2481
|
+
finales: 'end', final: 'end', fin: 'end', fines: 'end',
|
|
2482
|
+
};
|
|
2483
|
+
/** "a principios de julio", "a mediados de 2000", "al final del día", "a fin de mes". */
|
|
2484
|
+
const esEdge = (tokens, i, ctx) => {
|
|
2485
|
+
let at = i;
|
|
2486
|
+
const w0 = word(tokens[at]);
|
|
2487
|
+
if (w0 === 'a' || w0 === 'al' || w0 === 'en' || w0 === 'hacia') {
|
|
2488
|
+
at += 1;
|
|
2489
|
+
if (word(tokens[at]) === 'el' || word(tokens[at]) === 'la')
|
|
2490
|
+
at += 1;
|
|
2491
|
+
}
|
|
2492
|
+
const modeWord = word(tokens[at]) ?? '';
|
|
2493
|
+
const mode = EDGE_MODES[modeWord];
|
|
2494
|
+
if (mode === undefined)
|
|
2495
|
+
return undefined;
|
|
2496
|
+
at += 1;
|
|
2497
|
+
const dw = word(tokens[at]);
|
|
2498
|
+
if (dw === 'de' || dw === 'del')
|
|
2499
|
+
at += 1;
|
|
2500
|
+
// "fin de semana" is the weekend, not the end of the week.
|
|
2501
|
+
if ((modeWord === 'fin' || modeWord === 'fines') && word(tokens[at]) === 'semana')
|
|
2502
|
+
return undefined;
|
|
2503
|
+
const scope = readEdgeScope(tokens, at, ctx);
|
|
2504
|
+
if (!scope)
|
|
2505
|
+
return undefined;
|
|
2506
|
+
return {
|
|
2507
|
+
expr: edgeExpr(mode, scope),
|
|
2508
|
+
consumed: at + scope.consumed - i,
|
|
2509
|
+
confidence: 0.95,
|
|
2510
|
+
role: scope.kind === 'period' ? 'datetime' : 'date',
|
|
2511
|
+
};
|
|
2512
|
+
};
|
|
2513
|
+
/** "temprano en la mañana", "más tarde esta semana", "más temprano de este mes". */
|
|
2514
|
+
const esEarlyLate = (tokens, i, ctx) => {
|
|
2515
|
+
let at = i;
|
|
2516
|
+
let mode;
|
|
2517
|
+
const w0 = word(tokens[at]);
|
|
2518
|
+
if (w0 === 'temprano') {
|
|
2519
|
+
mode = 'start';
|
|
2520
|
+
at += 1;
|
|
2521
|
+
}
|
|
2522
|
+
else if (w0 === 'más' || w0 === 'mas') {
|
|
2523
|
+
const w1 = word(tokens[at + 1]);
|
|
2524
|
+
if (w1 === 'temprano')
|
|
2525
|
+
mode = 'start';
|
|
2526
|
+
else if (w1 === 'tarde')
|
|
2527
|
+
mode = 'end';
|
|
2528
|
+
else
|
|
2529
|
+
return undefined;
|
|
2530
|
+
at += 2;
|
|
2531
|
+
}
|
|
2532
|
+
else {
|
|
2533
|
+
return undefined;
|
|
2534
|
+
}
|
|
2535
|
+
let filler = 0;
|
|
2536
|
+
while (filler < 3) {
|
|
2537
|
+
const fw = word(tokens[at]);
|
|
2538
|
+
if (['en', 'de', 'del', 'la', 'el', 'este', 'esta', 'al', 'a'].includes(fw ?? '') &&
|
|
2539
|
+
!(fw === 'a' && WEEKDAYS[word(tokens[at + 1]) ?? ''] === undefined && ES_DEICTIC[word(tokens[at + 1]) ?? ''] === undefined && word(tokens[at + 1]) !== 'día' && word(tokens[at + 1]) !== 'la' && word(tokens[at + 1]) !== 'el')) {
|
|
2540
|
+
at += 1;
|
|
2541
|
+
filler += 1;
|
|
2542
|
+
}
|
|
2543
|
+
else
|
|
2544
|
+
break;
|
|
2545
|
+
}
|
|
2546
|
+
if (word(tokens[at]) === 'día' || word(tokens[at]) === 'dia') {
|
|
2547
|
+
const wd = WEEKDAYS[word(tokens[at + 1]) ?? ''];
|
|
2548
|
+
if (wd) {
|
|
2549
|
+
return {
|
|
2550
|
+
expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd }, mod: mode },
|
|
2551
|
+
consumed: at + 2 - i,
|
|
2552
|
+
confidence: 0.95,
|
|
2553
|
+
role: 'date',
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
const pw = readPeriodWord(tokens, at);
|
|
2558
|
+
if (pw !== undefined) {
|
|
2559
|
+
return {
|
|
2560
|
+
expr: {
|
|
2561
|
+
op: 'intersect',
|
|
2562
|
+
parts: [snapNow('day'), { op: 'literal', dayPeriod: pw.period }],
|
|
2563
|
+
mod: mode,
|
|
2564
|
+
},
|
|
2565
|
+
consumed: at + pw.consumed - i,
|
|
2566
|
+
confidence: 0.95,
|
|
2567
|
+
role: 'datetime',
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
const uw = word(tokens[at]);
|
|
2571
|
+
const unit = ES_UNITS[uw ?? ''];
|
|
2572
|
+
if (unit !== undefined && unit !== 'hour' && unit !== 'minute' && unit !== 'second') {
|
|
2573
|
+
return { expr: { ...snapNow(unit), mod: mode }, consumed: at + 1 - i, confidence: 0.95, role: 'date' };
|
|
2574
|
+
}
|
|
2575
|
+
const deictic = ES_DEICTIC[uw ?? ''];
|
|
2576
|
+
if (deictic !== undefined && uw !== 'mañana') {
|
|
2577
|
+
// "más tarde hoy" (skip "mañana": "más tarde mañana" is rare and ambiguous).
|
|
2578
|
+
return { expr: { ...snapOffset(deictic, 'day'), mod: mode }, consumed: at + 1 - i, confidence: 0.95, role: 'date' };
|
|
2579
|
+
}
|
|
2580
|
+
if (uw === 'hoy') {
|
|
2581
|
+
return { expr: { ...snapNow('day'), mod: mode }, consumed: at + 1 - i, confidence: 0.95, role: 'date' };
|
|
2582
|
+
}
|
|
2583
|
+
void ctx;
|
|
2584
|
+
return undefined;
|
|
2585
|
+
};
|
|
2586
|
+
function readRangeOperand(tokens, i, ctx, preferNum = false, preferMonth = false) {
|
|
2587
|
+
let at = i;
|
|
2588
|
+
let guard = 0;
|
|
2589
|
+
let yearShift;
|
|
2590
|
+
while (guard < 2) {
|
|
2591
|
+
const w0 = word(tokens[at]);
|
|
2592
|
+
if (['el', 'la', 'los', 'las'].includes(w0 ?? '')) {
|
|
2593
|
+
at += 1;
|
|
2594
|
+
}
|
|
2595
|
+
else if (w0 === 'este' || w0 === 'esta') {
|
|
2596
|
+
yearShift = yearShift ?? 0;
|
|
2597
|
+
at += 1;
|
|
2598
|
+
}
|
|
2599
|
+
else if (w0 === 'próximo' || w0 === 'proximo' || w0 === 'próxima' || w0 === 'proxima') {
|
|
2600
|
+
yearShift = 1;
|
|
2601
|
+
at += 1;
|
|
2602
|
+
}
|
|
2603
|
+
else
|
|
2604
|
+
break;
|
|
2605
|
+
guard += 1;
|
|
2606
|
+
}
|
|
2607
|
+
const t = tokens[at];
|
|
2608
|
+
const w = word(t);
|
|
2609
|
+
if (w === 'ahora') {
|
|
2610
|
+
// Recognizers floors "ahora" range endpoints to the day.
|
|
2611
|
+
return {
|
|
2612
|
+
op: { kind: 'expr', expr: { op: 'snap', base: snapNow('day'), unit: 'day', edge: 'start' } },
|
|
2613
|
+
consumed: at + 1 - i,
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
const deictic = ES_DEICTIC[w ?? ''];
|
|
2617
|
+
if (deictic !== undefined) {
|
|
2618
|
+
return { op: { kind: 'expr', expr: snapOffset(deictic, 'day') }, consumed: at + 1 - i };
|
|
2619
|
+
}
|
|
2620
|
+
// "N min después" as a range endpoint ("desde ahora hasta 3 min después").
|
|
2621
|
+
{
|
|
2622
|
+
const n = readWordNum(tokens, at);
|
|
2623
|
+
if (n) {
|
|
2624
|
+
const u = ES_UNITS[word(tokens[at + n.consumed]) ?? ''];
|
|
2625
|
+
const dw = word(tokens[at + n.consumed + 1]);
|
|
2626
|
+
if (u && (dw === 'después' || dw === 'despues')) {
|
|
2627
|
+
return {
|
|
2628
|
+
op: { kind: 'expr', expr: { op: 'offset', base: NOW, amount: n.value, unit: u } },
|
|
2629
|
+
consumed: at + n.consumed + 2 - i,
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
// Edge operand: "finales de 2008", "mediados de marzo".
|
|
2635
|
+
const mode = EDGE_MODES[w ?? ''];
|
|
2636
|
+
if (mode !== undefined) {
|
|
2637
|
+
let cursor = at + 1;
|
|
2638
|
+
const dw = word(tokens[cursor]);
|
|
2639
|
+
if (dw === 'de' || dw === 'del')
|
|
2640
|
+
cursor += 1;
|
|
2641
|
+
const inner = readRangeOperand(tokens, cursor, ctx);
|
|
2642
|
+
if (!inner)
|
|
2643
|
+
return undefined;
|
|
2644
|
+
const io = inner.op;
|
|
2645
|
+
const consumedAll = cursor + inner.consumed - i;
|
|
2646
|
+
if (io.kind === 'year') {
|
|
2647
|
+
const lit = { op: 'literal', date: { year: io.year } };
|
|
2648
|
+
const expr = mode === 'mid'
|
|
2649
|
+
? { op: 'offset', base: { op: 'snap', base: lit, unit: 'year', edge: 'start' }, amount: 182, unit: 'day' }
|
|
2650
|
+
: { op: 'snap', base: lit, unit: 'year', edge: mode };
|
|
2651
|
+
return { op: { kind: 'expr', expr }, consumed: consumedAll };
|
|
2652
|
+
}
|
|
2653
|
+
if (io.kind === 'month') {
|
|
2654
|
+
const date = { month: io.month };
|
|
2655
|
+
if (io.year !== undefined)
|
|
2656
|
+
date.year = io.year;
|
|
2657
|
+
if (mode === 'mid')
|
|
2658
|
+
return { op: { kind: 'date', date: { ...date, day: 16 } }, consumed: consumedAll };
|
|
2659
|
+
return {
|
|
2660
|
+
op: { kind: 'expr', expr: { op: 'snap', base: { op: 'literal', date }, unit: 'month', edge: mode } },
|
|
2661
|
+
consumed: consumedAll,
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
if (io.kind === 'expr') {
|
|
2665
|
+
return { op: { kind: 'expr', expr: { ...io.expr, mod: mode } }, consumed: consumedAll };
|
|
2666
|
+
}
|
|
2667
|
+
return undefined;
|
|
2668
|
+
}
|
|
2669
|
+
// Quarter operands: "2017-t1", "primer trimestre de 2017".
|
|
2670
|
+
const q = readQuarterToken(tokens, at);
|
|
2671
|
+
if (q) {
|
|
2672
|
+
return {
|
|
2673
|
+
op: { kind: 'expr', expr: quarterExpr(q), qYear: q.year, qMonth: q.start },
|
|
2674
|
+
consumed: at + q.consumed - i,
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2677
|
+
const oq = readOrdinalQuarter(tokens, at);
|
|
2678
|
+
if (oq) {
|
|
2679
|
+
const op = { kind: 'expr', expr: oq.expr };
|
|
2680
|
+
if (oq.year !== undefined)
|
|
2681
|
+
op.qYear = oq.year;
|
|
2682
|
+
if (oq.startMonth !== undefined)
|
|
2683
|
+
op.qMonth = oq.startMonth;
|
|
2684
|
+
return { op, consumed: at + oq.consumed - i };
|
|
2685
|
+
}
|
|
2686
|
+
// Clock forms (with optional period suffix).
|
|
2687
|
+
const time = readTime(tokens, at);
|
|
2688
|
+
if (time && !time.bare) {
|
|
2689
|
+
let cursor = at + time.consumed;
|
|
2690
|
+
let pt = time.time;
|
|
2691
|
+
const ps = readPeriodSuffix(tokens, cursor);
|
|
2692
|
+
if (ps) {
|
|
2693
|
+
pt = applyPeriod(pt, ps.period);
|
|
2694
|
+
cursor += ps.consumed;
|
|
2695
|
+
}
|
|
2696
|
+
return { op: { kind: 'time', time: pt }, consumed: cursor - i };
|
|
2697
|
+
}
|
|
2698
|
+
if (t?.type === 'numdate') {
|
|
2699
|
+
const date = numdateToDate(t);
|
|
2700
|
+
if (date) {
|
|
2701
|
+
let cursor = at + 1;
|
|
2702
|
+
if (yearShift !== undefined && yearShift !== 0 && date.year === undefined) {
|
|
2703
|
+
const expr = {
|
|
2704
|
+
op: 'intersect',
|
|
2705
|
+
parts: [snapOffset(yearShift, 'year'), { op: 'literal', date }],
|
|
2706
|
+
};
|
|
2707
|
+
return { op: { kind: 'expr', expr }, consumed: cursor - i };
|
|
2708
|
+
}
|
|
2709
|
+
if (yearShift === 0 && date.year === undefined) {
|
|
2710
|
+
const expr = {
|
|
2711
|
+
op: 'intersect',
|
|
2712
|
+
parts: [snapNow('year'), { op: 'literal', date }],
|
|
2713
|
+
};
|
|
2714
|
+
return { op: { kind: 'expr', expr }, consumed: cursor - i };
|
|
2715
|
+
}
|
|
2716
|
+
return { op: { kind: 'date', date }, consumed: cursor - i };
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
// Slash/joined dates: "diciembre/2018", "01/agosto/2019".
|
|
2720
|
+
{
|
|
2721
|
+
const nd = esNumdate(tokens, at, ctx);
|
|
2722
|
+
if (nd && nd.role === 'date' && nd.expr.op === 'literal' && nd.expr.date !== undefined) {
|
|
2723
|
+
return { op: { kind: 'date', date: nd.expr.date }, consumed: at + nd.consumed - i };
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
// Ambiguous month/weekday abbreviations ("mar"): a month left side reads a
|
|
2727
|
+
// month right side.
|
|
2728
|
+
if (preferMonth && monthOf(w) !== undefined) {
|
|
2729
|
+
const month = monthOf(w);
|
|
2730
|
+
let k = at + 1;
|
|
2731
|
+
if (word(tokens[k]) === 'de' || word(tokens[k]) === 'del') {
|
|
2732
|
+
const y = yearAt(tokens, k + 1);
|
|
2733
|
+
if (y !== undefined)
|
|
2734
|
+
return { op: { kind: 'month', month, year: y }, consumed: k + 2 - i };
|
|
2735
|
+
}
|
|
2736
|
+
const y = yearAt(tokens, k);
|
|
2737
|
+
if (y !== undefined)
|
|
2738
|
+
return { op: { kind: 'month', month, year: y }, consumed: k + 1 - i };
|
|
2739
|
+
return { op: { kind: 'month', month }, consumed: at + 1 - i };
|
|
2740
|
+
}
|
|
2741
|
+
const wd = WEEKDAYS[w ?? ''];
|
|
2742
|
+
if (wd) {
|
|
2743
|
+
let cursor = at + 1;
|
|
2744
|
+
let dir = yearShift === 1 ? 'next' : 'nearest';
|
|
2745
|
+
const pm = word(tokens[cursor]);
|
|
2746
|
+
if (pm === 'próximo' || pm === 'proximo' || pm === 'siguiente') {
|
|
2747
|
+
dir = 'next';
|
|
2748
|
+
cursor += 1;
|
|
2749
|
+
}
|
|
2750
|
+
else if (pm === 'pasado' || pm === 'anterior') {
|
|
2751
|
+
dir = 'prev';
|
|
2752
|
+
cursor += 1;
|
|
2753
|
+
}
|
|
2754
|
+
// weekday + explicit date → the date rules own it.
|
|
2755
|
+
const dm = esDayMonth(tokens, cursor, ctx) ?? esNumdate(tokens, cursor, ctx);
|
|
2756
|
+
if (dm && dm.expr.op === 'literal' && dm.expr.date !== undefined) {
|
|
2757
|
+
return { op: { kind: 'date', date: dm.expr.date }, consumed: cursor + dm.consumed - i };
|
|
2758
|
+
}
|
|
2759
|
+
const seek = { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: wd } };
|
|
2760
|
+
return { op: { kind: 'expr', expr: seek }, consumed: cursor - i };
|
|
2761
|
+
}
|
|
2762
|
+
const year0 = yearAt(tokens, at);
|
|
2763
|
+
const month0 = monthOf(w);
|
|
2764
|
+
const numFirst = preferNum && (ONES[w ?? ''] !== undefined || TEENS[w ?? ''] !== undefined);
|
|
2765
|
+
if (month0 !== undefined && !numFirst) {
|
|
2766
|
+
let k = at + 1;
|
|
2767
|
+
if (word(tokens[k]) === 'de' || word(tokens[k]) === 'del') {
|
|
2768
|
+
const y = yearAt(tokens, k + 1);
|
|
2769
|
+
if (y !== undefined)
|
|
2770
|
+
return { op: { kind: 'month', month: month0, year: y }, consumed: k + 2 - i };
|
|
2771
|
+
}
|
|
2772
|
+
const y = yearAt(tokens, k);
|
|
2773
|
+
if (y !== undefined)
|
|
2774
|
+
return { op: { kind: 'month', month: month0, year: y }, consumed: k + 1 - i };
|
|
2775
|
+
if (yearShift !== undefined) {
|
|
2776
|
+
// "este mayo" → May of the current year.
|
|
2777
|
+
const yearExpr = yearShift === 0 ? snapNow('year') : snapOffset(yearShift, 'year');
|
|
2778
|
+
return {
|
|
2779
|
+
op: {
|
|
2780
|
+
kind: 'expr',
|
|
2781
|
+
expr: { op: 'intersect', parts: [yearExpr, { op: 'literal', date: { month: month0 } }] },
|
|
2782
|
+
},
|
|
2783
|
+
consumed: at + 1 - i,
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
return { op: { kind: 'month', month: month0 }, consumed: at + 1 - i };
|
|
2787
|
+
}
|
|
2788
|
+
// "año 2014" — skip the noun.
|
|
2789
|
+
if ((w === 'año' || w === 'ano') && yearAt(tokens, at + 1) !== undefined) {
|
|
2790
|
+
return { op: { kind: 'year', year: yearAt(tokens, at + 1) }, consumed: at + 2 - i };
|
|
2791
|
+
}
|
|
2792
|
+
// day + month (+ year) via the shared reader.
|
|
2793
|
+
const dm = esDayMonth(tokens, at, ctx);
|
|
2794
|
+
if (dm && dm.expr.op === 'literal' && dm.expr.date !== undefined && dm.expr.date.day !== undefined) {
|
|
2795
|
+
return { op: { kind: 'date', date: dm.expr.date }, consumed: at + dm.consumed - i };
|
|
2796
|
+
}
|
|
2797
|
+
if (year0 !== undefined)
|
|
2798
|
+
return { op: { kind: 'year', year: year0 }, consumed: at + 1 - i };
|
|
2799
|
+
const dnum = readWordNum(tokens, at);
|
|
2800
|
+
if (dnum && dnum.value >= 0 && dnum.value <= 31) {
|
|
2801
|
+
let cursor = at + dnum.consumed;
|
|
2802
|
+
// "3 de la mañana" → an hour with a period.
|
|
2803
|
+
const ps = readPeriodSuffix(tokens, cursor);
|
|
2804
|
+
if (ps !== undefined && dnum.value <= 23) {
|
|
2805
|
+
cursor += ps.consumed;
|
|
2806
|
+
const timeOp = { hour: dnum.value };
|
|
2807
|
+
if (dnum.value <= 12)
|
|
2808
|
+
timeOp.meridiem = meridiemFor(ps.period);
|
|
2809
|
+
return { op: { kind: 'time', time: timeOp }, consumed: cursor - i };
|
|
2810
|
+
}
|
|
2811
|
+
return { op: { kind: 'num', value: dnum.value }, consumed: cursor - i };
|
|
2812
|
+
}
|
|
2813
|
+
return undefined;
|
|
2814
|
+
}
|
|
2815
|
+
function operandToDateExpr(op) {
|
|
2816
|
+
switch (op.kind) {
|
|
2817
|
+
case 'date':
|
|
2818
|
+
return { op: 'literal', date: op.date };
|
|
2819
|
+
case 'month': {
|
|
2820
|
+
const date = { month: op.month };
|
|
2821
|
+
if (op.year !== undefined)
|
|
2822
|
+
date.year = op.year;
|
|
2823
|
+
const lit = { op: 'literal', date };
|
|
2824
|
+
return op.mod !== undefined ? { ...lit, mod: op.mod } : lit;
|
|
2825
|
+
}
|
|
2826
|
+
case 'year':
|
|
2827
|
+
return { op: 'literal', date: { year: op.year } };
|
|
2828
|
+
case 'expr':
|
|
2829
|
+
return op.expr;
|
|
2830
|
+
default:
|
|
2831
|
+
return undefined;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
/** Day-number pair followed by a month scope: "de 18 a 19 el noviembre", "9 a 12 de junio". */
|
|
2835
|
+
function finishNumPair(tokens, at, n1, n2, ruleStart) {
|
|
2836
|
+
let cursor = at;
|
|
2837
|
+
if (['el', 'del', 'de', 'en'].includes(word(tokens[cursor]) ?? ''))
|
|
2838
|
+
cursor += 1;
|
|
2839
|
+
const month = monthOf(word(tokens[cursor]));
|
|
2840
|
+
if (month !== undefined && n1 >= 1 && n1 <= 31 && n2 >= 1 && n2 <= 31) {
|
|
2841
|
+
let yAt = cursor + 1;
|
|
2842
|
+
if (['de', 'del', 'en'].includes(word(tokens[yAt]) ?? ''))
|
|
2843
|
+
yAt += 1;
|
|
2844
|
+
const y = yearAt(tokens, yAt);
|
|
2845
|
+
const mk = (d) => y !== undefined
|
|
2846
|
+
? { op: 'literal', date: { day: d, month, year: y } }
|
|
2847
|
+
: { op: 'literal', date: { day: d, month } };
|
|
2848
|
+
return {
|
|
2849
|
+
expr: { op: 'between', start: mk(n1), end: mk(n2) },
|
|
2850
|
+
consumed: (y !== undefined ? yAt + 1 : cursor + 1) - ruleStart,
|
|
2851
|
+
confidence: 1,
|
|
2852
|
+
role: 'date',
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
return undefined;
|
|
2856
|
+
}
|
|
2857
|
+
/** "de X a Y", "entre X y Y", "desde X hasta Y", "del X al Y" ranges. */
|
|
2858
|
+
const esRange = (tokens, i, ctx) => {
|
|
2859
|
+
let at = i;
|
|
2860
|
+
const w0 = word(tokens[at]);
|
|
2861
|
+
let hasPrefix = false;
|
|
2862
|
+
if (['de', 'del', 'desde', 'entre'].includes(w0 ?? '')) {
|
|
2863
|
+
hasPrefix = true;
|
|
2864
|
+
at += 1;
|
|
2865
|
+
}
|
|
2866
|
+
const readA = readRangeOperand(tokens, at, ctx);
|
|
2867
|
+
if (!readA)
|
|
2868
|
+
return undefined;
|
|
2869
|
+
let a = readA.op;
|
|
2870
|
+
at += readA.consumed;
|
|
2871
|
+
const conn = word(tokens[at]);
|
|
2872
|
+
const aQuarter = a.kind === 'expr' && a.qMonth !== undefined;
|
|
2873
|
+
const connOk = conn === 'a' || conn === 'al' || conn === 'hasta' || conn === '-' || conn === '~' ||
|
|
2874
|
+
conn === 'hacia' || (conn === 'y' && hasPrefix);
|
|
2875
|
+
if (!connOk)
|
|
2876
|
+
return undefined;
|
|
2877
|
+
// Prefixless ranges only for time-ish left sides, quarters, strong
|
|
2878
|
+
// connectors, or full dates joined by a dash.
|
|
2879
|
+
if (!hasPrefix && a.kind !== 'time' && a.kind !== 'num' && !aQuarter) {
|
|
2880
|
+
const strongConn = conn === 'hasta' || conn === '~';
|
|
2881
|
+
const dashDates = conn === '-' && (a.kind === 'date' || a.kind === 'expr' || a.kind === 'month' || a.kind === 'year');
|
|
2882
|
+
if (!strongConn && !dashDates)
|
|
2883
|
+
return undefined;
|
|
2884
|
+
}
|
|
2885
|
+
at += 1;
|
|
2886
|
+
const preferMonthB = a.kind === 'month' || a.kind === 'year' ||
|
|
2887
|
+
(a.kind === 'date' && a.date.month !== undefined);
|
|
2888
|
+
const readB = readRangeOperand(tokens, at, ctx, a.kind === 'num' || a.kind === 'time', preferMonthB);
|
|
2889
|
+
if (!readB)
|
|
2890
|
+
return undefined;
|
|
2891
|
+
let b = readB.op;
|
|
2892
|
+
at += readB.consumed;
|
|
2893
|
+
// Quarter-to-quarter ranges end at the start of the closing quarter
|
|
2894
|
+
// (Recognizers convention): "2017-t1 a 2018-t1" → 2017-01-01…2018-01-01.
|
|
2895
|
+
if (aQuarter && b.kind === 'expr' && b.qMonth !== undefined && b.qYear !== undefined) {
|
|
2896
|
+
return {
|
|
2897
|
+
expr: {
|
|
2898
|
+
op: 'between',
|
|
2899
|
+
start: a.kind === 'expr' ? a.expr : undefined,
|
|
2900
|
+
end: {
|
|
2901
|
+
op: 'snap',
|
|
2902
|
+
base: { op: 'literal', date: { year: b.qYear, month: b.qMonth } },
|
|
2903
|
+
unit: 'month',
|
|
2904
|
+
edge: 'start',
|
|
2905
|
+
},
|
|
2906
|
+
},
|
|
2907
|
+
consumed: at - i,
|
|
2908
|
+
confidence: 0.95,
|
|
2909
|
+
role: 'date',
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
// Month scope after a number pair: "de 18 a 19 el noviembre", "9 a 12 de junio".
|
|
2913
|
+
if (a.kind === 'num' && b.kind === 'num') {
|
|
2914
|
+
const scoped = finishNumPair(tokens, at, a.value, b.value, i);
|
|
2915
|
+
if (scoped)
|
|
2916
|
+
return scoped;
|
|
2917
|
+
}
|
|
2918
|
+
// "desde próximo lunes hasta viernes" — the bare closing weekday follows
|
|
2919
|
+
// the opening one.
|
|
2920
|
+
if (a.kind === 'expr' && a.expr.op === 'seek' && a.expr.target.kind === 'weekday' &&
|
|
2921
|
+
b.kind === 'expr' && b.expr.op === 'seek' && b.expr.target.kind === 'weekday' && b.expr.dir === 'nearest') {
|
|
2922
|
+
b = { kind: 'expr', expr: { op: 'seek', base: a.expr, dir: 'next', target: b.expr.target, n: 1 } };
|
|
2923
|
+
}
|
|
2924
|
+
// Month applies backward when only B got it: "de 26 a 28 de junio en 2020".
|
|
2925
|
+
if (a.kind === 'num' && b.kind === 'date' && b.date.month !== undefined) {
|
|
2926
|
+
const date = { day: a.value, month: b.date.month };
|
|
2927
|
+
if (b.date.year !== undefined)
|
|
2928
|
+
date.year = b.date.year;
|
|
2929
|
+
a = { kind: 'date', date };
|
|
2930
|
+
if (!hasPrefix) {
|
|
2931
|
+
// Prefixless "9 a 12 de junio" ends at the start of the closing day
|
|
2932
|
+
// (Recognizers convention for this bare form).
|
|
2933
|
+
return {
|
|
2934
|
+
expr: {
|
|
2935
|
+
op: 'between',
|
|
2936
|
+
start: { op: 'literal', date: a.date },
|
|
2937
|
+
end: { op: 'snap', base: { op: 'literal', date: b.date }, unit: 'day', edge: 'start' },
|
|
2938
|
+
},
|
|
2939
|
+
consumed: at - i,
|
|
2940
|
+
confidence: 0.9,
|
|
2941
|
+
role: 'date',
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// Shared trailing year: "de enero a abril de 2015", "de noviembre a febrero de 2017".
|
|
2946
|
+
if (b.kind === 'month' && b.year !== undefined && a.kind === 'month' && a.year === undefined) {
|
|
2947
|
+
a = { ...a, year: a.month <= b.month ? b.year : b.year - 1 };
|
|
2948
|
+
}
|
|
2949
|
+
// "de noviembre a 5 de febrero de 2017" — month left, dated right.
|
|
2950
|
+
if (b.kind === 'date' && b.date.year !== undefined && a.kind === 'month' && a.year === undefined) {
|
|
2951
|
+
a = { ...a, year: a.month <= (b.date.month ?? 12) ? b.date.year : b.date.year - 1 };
|
|
2952
|
+
}
|
|
2953
|
+
if (b.kind === 'date' && b.date.year !== undefined && a.kind === 'date' && a.date.year === undefined) {
|
|
2954
|
+
const am = a.date.month ?? 0;
|
|
2955
|
+
const bm = b.date.month ?? 0;
|
|
2956
|
+
if (am < bm || (am === bm && (a.date.day ?? 0) <= (b.date.day ?? 32))) {
|
|
2957
|
+
a = { kind: 'date', date: { ...a.date, year: b.date.year } };
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
// Trailing year applying to both sides: "de mayo a octubre, 2020", "… en 2020".
|
|
2961
|
+
if ((a.kind === 'date' || a.kind === 'month') && (b.kind === 'date' || b.kind === 'month')) {
|
|
2962
|
+
const aHasYear = a.kind === 'date' ? a.date.year !== undefined : a.year !== undefined;
|
|
2963
|
+
const bHasYear = b.kind === 'date' ? b.date.year !== undefined : b.year !== undefined;
|
|
2964
|
+
if (!aHasYear && !bHasYear) {
|
|
2965
|
+
let yAt = at;
|
|
2966
|
+
if (['de', 'del', 'en'].includes(word(tokens[yAt]) ?? ''))
|
|
2967
|
+
yAt += 1;
|
|
2968
|
+
const y = yearAt(tokens, yAt);
|
|
2969
|
+
if (y !== undefined) {
|
|
2970
|
+
if (a.kind === 'date')
|
|
2971
|
+
a = { kind: 'date', date: { ...a.date, year: y } };
|
|
2972
|
+
else
|
|
2973
|
+
a = { ...a, year: y };
|
|
2974
|
+
if (b.kind === 'date')
|
|
2975
|
+
b = { kind: 'date', date: { ...b.date, year: y } };
|
|
2976
|
+
else
|
|
2977
|
+
b = { ...b, year: y };
|
|
2978
|
+
at = yAt + 1;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
// Trailing period: "las cinco a las siete de la mañana".
|
|
2983
|
+
let sharedPeriod;
|
|
2984
|
+
if ((a.kind === 'time' || a.kind === 'num') && (b.kind === 'time' || b.kind === 'num')) {
|
|
2985
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
2986
|
+
if (ps) {
|
|
2987
|
+
sharedPeriod = ps.period;
|
|
2988
|
+
at += ps.consumed;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
const numToTime = (v) => (v <= 12 ? { hour: v, meridiem: 'unknown' } : { hour: v });
|
|
2992
|
+
const timeish = (o) => o.kind === 'time' || o.kind === 'num';
|
|
2993
|
+
const asTime = (o) => o.kind === 'time' ? o.time : o.kind === 'num' ? numToTime(o.value) : undefined;
|
|
2994
|
+
if (timeish(a) && timeish(b)) {
|
|
2995
|
+
let ta = asTime(a);
|
|
2996
|
+
let tb = asTime(b);
|
|
2997
|
+
if (sharedPeriod !== undefined) {
|
|
2998
|
+
ta = applyPeriod(ta, sharedPeriod);
|
|
2999
|
+
tb = applyPeriod(tb, sharedPeriod);
|
|
3000
|
+
}
|
|
3001
|
+
// Share B's meridiem with A within the same rising half of the day.
|
|
3002
|
+
if (ta.meridiem === 'unknown' && tb.meridiem !== undefined && tb.meridiem !== 'unknown' && (ta.hour ?? 0) < (tb.hour ?? 0)) {
|
|
3003
|
+
ta = { ...ta, meridiem: tb.meridiem };
|
|
3004
|
+
}
|
|
3005
|
+
// …and A's with B: "de las 4 p.m. y las 5" (rare; safe when rising and
|
|
3006
|
+
// the target is not the ambiguous hour 12).
|
|
3007
|
+
if (tb.meridiem === 'unknown' && ta.meridiem !== undefined && ta.meridiem !== 'unknown' &&
|
|
3008
|
+
(ta.hour ?? 0) < (tb.hour ?? 0) && tb.hour !== 12) {
|
|
3009
|
+
tb = { ...tb, meridiem: ta.meridiem };
|
|
3010
|
+
}
|
|
3011
|
+
// Descending across noon: "de 8 a.m. - 3" → 08:00–15:00.
|
|
3012
|
+
if (tb.meridiem === 'unknown' && ta.meridiem === 'am' && (tb.hour ?? 0) < (ta.hour ?? 0)) {
|
|
3013
|
+
tb = { ...tb, meridiem: 'pm' };
|
|
3014
|
+
}
|
|
3015
|
+
return {
|
|
3016
|
+
expr: { op: 'between', start: timeLiteral(ta), end: timeLiteral(tb) },
|
|
3017
|
+
consumed: at - i,
|
|
3018
|
+
confidence: hasPrefix ? 0.95 : 0.85,
|
|
3019
|
+
role: 'time',
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
const dayLit = (o) => o.kind === 'num' ? { op: 'literal', date: { day: o.value } } : undefined;
|
|
3023
|
+
const ea = operandToDateExpr(a) ?? dayLit(a);
|
|
3024
|
+
const eb = operandToDateExpr(b) ?? dayLit(b);
|
|
3025
|
+
if (!ea || !eb)
|
|
3026
|
+
return undefined;
|
|
3027
|
+
if (!hasPrefix && conn !== 'hasta' && conn !== '~' && conn !== '-')
|
|
3028
|
+
return undefined;
|
|
3029
|
+
return {
|
|
3030
|
+
expr: { op: 'between', start: ea, end: eb },
|
|
3031
|
+
consumed: at - i,
|
|
3032
|
+
confidence: 0.95,
|
|
3033
|
+
role: 'date',
|
|
3034
|
+
};
|
|
3035
|
+
};
|
|
3036
|
+
/** "enero-febrero de 2017" → month range in dash-split tokens. */
|
|
3037
|
+
const esMonthDashMonth = (tokens, i) => {
|
|
3038
|
+
const m1 = MONTHS[word(tokens[i]) ?? ''];
|
|
3039
|
+
if (m1 === undefined || word(tokens[i + 1]) !== '-')
|
|
3040
|
+
return undefined;
|
|
3041
|
+
const m2 = MONTHS[word(tokens[i + 2]) ?? ''];
|
|
3042
|
+
if (m2 === undefined)
|
|
3043
|
+
return undefined;
|
|
3044
|
+
let at = i + 3;
|
|
3045
|
+
let year;
|
|
3046
|
+
let yAt = at;
|
|
3047
|
+
if (['de', 'del'].includes(word(tokens[yAt]) ?? ''))
|
|
3048
|
+
yAt += 1;
|
|
3049
|
+
const y = yearAt(tokens, yAt);
|
|
3050
|
+
if (y !== undefined) {
|
|
3051
|
+
year = y;
|
|
3052
|
+
at = yAt + 1;
|
|
3053
|
+
}
|
|
3054
|
+
const mk = (mo, yy) => ({
|
|
3055
|
+
op: 'literal',
|
|
3056
|
+
date: { month: mo, ...(yy !== undefined ? { year: yy } : {}) },
|
|
3057
|
+
});
|
|
3058
|
+
const y1 = year !== undefined ? (m1 <= m2 ? year : year - 1) : undefined;
|
|
3059
|
+
return {
|
|
3060
|
+
expr: { op: 'between', start: mk(m1, y1), end: mk(m2, year) },
|
|
3061
|
+
consumed: at - i,
|
|
3062
|
+
confidence: 0.95,
|
|
3063
|
+
role: 'date',
|
|
3064
|
+
};
|
|
3065
|
+
};
|
|
3066
|
+
/* ------------------------------------------------------------------ */
|
|
3067
|
+
/* Specials */
|
|
3068
|
+
/* ------------------------------------------------------------------ */
|
|
3069
|
+
const esSpecials = (tokens, i) => {
|
|
3070
|
+
const w0 = word(tokens[i]);
|
|
3071
|
+
// "de madrugada" → 04:00–08:00 (no matching day period; structural).
|
|
3072
|
+
if (w0 === 'de' && word(tokens[i + 1]) === 'madrugada') {
|
|
3073
|
+
return {
|
|
3074
|
+
expr: {
|
|
3075
|
+
op: 'between',
|
|
3076
|
+
start: timeLiteral({ hour: 4, meridiem: 'am' }),
|
|
3077
|
+
end: timeLiteral({ hour: 8, meridiem: 'am' }),
|
|
3078
|
+
},
|
|
3079
|
+
consumed: 2,
|
|
3080
|
+
confidence: 0.95,
|
|
3081
|
+
role: 'time',
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
// "pasado mediodía", "este pasado mediodia" → afternoon.
|
|
3085
|
+
{
|
|
3086
|
+
let at = i;
|
|
3087
|
+
if (w0 === 'este' || w0 === 'el')
|
|
3088
|
+
at += 1;
|
|
3089
|
+
const pw = readPeriodWord(tokens, at);
|
|
3090
|
+
if (pw !== undefined && word(tokens[at]) === 'pasado') {
|
|
3091
|
+
return {
|
|
3092
|
+
expr: { op: 'literal', dayPeriod: pw.period },
|
|
3093
|
+
consumed: at + pw.consumed - i,
|
|
3094
|
+
confidence: 0.9,
|
|
3095
|
+
role: 'time',
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
// "hora de almuerzo" / "almuerzo" → the lunch block (11:00–13:00).
|
|
3100
|
+
if (w0 === 'hora' && word(tokens[i + 1]) === 'de' && word(tokens[i + 2]) === 'almuerzo') {
|
|
3101
|
+
return {
|
|
3102
|
+
expr: { op: 'between', start: timeLiteral({ hour: 11 }), end: timeLiteral({ hour: 13 }) },
|
|
3103
|
+
consumed: 3,
|
|
3104
|
+
confidence: 0.95,
|
|
3105
|
+
role: 'time',
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
if (w0 === 'almuerzo') {
|
|
3109
|
+
return {
|
|
3110
|
+
expr: { op: 'between', start: timeLiteral({ hour: 11 }), end: timeLiteral({ hour: 13 }) },
|
|
3111
|
+
consumed: 1,
|
|
3112
|
+
confidence: 0.7,
|
|
3113
|
+
role: 'time',
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
// "la cena" → the dinner block (Recognizers: 16:00–20:00 evening).
|
|
3117
|
+
if (w0 === 'cena') {
|
|
3118
|
+
return { expr: { op: 'literal', dayPeriod: 'evening' }, consumed: 1, confidence: 0.7, role: 'time' };
|
|
3119
|
+
}
|
|
3120
|
+
return undefined;
|
|
3121
|
+
};
|
|
3122
|
+
/**
|
|
3123
|
+
* "ayer por la tarde" as one datetime match, so an adjacent explicit time
|
|
3124
|
+
* range keeps precedence over the period block when the parser merges parts.
|
|
3125
|
+
*/
|
|
3126
|
+
const esDeicticPeriod = (tokens, i) => {
|
|
3127
|
+
const w0 = word(tokens[i]);
|
|
3128
|
+
const deictic = w0 !== undefined ? ES_DEICTIC[w0] : undefined;
|
|
3129
|
+
if (deictic === undefined)
|
|
3130
|
+
return undefined;
|
|
3131
|
+
const ps = readPeriodSuffix(tokens, i + 1);
|
|
3132
|
+
if (!ps || ps.period === 'madrugada')
|
|
3133
|
+
return undefined;
|
|
3134
|
+
return {
|
|
3135
|
+
expr: {
|
|
3136
|
+
op: 'intersect',
|
|
3137
|
+
parts: [snapOffset(deictic, 'day'), { op: 'literal', dayPeriod: ps.period }],
|
|
3138
|
+
},
|
|
3139
|
+
consumed: 1 + ps.consumed,
|
|
3140
|
+
confidence: 1,
|
|
3141
|
+
role: 'datetime',
|
|
3142
|
+
};
|
|
3143
|
+
};
|
|
3144
|
+
/* ------------------------------------------------------------------ */
|
|
3145
|
+
/* Lexicon */
|
|
3146
|
+
/* ------------------------------------------------------------------ */
|
|
3147
|
+
const ES_UNITS = {
|
|
3148
|
+
segundo: 'second', segundos: 'second', seg: 'second', segs: 'second',
|
|
3149
|
+
minuto: 'minute', minutos: 'minute', min: 'minute', mins: 'minute',
|
|
3150
|
+
hora: 'hour', horas: 'hour', h: 'hour', hs: 'hour',
|
|
3151
|
+
día: 'day', días: 'day', dia: 'day', dias: 'day',
|
|
3152
|
+
semana: 'week', semanas: 'week',
|
|
3153
|
+
mes: 'month', meses: 'month',
|
|
3154
|
+
trimestre: 'quarter', trimestres: 'quarter',
|
|
3155
|
+
año: 'year', años: 'year', ano: 'year', anos: 'year',
|
|
3156
|
+
};
|
|
3157
|
+
const ES_DEICTIC = {
|
|
3158
|
+
hoy: 0, ayer: -1, mañana: 1, manana: 1, anteayer: -2,
|
|
3159
|
+
};
|
|
3160
|
+
const ES_POST_MODS = {
|
|
3161
|
+
pasado: -1, pasada: -1,
|
|
3162
|
+
anterior: -1, anteriores: -1,
|
|
3163
|
+
previo: -1, previa: -1,
|
|
3164
|
+
próximo: 1, proximo: 1, próxima: 1, proxima: 1,
|
|
3165
|
+
siguiente: 1,
|
|
3166
|
+
actual: 0, actuales: 0,
|
|
3167
|
+
};
|
|
3168
|
+
const ES_LAST_ADJS = [
|
|
3169
|
+
'últimos', 'ultimos', 'últimas', 'ultimas', 'pasados', 'pasadas',
|
|
3170
|
+
'anteriores', 'previos', 'previas',
|
|
3171
|
+
];
|
|
3172
|
+
const ES_NEXT_ADJS = ['próximos', 'proximos', 'próximas', 'proximas', 'siguientes'];
|
|
3173
|
+
export const ES_LEXICON = {
|
|
3174
|
+
articles: ['el', 'la', 'los', 'las', 'lo'],
|
|
3175
|
+
units: ES_UNITS,
|
|
3176
|
+
weekdays: WEEKDAYS,
|
|
3177
|
+
months: MONTHS,
|
|
3178
|
+
periods: PERIODS,
|
|
3179
|
+
smallNumbers: {
|
|
3180
|
+
...ONES, ...TEENS, ...TENS,
|
|
3181
|
+
unos: 3, unas: 3, algunos: 3, algunas: 3, varios: 3, varias: 3,
|
|
3182
|
+
},
|
|
3183
|
+
deictic: ES_DEICTIC,
|
|
3184
|
+
deicticPhrases: [{ words: ['pasado', 'mañana'], delta: 2 }],
|
|
3185
|
+
preMods: {
|
|
3186
|
+
próximo: 1, proximo: 1, próxima: 1, proxima: 1,
|
|
3187
|
+
este: 0, esta: 0,
|
|
3188
|
+
mismo: 0, misma: 0,
|
|
3189
|
+
siguiente: 1,
|
|
3190
|
+
último: -1, ultimo: -1, última: -1, ultima: -1,
|
|
3191
|
+
},
|
|
3192
|
+
postMods: ES_POST_MODS,
|
|
3193
|
+
postModPhrases: [{ words: ['que', 'viene'], delta: 1 }],
|
|
3194
|
+
agoPrefixes: [['hace']],
|
|
3195
|
+
inPrefixes: [['dentro', 'de'], ['en']],
|
|
3196
|
+
lastNAdjs: ES_LAST_ADJS,
|
|
3197
|
+
nextNAdjs: ES_NEXT_ADJS,
|
|
3198
|
+
atPhrases: [['a', 'las'], ['a', 'la'], ['hacia', 'las']],
|
|
3199
|
+
periodMarkers: [['por', 'la'], ['de', 'la'], ['en', 'la'], ['por', 'el'], ['a', 'la'], ['esta'], ['este']],
|
|
3200
|
+
dateSep: ['de', 'del'],
|
|
3201
|
+
weekendPhrases: [['fin', 'de', 'semana']],
|
|
3202
|
+
specialPhrases: [
|
|
3203
|
+
{
|
|
3204
|
+
// "anoche" = last night
|
|
3205
|
+
words: ['anoche'],
|
|
3206
|
+
expr: {
|
|
3207
|
+
op: 'intersect',
|
|
3208
|
+
parts: [
|
|
3209
|
+
{ op: 'snap', base: { op: 'offset', base: NOW, amount: -1, unit: 'day' }, unit: 'day' },
|
|
3210
|
+
{ op: 'literal', dayPeriod: 'night' },
|
|
3211
|
+
],
|
|
3212
|
+
},
|
|
3213
|
+
},
|
|
3214
|
+
{ words: ['ahora', 'mismo'], expr: NOW, role: 'datetime' },
|
|
3215
|
+
{ words: ['ahora'], expr: NOW, role: 'datetime' },
|
|
3216
|
+
{
|
|
3217
|
+
// "esta madrugada" → the small hours of today (04:00–08:00).
|
|
3218
|
+
words: ['esta', 'madrugada'],
|
|
3219
|
+
expr: {
|
|
3220
|
+
op: 'between',
|
|
3221
|
+
start: { op: 'literal', time: { hour: 4, meridiem: 'am' } },
|
|
3222
|
+
end: { op: 'literal', time: { hour: 8, meridiem: 'am' } },
|
|
3223
|
+
},
|
|
3224
|
+
role: 'time',
|
|
3225
|
+
},
|
|
3226
|
+
],
|
|
3227
|
+
noonWords: ['mediodía', 'mediodia'],
|
|
3228
|
+
midnightWords: ['medianoche'],
|
|
3229
|
+
thousandWords: ['mil'],
|
|
3230
|
+
rangeFrom: [['desde'], ['entre'], ['de']],
|
|
3231
|
+
rangeTo: [['hasta'], ['a']],
|
|
3232
|
+
rangeAnd: ['y'],
|
|
3233
|
+
durationTriggers: ['durante', 'durará', 'durara', 'dura'],
|
|
3234
|
+
dateOrder: 'DMY',
|
|
3235
|
+
dayOrdinals: {
|
|
3236
|
+
primero: 1, primer: 1, segundo: 2, tercero: 3, cuarto: 4, quinto: 5,
|
|
3237
|
+
},
|
|
3238
|
+
endOfMarkers: [
|
|
3239
|
+
['al', 'final', 'de'], ['al', 'final', 'del'], ['al', 'fin', 'de'], ['al', 'fin', 'del'],
|
|
3240
|
+
],
|
|
3241
|
+
firstAdjs: ['primera', 'primer'],
|
|
3242
|
+
lastAdjs: ['última', 'ultima'],
|
|
3243
|
+
};
|
|
3244
|
+
const esExtras = [
|
|
3245
|
+
{ name: 'es-range', rule: esRange },
|
|
3246
|
+
{ name: 'es-week-of', rule: esWeekOf },
|
|
3247
|
+
{ name: 'es-nth-weekday-of-month', rule: esNthWeekdayOfMonth },
|
|
3248
|
+
{ name: 'es-weekday-of-next-week', rule: esWeekdayOfNextWeek },
|
|
3249
|
+
{ name: 'es-week-after-next', rule: esWeekAfterNext },
|
|
3250
|
+
{ name: 'es-este-proximo', rule: esEsteProximo },
|
|
3251
|
+
{ name: 'es-n-after-date', rule: esNAfterDate },
|
|
3252
|
+
{ name: 'es-dentro-de', rule: esDentroDe },
|
|
3253
|
+
{ name: 'es-first-last-n-of-scope', rule: esFirstLastNOfScope },
|
|
3254
|
+
{ name: 'es-n-unit-span', rule: esNUnitSpan },
|
|
3255
|
+
{ name: 'es-early-late', rule: esEarlyLate },
|
|
3256
|
+
{ name: 'es-edge', rule: esEdge },
|
|
3257
|
+
{ name: 'es-year-to-date', rule: esYearToDate },
|
|
3258
|
+
{ name: 'es-year-anterior-a', rule: esYearAnteriorA },
|
|
3259
|
+
{ name: 'es-rest-of', rule: esRestOf },
|
|
3260
|
+
{ name: 'es-quarter', rule: esQuarter },
|
|
3261
|
+
{ name: 'es-ordinal-quarter', rule: esOrdinalQuarter },
|
|
3262
|
+
{ name: 'es-decade', rule: esDecade },
|
|
3263
|
+
{ name: 'es-century', rule: esCentury },
|
|
3264
|
+
{ name: 'es-cal-year', rule: esCalYear },
|
|
3265
|
+
{ name: 'es-duration', rule: esDuration },
|
|
3266
|
+
{ name: 'es-all-unit', rule: esAllUnit },
|
|
3267
|
+
{ name: 'es-holiday', rule: esHoliday },
|
|
3268
|
+
{ name: 'es-specials', rule: esSpecials },
|
|
3269
|
+
{ name: 'es-deictic-period', rule: esDeicticPeriod },
|
|
3270
|
+
{ name: 'es-time', rule: esTime },
|
|
3271
|
+
{ name: 'es-day-month', rule: esDayMonth },
|
|
3272
|
+
{ name: 'es-month-first', rule: esMonthFirst },
|
|
3273
|
+
{ name: 'es-month-dash-month', rule: esMonthDashMonth },
|
|
3274
|
+
{ name: 'es-spaced-date', rule: esSpacedDate },
|
|
3275
|
+
{ name: 'es-numdate', rule: esNumdate },
|
|
3276
|
+
{ name: 'es-weekday-day', rule: esWeekdayDay },
|
|
3277
|
+
{ name: 'es-bare-day', rule: esBareDay },
|
|
3278
|
+
{ name: 'es-bare-unit', rule: esBareUnit },
|
|
3279
|
+
{ name: 'es-bare-year', rule: esBareYear },
|
|
3280
|
+
];
|
|
3281
|
+
export const ES_RULE_ENTRIES = makeLatinRules(ES_LEXICON, esExtras);
|
|
3282
|
+
/** Connector words allowed between merged date & time parts. */
|
|
3283
|
+
export const ES_CONNECTORS = [
|
|
3284
|
+
'a', 'al', 'las', 'la', 'el', 'los', 'de', 'del', 'por', 'en', 'para',
|
|
3285
|
+
'hacia', 'este', 'esta', 'entre', '-', '~',
|
|
3286
|
+
];
|