nl2time 0.1.1 → 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 +35 -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 +55 -30
- 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 +3 -3
package/dist/parse/fr.js
ADDED
|
@@ -0,0 +1,2358 @@
|
|
|
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
|
+
/** "d'avril" → "avril", "l'année" → "année". */
|
|
13
|
+
function stripElision(w) {
|
|
14
|
+
if (w === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
return /^[dl]['']/.test(w) ? w.slice(2) : w;
|
|
17
|
+
}
|
|
18
|
+
const MONTHS = {
|
|
19
|
+
janvier: 1, janv: 1, jan: 1,
|
|
20
|
+
février: 2, fevrier: 2, févr: 2, fevr: 2, fév: 2, fev: 2,
|
|
21
|
+
mars: 3,
|
|
22
|
+
avril: 4, avr: 4,
|
|
23
|
+
mai: 5,
|
|
24
|
+
juin: 6,
|
|
25
|
+
juillet: 7, juil: 7,
|
|
26
|
+
août: 8, aout: 8,
|
|
27
|
+
septembre: 9, sept: 9, sep: 9,
|
|
28
|
+
octobre: 10, oct: 10,
|
|
29
|
+
novembre: 11, nov: 11,
|
|
30
|
+
décembre: 12, decembre: 12, déc: 12, dec: 12,
|
|
31
|
+
};
|
|
32
|
+
const WEEKDAYS = {
|
|
33
|
+
lundi: 'mon', mardi: 'tue', mercredi: 'wed', mer: 'wed',
|
|
34
|
+
jeudi: 'thu', jeu: 'thu', vendredi: 'fri', ven: 'fri',
|
|
35
|
+
samedi: 'sat', sam: 'sat', dimanche: 'sun', dim: 'sun', lun: 'mon',
|
|
36
|
+
};
|
|
37
|
+
const PERIODS = {
|
|
38
|
+
matin: 'morning', matinée: 'morning', matinee: 'morning',
|
|
39
|
+
'après-midi': 'afternoon', "l'après-midi": 'afternoon', 'apres-midi': 'afternoon',
|
|
40
|
+
soir: 'evening', soirée: 'evening', soiree: 'evening',
|
|
41
|
+
nuit: 'night',
|
|
42
|
+
};
|
|
43
|
+
const ONES = {
|
|
44
|
+
un: 1, une: 1, deux: 2, trois: 3, quatre: 4, cinq: 5, six: 6, sept: 7,
|
|
45
|
+
huit: 8, neuf: 9,
|
|
46
|
+
};
|
|
47
|
+
const TEENS = {
|
|
48
|
+
dix: 10, onze: 11, douze: 12, treize: 13, quatorze: 14, quinze: 15, seize: 16,
|
|
49
|
+
};
|
|
50
|
+
const TENS = {
|
|
51
|
+
vingt: 20, trente: 30, quarante: 40, cinquante: 50, soixante: 60,
|
|
52
|
+
};
|
|
53
|
+
/** "1er", "4e", "3ème", "1ère" → day-of-month value. */
|
|
54
|
+
function ordinalWord(w) {
|
|
55
|
+
const m = w?.match(/^(\d{1,2})(?:er|ère|ere|re|e|ème|eme)$/);
|
|
56
|
+
return m ? Number(m[1]) : undefined;
|
|
57
|
+
}
|
|
58
|
+
const ORDINAL_WORDS = {
|
|
59
|
+
premier: 1, première: 1, premiere: 1,
|
|
60
|
+
deuxième: 2, deuxieme: 2, second: 2, seconde: 2,
|
|
61
|
+
troisième: 3, troisieme: 3,
|
|
62
|
+
quatrième: 4, quatrieme: 4,
|
|
63
|
+
cinquième: 5, cinquieme: 5,
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* French number words, including split hyphen compounds ("vingt · - · trois")
|
|
67
|
+
* and "vingt et un". Returns undefined when the tokens are not a number.
|
|
68
|
+
*/
|
|
69
|
+
function readWordNum(tokens, i) {
|
|
70
|
+
const t = tokens[i];
|
|
71
|
+
if (t?.type === 'number' && !t.ordinal)
|
|
72
|
+
return { value: t.value, consumed: 1 };
|
|
73
|
+
const ow = ordinalWord(word(t));
|
|
74
|
+
if (ow !== undefined)
|
|
75
|
+
return { value: ow, consumed: 1 };
|
|
76
|
+
const w = word(t);
|
|
77
|
+
if (w === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (ONES[w] !== undefined)
|
|
80
|
+
return { value: ONES[w], consumed: 1 };
|
|
81
|
+
if (ORDINAL_WORDS[w] !== undefined)
|
|
82
|
+
return { value: ORDINAL_WORDS[w], consumed: 1 };
|
|
83
|
+
if (w === 'quelques' || w === 'plusieurs')
|
|
84
|
+
return { value: 3, consumed: 1 };
|
|
85
|
+
// Corpus typo tolerance: "quel ques minutes".
|
|
86
|
+
if (w === 'quel' && word(tokens[i + 1]) === 'ques')
|
|
87
|
+
return { value: 3, consumed: 2 };
|
|
88
|
+
if (TEENS[w] !== undefined) {
|
|
89
|
+
// "dix - sept" → 17 (split hyphen compound).
|
|
90
|
+
if (w === 'dix' && word(tokens[i + 1]) === '-') {
|
|
91
|
+
const o = ONES[word(tokens[i + 2]) ?? ''];
|
|
92
|
+
if (o !== undefined && o >= 7)
|
|
93
|
+
return { value: 10 + o, consumed: 3 };
|
|
94
|
+
}
|
|
95
|
+
return { value: TEENS[w], consumed: 1 };
|
|
96
|
+
}
|
|
97
|
+
if (TENS[w] !== undefined) {
|
|
98
|
+
const base = TENS[w];
|
|
99
|
+
if (word(tokens[i + 1]) === '-') {
|
|
100
|
+
const next = word(tokens[i + 2]) ?? '';
|
|
101
|
+
const o = ONES[next] ?? (base === 60 ? TEENS[next] : undefined);
|
|
102
|
+
if (o !== undefined)
|
|
103
|
+
return { value: base + o, consumed: 3 };
|
|
104
|
+
}
|
|
105
|
+
if (word(tokens[i + 1]) === 'et') {
|
|
106
|
+
const next = word(tokens[i + 2]) ?? '';
|
|
107
|
+
const o = ONES[next] ?? (base === 60 && next === 'onze' ? 11 : undefined);
|
|
108
|
+
if (o !== undefined)
|
|
109
|
+
return { value: base + o, consumed: 3 };
|
|
110
|
+
}
|
|
111
|
+
return { value: base, consumed: 1 };
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function yearAt(tokens, i) {
|
|
116
|
+
const t = tokens[i];
|
|
117
|
+
return t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 2999 ? t.value : undefined;
|
|
118
|
+
}
|
|
119
|
+
function century(y) {
|
|
120
|
+
return y >= 100 ? y : y >= 50 ? 1900 + y : 2000 + y;
|
|
121
|
+
}
|
|
122
|
+
/** numdate token → DMY partial date. */
|
|
123
|
+
function numdateToDate(t) {
|
|
124
|
+
if (t.type !== 'numdate')
|
|
125
|
+
return undefined;
|
|
126
|
+
const p = t.parts;
|
|
127
|
+
const valid = (d) => d.month !== undefined && d.month >= 1 && d.month <= 12 && d.day !== undefined && d.day >= 1 && d.day <= 31
|
|
128
|
+
? d
|
|
129
|
+
: undefined;
|
|
130
|
+
if (p.length === 2) {
|
|
131
|
+
return valid({ day: p[0], month: p[1] }) ?? valid({ day: p[1], month: p[0] });
|
|
132
|
+
}
|
|
133
|
+
const [a, b, c] = [p[0], p[1], p[2]];
|
|
134
|
+
if (a >= 1000)
|
|
135
|
+
return valid({ year: a, month: b, day: c });
|
|
136
|
+
return (valid({ day: a, month: b, year: century(c) }) ?? valid({ day: b, month: a, year: century(c) }));
|
|
137
|
+
}
|
|
138
|
+
function timeLiteral(time) {
|
|
139
|
+
return { op: 'literal', time };
|
|
140
|
+
}
|
|
141
|
+
function meridiemFor(period) {
|
|
142
|
+
return period === 'morning' ? 'am' : 'pm';
|
|
143
|
+
}
|
|
144
|
+
/** A day-period noun, elided or split ("apres · - · midi"). */
|
|
145
|
+
function readPeriodWord(tokens, i) {
|
|
146
|
+
const w = word(tokens[i]);
|
|
147
|
+
const period = PERIODS[w ?? ''] ?? PERIODS[stripElision(w) ?? ''];
|
|
148
|
+
if (period !== undefined)
|
|
149
|
+
return { period, consumed: 1 };
|
|
150
|
+
if ((w === 'apres' || w === 'après') && word(tokens[i + 1]) === '-' && word(tokens[i + 2]) === 'midi') {
|
|
151
|
+
return { period: 'afternoon', consumed: 3 };
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Period phrase after a clock time: "du matin", "du soir", "de l'après-midi",
|
|
157
|
+
* "de la soirée". Returns the period and tokens consumed.
|
|
158
|
+
*/
|
|
159
|
+
function readPeriodSuffix(tokens, i) {
|
|
160
|
+
let at = i;
|
|
161
|
+
const w0 = word(tokens[at]);
|
|
162
|
+
if (w0 !== 'du' && w0 !== 'de' && w0 !== 'dans')
|
|
163
|
+
return undefined;
|
|
164
|
+
at += 1;
|
|
165
|
+
if (word(tokens[at]) === 'la' || word(tokens[at]) === 'le')
|
|
166
|
+
at += 1;
|
|
167
|
+
const pw = readPeriodWord(tokens, at);
|
|
168
|
+
if (pw === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
return { period: pw.period, consumed: at + pw.consumed - i };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Clock-time phrase:
|
|
174
|
+
* [à|a|vers] 15h30 | 15h | 14:30 | N heures [et|moins] M [minutes] |
|
|
175
|
+
* midi | minuit — with optional "du matin/soir / de l'après-midi" suffix.
|
|
176
|
+
*/
|
|
177
|
+
function readTime(tokens, i) {
|
|
178
|
+
let at = i;
|
|
179
|
+
const t = tokens[at];
|
|
180
|
+
const w = word(t);
|
|
181
|
+
// "15h30" / "15h" / "4h45" (single word token).
|
|
182
|
+
const hm = w?.match(/^(\d{1,2})h(\d{2})?$/);
|
|
183
|
+
if (hm) {
|
|
184
|
+
const hour = Number(hm[1]);
|
|
185
|
+
if (hour > 23)
|
|
186
|
+
return undefined;
|
|
187
|
+
const time = { hour };
|
|
188
|
+
if (hm[2] !== undefined)
|
|
189
|
+
time.minute = Number(hm[2]);
|
|
190
|
+
if (hour <= 12)
|
|
191
|
+
time.meridiem = 'unknown';
|
|
192
|
+
return { time, consumed: at + 1 - i, bare: false };
|
|
193
|
+
}
|
|
194
|
+
if (t?.type === 'clock') {
|
|
195
|
+
const time = { hour: t.hour };
|
|
196
|
+
if (t.explicitMinute)
|
|
197
|
+
time.minute = t.minute;
|
|
198
|
+
if (t.second !== undefined)
|
|
199
|
+
time.second = t.second;
|
|
200
|
+
if (t.meridiem)
|
|
201
|
+
time.meridiem = t.meridiem;
|
|
202
|
+
else if (t.hour <= 12)
|
|
203
|
+
time.meridiem = 'unknown';
|
|
204
|
+
return { time, consumed: at + 1 - i, bare: false };
|
|
205
|
+
}
|
|
206
|
+
if (w === 'midi')
|
|
207
|
+
return { time: { hour: 12, meridiem: 'pm' }, consumed: 1, bare: false };
|
|
208
|
+
if (w === 'minuit')
|
|
209
|
+
return { time: { hour: 12, meridiem: 'am' }, consumed: 1, bare: false };
|
|
210
|
+
// "3p.m" / "11ish" — joined meridiem/approx suffixes the tokenizer keeps.
|
|
211
|
+
const pm = w?.match(/^(\d{1,2})(?:[.:h](\d{2}))?([ap])\.?m?$/);
|
|
212
|
+
if (pm && (pm[3] === 'a' || pm[3] === 'p') && /[ap]\.?m$/.test(w)) {
|
|
213
|
+
const hour = Number(pm[1]);
|
|
214
|
+
if (hour >= 1 && hour <= 12) {
|
|
215
|
+
const time = { hour, meridiem: pm[3] === 'a' ? 'am' : 'pm' };
|
|
216
|
+
if (pm[2] !== undefined)
|
|
217
|
+
time.minute = Number(pm[2]);
|
|
218
|
+
return { time, consumed: 1, bare: false };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const ish = w?.match(/^(\d{1,2})ish$/);
|
|
222
|
+
if (ish) {
|
|
223
|
+
const hour = Number(ish[1]);
|
|
224
|
+
if (hour <= 23) {
|
|
225
|
+
const time = { hour };
|
|
226
|
+
if (hour <= 12)
|
|
227
|
+
time.meridiem = 'unknown';
|
|
228
|
+
return { time, consumed: 1, bare: false };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// "1140 a.m." — military-style number + meridiem word.
|
|
232
|
+
if (t?.type === 'number' && t.value >= 100 && t.value <= 1259) {
|
|
233
|
+
const mw = word(tokens[at + 1]);
|
|
234
|
+
if (mw === 'am' || mw === 'pm') {
|
|
235
|
+
const hour = Math.floor(t.value / 100);
|
|
236
|
+
const minute = t.value % 100;
|
|
237
|
+
if (minute < 60) {
|
|
238
|
+
return { time: { hour, minute, meridiem: mw }, consumed: 2, bare: false };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// "N heures [M | et M | moins M | et demie/quart]" — word or digit hour.
|
|
243
|
+
const n = readWordNum(tokens, at);
|
|
244
|
+
if (!n || n.value > 23)
|
|
245
|
+
return undefined;
|
|
246
|
+
at += n.consumed;
|
|
247
|
+
let hw = word(tokens[at]);
|
|
248
|
+
// "sept et demie heures" — fraction before the hour word.
|
|
249
|
+
if (hw === 'et' && (word(tokens[at + 1]) === 'demie' || word(tokens[at + 1]) === 'demi' || word(tokens[at + 1]) === 'quart')) {
|
|
250
|
+
const fw = word(tokens[at + 1]);
|
|
251
|
+
const hw2 = word(tokens[at + 2]);
|
|
252
|
+
if (hw2 === 'heures' || hw2 === 'heure') {
|
|
253
|
+
const time = { hour: n.value, minute: fw === 'quart' ? 15 : 30 };
|
|
254
|
+
if (n.value <= 12)
|
|
255
|
+
time.meridiem = 'unknown';
|
|
256
|
+
return { time, consumed: at + 3 - i, bare: true };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (hw !== 'heures' && hw !== 'heure' && hw !== 'h')
|
|
260
|
+
return undefined;
|
|
261
|
+
at += 1;
|
|
262
|
+
let hour = n.value;
|
|
263
|
+
let minute;
|
|
264
|
+
const mw = word(tokens[at]);
|
|
265
|
+
if (mw === 'et') {
|
|
266
|
+
const fw = word(tokens[at + 1]);
|
|
267
|
+
if (fw === 'demie' || fw === 'demi') {
|
|
268
|
+
minute = 30;
|
|
269
|
+
at += 2;
|
|
270
|
+
}
|
|
271
|
+
else if (fw === 'quart') {
|
|
272
|
+
minute = 15;
|
|
273
|
+
at += 2;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const m = readWordNum(tokens, at + 1);
|
|
277
|
+
if (m && m.value <= 59) {
|
|
278
|
+
minute = m.value;
|
|
279
|
+
at += 1 + m.consumed;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (mw === 'moins') {
|
|
284
|
+
if (word(tokens[at + 1]) === 'le' && word(tokens[at + 2]) === 'quart') {
|
|
285
|
+
hour = hour === 0 ? 23 : hour - 1;
|
|
286
|
+
minute = 45;
|
|
287
|
+
at += 3;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const m = readWordNum(tokens, at + 1);
|
|
291
|
+
if (m && m.value <= 59) {
|
|
292
|
+
hour = hour === 0 ? 23 : hour - 1;
|
|
293
|
+
minute = 60 - m.value;
|
|
294
|
+
at += 1 + m.consumed;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const m = readWordNum(tokens, at);
|
|
300
|
+
if (m && m.value <= 59 && tokens[at]?.type !== 'number' ? true : m && m.value <= 59) {
|
|
301
|
+
// Plain trailing minutes: "sept heures dix", "8 h 20".
|
|
302
|
+
if (m && m.value <= 59) {
|
|
303
|
+
minute = m.value;
|
|
304
|
+
at += m.consumed;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (word(tokens[at]) === 'minutes' || word(tokens[at]) === 'minute')
|
|
309
|
+
at += 1;
|
|
310
|
+
const time = { hour };
|
|
311
|
+
if (minute !== undefined)
|
|
312
|
+
time.minute = minute;
|
|
313
|
+
if (hour <= 12)
|
|
314
|
+
time.meridiem = 'unknown';
|
|
315
|
+
return { time, consumed: at - i, bare: true };
|
|
316
|
+
}
|
|
317
|
+
/** Apply a day-period's meridiem to an ambiguous time. */
|
|
318
|
+
function applyPeriod(time, period) {
|
|
319
|
+
if (time.meridiem !== undefined && time.meridiem !== 'unknown')
|
|
320
|
+
return time;
|
|
321
|
+
return { ...time, meridiem: meridiemFor(period) };
|
|
322
|
+
}
|
|
323
|
+
/* ------------------------------------------------------------------ */
|
|
324
|
+
/* Extras */
|
|
325
|
+
/* ------------------------------------------------------------------ */
|
|
326
|
+
/** "à 15h30" / "16h" / "vingt heures" / "sept heures moins dix" / "midi". */
|
|
327
|
+
const frTime = (tokens, i) => {
|
|
328
|
+
let at = i;
|
|
329
|
+
const lead = word(tokens[at]);
|
|
330
|
+
const hasLead = lead === 'à' || lead === 'a' || lead === 'vers';
|
|
331
|
+
if (hasLead)
|
|
332
|
+
at += 1;
|
|
333
|
+
const read = readTime(tokens, at);
|
|
334
|
+
if (!read) {
|
|
335
|
+
// "10, ce soir" — a bare hour licensed by a following demonstrative +
|
|
336
|
+
// period; emit just the hour and let the merger attach the period.
|
|
337
|
+
const t = tokens[at];
|
|
338
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 1 && t.value <= 12) {
|
|
339
|
+
const dw = word(tokens[at + 1]);
|
|
340
|
+
if ((dw === 'ce' || dw === 'cette') && readPeriodWord(tokens, at + 2) !== undefined) {
|
|
341
|
+
return {
|
|
342
|
+
expr: timeLiteral({ hour: t.value, meridiem: 'unknown' }),
|
|
343
|
+
consumed: at + 1 - i,
|
|
344
|
+
confidence: 0.9,
|
|
345
|
+
role: 'time',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
// A bare word-number time without any lead is too loose only when it is a
|
|
352
|
+
// lone number; "N heures" already requires the hour word, so accept.
|
|
353
|
+
at += read.consumed;
|
|
354
|
+
let time = read.time;
|
|
355
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
356
|
+
if (ps) {
|
|
357
|
+
time = applyPeriod(time, ps.period);
|
|
358
|
+
at += ps.consumed;
|
|
359
|
+
}
|
|
360
|
+
// "à 16h - 18h30" → time range.
|
|
361
|
+
if (word(tokens[at]) === '-') {
|
|
362
|
+
const second = readTime(tokens, at + 1);
|
|
363
|
+
if (second) {
|
|
364
|
+
return {
|
|
365
|
+
expr: {
|
|
366
|
+
op: 'between',
|
|
367
|
+
start: timeLiteral(time),
|
|
368
|
+
end: timeLiteral(second.time),
|
|
369
|
+
},
|
|
370
|
+
consumed: at + 1 + second.consumed - i,
|
|
371
|
+
confidence: 0.95,
|
|
372
|
+
role: 'time',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
expr: timeLiteral(time),
|
|
378
|
+
consumed: at - i,
|
|
379
|
+
confidence: 1,
|
|
380
|
+
role: 'time',
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
/** "dans les 2h" → the next 2 hours (rolling span). */
|
|
384
|
+
const frInLesH = (tokens, i) => {
|
|
385
|
+
if (word(tokens[i]) !== 'dans')
|
|
386
|
+
return undefined;
|
|
387
|
+
let at = i + 1;
|
|
388
|
+
if (word(tokens[at]) === 'les')
|
|
389
|
+
at += 1;
|
|
390
|
+
const m = word(tokens[at])?.match(/^(\d{1,3})h$/);
|
|
391
|
+
if (!m)
|
|
392
|
+
return undefined;
|
|
393
|
+
return {
|
|
394
|
+
expr: { op: 'span', anchor: NOW, amount: { hours: Number(m[1]) } },
|
|
395
|
+
consumed: at + 1 - i,
|
|
396
|
+
confidence: 1,
|
|
397
|
+
role: 'datetime',
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
/** Numeric dates: numdate tokens (22/04, 01/01/2015) and joined words. */
|
|
401
|
+
const frNumdate = (tokens, i) => {
|
|
402
|
+
const t = tokens[i];
|
|
403
|
+
if (t?.type === 'numdate') {
|
|
404
|
+
const date = numdateToDate(t);
|
|
405
|
+
if (!date)
|
|
406
|
+
return undefined;
|
|
407
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 1, role: 'date' };
|
|
408
|
+
}
|
|
409
|
+
const w = word(t);
|
|
410
|
+
if (w === undefined)
|
|
411
|
+
return undefined;
|
|
412
|
+
// "2014-2018" / "2014~2018" → year range.
|
|
413
|
+
let m = w.match(/^([12]\d{3})[-~]([12]\d{3})$/);
|
|
414
|
+
if (m && Number(m[1]) < Number(m[2])) {
|
|
415
|
+
return {
|
|
416
|
+
expr: {
|
|
417
|
+
op: 'between',
|
|
418
|
+
start: { op: 'literal', date: { year: Number(m[1]) } },
|
|
419
|
+
end: { op: 'literal', date: { year: Number(m[2]) } },
|
|
420
|
+
},
|
|
421
|
+
consumed: 1,
|
|
422
|
+
confidence: 0.95,
|
|
423
|
+
role: 'date',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// "2015-3" / "12-2015" → year-month.
|
|
427
|
+
m = w.match(/^([12]\d{3})-(\d{1,2})$/);
|
|
428
|
+
if (m && Number(m[2]) >= 1 && Number(m[2]) <= 12) {
|
|
429
|
+
return {
|
|
430
|
+
expr: { op: 'literal', date: { year: Number(m[1]), month: Number(m[2]) } },
|
|
431
|
+
consumed: 1,
|
|
432
|
+
confidence: 0.95,
|
|
433
|
+
role: 'date',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
m = w.match(/^(\d{1,2})-([12]\d{3})$/);
|
|
437
|
+
if (m && Number(m[1]) >= 1 && Number(m[1]) <= 12) {
|
|
438
|
+
return {
|
|
439
|
+
expr: { op: 'literal', date: { year: Number(m[2]), month: Number(m[1]) } },
|
|
440
|
+
consumed: 1,
|
|
441
|
+
confidence: 0.95,
|
|
442
|
+
role: 'date',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// "28/2-1/3[/2017]" → joined numeric-date range (day/month order).
|
|
446
|
+
m = w.match(/^(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})(?:\/(\d{4}))?$/);
|
|
447
|
+
if (m) {
|
|
448
|
+
const year = m[5] !== undefined ? Number(m[5]) : yearAt(tokens, i + 1);
|
|
449
|
+
const mk = (d, mo) => mo >= 1 && mo <= 12 && d >= 1 && d <= 31
|
|
450
|
+
? year !== undefined
|
|
451
|
+
? { day: d, month: mo, year }
|
|
452
|
+
: { day: d, month: mo }
|
|
453
|
+
: undefined;
|
|
454
|
+
const d1 = mk(Number(m[1]), Number(m[2]));
|
|
455
|
+
const d2 = mk(Number(m[3]), Number(m[4]));
|
|
456
|
+
if (d1 && d2) {
|
|
457
|
+
const extra = m[5] === undefined && year !== undefined ? 1 : 0;
|
|
458
|
+
return {
|
|
459
|
+
expr: {
|
|
460
|
+
op: 'between',
|
|
461
|
+
start: { op: 'literal', date: d1 },
|
|
462
|
+
end: { op: 'literal', date: d2 },
|
|
463
|
+
},
|
|
464
|
+
consumed: 1 + extra,
|
|
465
|
+
confidence: 0.95,
|
|
466
|
+
role: 'date',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// "10-1-2018-10-7-2018" → joined M-D-Y date range (Recognizers uses
|
|
471
|
+
// month-first in this dashed form even in the French specs).
|
|
472
|
+
m = w.match(/^(\d{1,2})-(\d{1,2})-(\d{4})-(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
|
473
|
+
if (m) {
|
|
474
|
+
const mk = (mo, d, y) => mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? { year: y, month: mo, day: d } : undefined;
|
|
475
|
+
const d1 = mk(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
476
|
+
const d2 = mk(Number(m[4]), Number(m[5]), Number(m[6]));
|
|
477
|
+
if (d1 && d2) {
|
|
478
|
+
return {
|
|
479
|
+
expr: { op: 'between', start: { op: 'literal', date: d1 }, end: { op: 'literal', date: d2 } },
|
|
480
|
+
consumed: 1,
|
|
481
|
+
confidence: 0.9,
|
|
482
|
+
role: 'date',
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// "décembre/2018-mai/2019" → month/year range in one token.
|
|
487
|
+
m = w.match(/^([a-zà-ÿû]+)\/(\d{4})-([a-zà-ÿû]+)\/(\d{4})$/);
|
|
488
|
+
if (m) {
|
|
489
|
+
const m1 = MONTHS[m[1]];
|
|
490
|
+
const m2 = MONTHS[m[3]];
|
|
491
|
+
if (m1 !== undefined && m2 !== undefined) {
|
|
492
|
+
return {
|
|
493
|
+
expr: {
|
|
494
|
+
op: 'between',
|
|
495
|
+
start: { op: 'literal', date: { month: m1, year: Number(m[2]) } },
|
|
496
|
+
end: { op: 'literal', date: { month: m2, year: Number(m[4]) } },
|
|
497
|
+
},
|
|
498
|
+
consumed: 1,
|
|
499
|
+
confidence: 0.95,
|
|
500
|
+
role: 'date',
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// "2019-août-1" / "août-2019" — dash-joined with an accented month name
|
|
505
|
+
// (accents block the tokenizer's dash split).
|
|
506
|
+
if (w.includes('-')) {
|
|
507
|
+
const segments = w.split('-');
|
|
508
|
+
if (segments.length >= 2 && segments.length <= 3 && segments.every((s) => s.length > 0)) {
|
|
509
|
+
let month;
|
|
510
|
+
let day;
|
|
511
|
+
let year;
|
|
512
|
+
let ok = true;
|
|
513
|
+
for (const seg of segments) {
|
|
514
|
+
const mo = MONTHS[seg];
|
|
515
|
+
if (mo !== undefined && month === undefined) {
|
|
516
|
+
month = mo;
|
|
517
|
+
}
|
|
518
|
+
else if (/^[12]\d{3}$/.test(seg) && year === undefined) {
|
|
519
|
+
year = Number(seg);
|
|
520
|
+
}
|
|
521
|
+
else if (/^\d{1,2}$/.test(seg) && day === undefined && Number(seg) >= 1 && Number(seg) <= 31) {
|
|
522
|
+
day = Number(seg);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
ok = false;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (ok && month !== undefined && (day !== undefined || year !== undefined)) {
|
|
530
|
+
const date = { month };
|
|
531
|
+
if (day !== undefined)
|
|
532
|
+
date.day = day;
|
|
533
|
+
if (year !== undefined)
|
|
534
|
+
date.year = year;
|
|
535
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.95, role: 'date' };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// "2019.0" → the year (stray decimal).
|
|
540
|
+
m = w.match(/^([12]\d{3})\.0$/);
|
|
541
|
+
if (m) {
|
|
542
|
+
return { expr: { op: 'literal', date: { year: Number(m[1]) } }, consumed: 1, confidence: 0.8, role: 'date' };
|
|
543
|
+
}
|
|
544
|
+
// "16-17" / "3-8" → joined hour pair.
|
|
545
|
+
m = w.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
546
|
+
if (m) {
|
|
547
|
+
const h1 = Number(m[1]);
|
|
548
|
+
const h2 = Number(m[2]);
|
|
549
|
+
if (h1 <= 23 && h2 <= 23) {
|
|
550
|
+
const mk = (h) => (h <= 12 ? { hour: h, meridiem: 'unknown' } : { hour: h });
|
|
551
|
+
return {
|
|
552
|
+
expr: { op: 'between', start: timeLiteral(mk(h1)), end: timeLiteral(mk(h2)) },
|
|
553
|
+
consumed: 1,
|
|
554
|
+
confidence: 0.85,
|
|
555
|
+
role: 'time',
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
};
|
|
561
|
+
/**
|
|
562
|
+
* Day-month dates the factory can't read: ordinal words ("le 1er janvier
|
|
563
|
+
* 2019", "le 4e jour de juillet de 1995") and word-number days.
|
|
564
|
+
*/
|
|
565
|
+
const frDayMonth = (tokens, i) => {
|
|
566
|
+
let at = i;
|
|
567
|
+
const w0 = word(tokens[at]);
|
|
568
|
+
if (w0 === 'le' || w0 === 'ce')
|
|
569
|
+
at += 1;
|
|
570
|
+
const day = readWordNum(tokens, at);
|
|
571
|
+
if (!day || day.value < 1 || day.value > 31)
|
|
572
|
+
return undefined;
|
|
573
|
+
let cursor = at + day.consumed;
|
|
574
|
+
if (word(tokens[cursor]) === 'jour')
|
|
575
|
+
cursor += 1;
|
|
576
|
+
let monthWord = word(tokens[cursor]);
|
|
577
|
+
if (monthWord === 'de' || monthWord === 'du') {
|
|
578
|
+
cursor += 1;
|
|
579
|
+
monthWord = word(tokens[cursor]);
|
|
580
|
+
}
|
|
581
|
+
const month = MONTHS[monthWord ?? ''] ?? MONTHS[stripElision(monthWord) ?? ''];
|
|
582
|
+
if (month === undefined)
|
|
583
|
+
return undefined;
|
|
584
|
+
cursor += 1;
|
|
585
|
+
let yAt = cursor;
|
|
586
|
+
if (word(tokens[yAt]) === 'de' || word(tokens[yAt]) === 'du')
|
|
587
|
+
yAt += 1;
|
|
588
|
+
const year = yearAt(tokens, yAt);
|
|
589
|
+
const date = { month, day: day.value };
|
|
590
|
+
if (year !== undefined)
|
|
591
|
+
date.year = year;
|
|
592
|
+
return {
|
|
593
|
+
expr: { op: 'literal', date },
|
|
594
|
+
consumed: (year !== undefined ? yAt + 1 : cursor) - i,
|
|
595
|
+
confidence: 1,
|
|
596
|
+
role: 'date',
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
/** Month-first forms: "Mai vingt-neuf", "novembre 23 de 1987", "juillet 98". */
|
|
600
|
+
const frMonthFirst = (tokens, i) => {
|
|
601
|
+
const w = word(tokens[i]);
|
|
602
|
+
const month = MONTHS[w ?? ''] ?? MONTHS[stripElision(w) ?? ''];
|
|
603
|
+
if (month === undefined)
|
|
604
|
+
return undefined;
|
|
605
|
+
let at = i + 1;
|
|
606
|
+
if (word(tokens[at]) === '-')
|
|
607
|
+
at += 1;
|
|
608
|
+
// "novembre 19-20" → day range within the month.
|
|
609
|
+
const pair = word(tokens[at])?.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
610
|
+
if (pair && Number(pair[1]) <= 31 && Number(pair[2]) <= 31) {
|
|
611
|
+
return {
|
|
612
|
+
expr: {
|
|
613
|
+
op: 'between',
|
|
614
|
+
start: { op: 'literal', date: { month, day: Number(pair[1]) } },
|
|
615
|
+
end: { op: 'literal', date: { month, day: Number(pair[2]) } },
|
|
616
|
+
},
|
|
617
|
+
consumed: at + 1 - i,
|
|
618
|
+
confidence: 0.9,
|
|
619
|
+
role: 'date',
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const day = readWordNum(tokens, at);
|
|
623
|
+
if (!day)
|
|
624
|
+
return undefined;
|
|
625
|
+
// "juillet 98" → July 1998.
|
|
626
|
+
if (day.consumed === 1 && tokens[at]?.type === 'number' && day.value > 31 && day.value <= 99) {
|
|
627
|
+
return {
|
|
628
|
+
expr: { op: 'literal', date: { month, year: century(day.value) } },
|
|
629
|
+
consumed: at + 1 - i,
|
|
630
|
+
confidence: 0.9,
|
|
631
|
+
role: 'date',
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// "septembre-2020-23" → month · year · day.
|
|
635
|
+
if (day.consumed === 1 && tokens[at]?.type === 'number' && day.value >= 1000 && day.value <= 2999) {
|
|
636
|
+
const year = day.value;
|
|
637
|
+
let dAt = at + 1;
|
|
638
|
+
if (word(tokens[dAt]) === '-')
|
|
639
|
+
dAt += 1;
|
|
640
|
+
const dTok = tokens[dAt];
|
|
641
|
+
if (dTok?.type === 'number' && dTok.value >= 1 && dTok.value <= 31) {
|
|
642
|
+
return {
|
|
643
|
+
expr: { op: 'literal', date: { year, month, day: dTok.value } },
|
|
644
|
+
consumed: dAt + 1 - i,
|
|
645
|
+
confidence: 0.9,
|
|
646
|
+
role: 'date',
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
expr: { op: 'literal', date: { year, month } },
|
|
651
|
+
consumed: at + 1 - i,
|
|
652
|
+
confidence: 0.9,
|
|
653
|
+
role: 'date',
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (day.value < 1 || day.value > 31)
|
|
657
|
+
return undefined;
|
|
658
|
+
let cursor = at + day.consumed;
|
|
659
|
+
let yAt = cursor;
|
|
660
|
+
if (word(tokens[yAt]) === 'de' || word(tokens[yAt]) === '-')
|
|
661
|
+
yAt += 1;
|
|
662
|
+
const year = yearAt(tokens, yAt);
|
|
663
|
+
const date = { month, day: day.value };
|
|
664
|
+
if (year !== undefined) {
|
|
665
|
+
date.year = year;
|
|
666
|
+
cursor = yAt + 1;
|
|
667
|
+
}
|
|
668
|
+
return { expr: { op: 'literal', date }, consumed: cursor - i, confidence: 0.9, role: 'date' };
|
|
669
|
+
};
|
|
670
|
+
/** "le 17" / "le 1er" — bare day-of-month with article. */
|
|
671
|
+
const frBareDay = (tokens, i) => {
|
|
672
|
+
const art = word(tokens[i]);
|
|
673
|
+
if (art !== 'le' && art !== 'ce')
|
|
674
|
+
return undefined;
|
|
675
|
+
const t = tokens[i + 1];
|
|
676
|
+
let day;
|
|
677
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 1 && t.value <= 31)
|
|
678
|
+
day = t.value;
|
|
679
|
+
else
|
|
680
|
+
day = ordinalWord(word(t));
|
|
681
|
+
if (day === undefined || day < 1 || day > 31)
|
|
682
|
+
return undefined;
|
|
683
|
+
// Don't swallow "le 15 mars" (month follows) or "le 15 du mois prochain".
|
|
684
|
+
const next = word(tokens[i + 2]);
|
|
685
|
+
if (next !== undefined && (MONTHS[next] !== undefined || MONTHS[stripElision(next) ?? ''] !== undefined)) {
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
if (next === 'du' || next === 'de') {
|
|
689
|
+
// "le 15 du mois prochain" → day within next/last month.
|
|
690
|
+
let at = i + 3;
|
|
691
|
+
if (word(tokens[at]) === 'le')
|
|
692
|
+
at += 1;
|
|
693
|
+
if (word(tokens[at]) === 'mois') {
|
|
694
|
+
const mod = word(tokens[at + 1]);
|
|
695
|
+
const delta = mod === 'prochain' || mod === 'suivant' ? 1 : mod === 'dernier' || mod === 'précédent' ? -1 : 0;
|
|
696
|
+
if (delta !== 0) {
|
|
697
|
+
return {
|
|
698
|
+
expr: {
|
|
699
|
+
op: 'intersect',
|
|
700
|
+
parts: [snapOffset(delta, 'month'), { op: 'literal', date: { day } }],
|
|
701
|
+
},
|
|
702
|
+
consumed: at + 2 - i,
|
|
703
|
+
confidence: 1,
|
|
704
|
+
role: 'date',
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
expr: { op: 'literal', date: { day } },
|
|
711
|
+
consumed: 2,
|
|
712
|
+
confidence: 0.8,
|
|
713
|
+
role: 'date',
|
|
714
|
+
};
|
|
715
|
+
};
|
|
716
|
+
/** "lundi 21", "le dimanche 31" — weekday + bare day number. */
|
|
717
|
+
const frWeekdayDay = (tokens, i) => {
|
|
718
|
+
let at = i;
|
|
719
|
+
if (word(tokens[at]) === 'le' || word(tokens[at]) === 'ce')
|
|
720
|
+
at += 1;
|
|
721
|
+
const wd = WEEKDAYS[word(tokens[at]) ?? ''];
|
|
722
|
+
if (!wd)
|
|
723
|
+
return undefined;
|
|
724
|
+
const t = tokens[at + 1];
|
|
725
|
+
const day = t?.type === 'number' && !t.ordinal ? t.value : ordinalWord(word(t));
|
|
726
|
+
if (day === undefined || day < 1 || day > 31)
|
|
727
|
+
return undefined;
|
|
728
|
+
// Month follows → the full-date rules own it.
|
|
729
|
+
const next = word(tokens[at + 2]);
|
|
730
|
+
if (next !== undefined && (MONTHS[next] !== undefined || MONTHS[stripElision(next) ?? ''] !== undefined)) {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
if (tokens[at + 2]?.type === 'numdate')
|
|
734
|
+
return undefined;
|
|
735
|
+
return {
|
|
736
|
+
expr: { op: 'literal', date: { day } },
|
|
737
|
+
consumed: at + 2 - i,
|
|
738
|
+
confidence: 0.9,
|
|
739
|
+
role: 'date',
|
|
740
|
+
};
|
|
741
|
+
};
|
|
742
|
+
/** Bare day-period noun → mergeable time part ("demain soir", "mardi matin"). */
|
|
743
|
+
const frBarePeriod = (tokens, i) => {
|
|
744
|
+
const pw = readPeriodWord(tokens, i);
|
|
745
|
+
if (pw === undefined)
|
|
746
|
+
return undefined;
|
|
747
|
+
return {
|
|
748
|
+
expr: { op: 'literal', dayPeriod: pw.period },
|
|
749
|
+
consumed: pw.consumed,
|
|
750
|
+
confidence: 0.55,
|
|
751
|
+
role: 'time',
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
const DUR_UNITS = {
|
|
755
|
+
seconde: 'seconds', secondes: 'seconds', sec: 'seconds',
|
|
756
|
+
minute: 'minutes', minutes: 'minutes', min: 'minutes',
|
|
757
|
+
heure: 'hours', heures: 'hours', h: 'hours',
|
|
758
|
+
jour: 'days', jours: 'days', journée: 'days', journee: 'days', journées: 'days', journees: 'days',
|
|
759
|
+
nuit: 'days', nuits: 'days',
|
|
760
|
+
semaine: 'weeks', semaines: 'weeks',
|
|
761
|
+
mois: 'months',
|
|
762
|
+
an: 'years', ans: 'years', année: 'years', annee: 'years', années: 'years', annees: 'years',
|
|
763
|
+
};
|
|
764
|
+
/** "3heures", "3h", "1.5h", "3.5ans" — joined amount+unit words. */
|
|
765
|
+
function joinedAmount(w) {
|
|
766
|
+
const m = w?.match(/^(\d+)(?:\.(\d+))?(h|heures?|min|sec|ans?|jours?|semaines?)$/);
|
|
767
|
+
if (!m)
|
|
768
|
+
return undefined;
|
|
769
|
+
const unit = DUR_UNITS[m[3]];
|
|
770
|
+
if (!unit)
|
|
771
|
+
return undefined;
|
|
772
|
+
const whole = Number(m[1]);
|
|
773
|
+
const frac = m[2] !== undefined ? Number(`0.${m[2]}`) : 0;
|
|
774
|
+
const amount = { [unit]: whole };
|
|
775
|
+
if (frac > 0) {
|
|
776
|
+
if (unit === 'hours')
|
|
777
|
+
amount['minutes'] = Math.round(frac * 60);
|
|
778
|
+
else
|
|
779
|
+
amount[unit] = whole + frac;
|
|
780
|
+
}
|
|
781
|
+
return amount;
|
|
782
|
+
}
|
|
783
|
+
/** Read one duration component: "3 heures", "vingt-quatre heures", "demi-heure". */
|
|
784
|
+
function readDurComponent(tokens, i) {
|
|
785
|
+
let at = i;
|
|
786
|
+
if (word(tokens[at]) === 'une' || word(tokens[at]) === 'un') {
|
|
787
|
+
// "une demi-heure" → demi · - · heure
|
|
788
|
+
if (word(tokens[at + 1]) === 'demi' && word(tokens[at + 2]) === '-' && word(tokens[at + 3]) === 'heure') {
|
|
789
|
+
return { amount: { minutes: 30 }, consumed: at + 4 - i };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (word(tokens[at]) === 'demi' && word(tokens[at + 1]) === '-' && word(tokens[at + 2]) === 'heure') {
|
|
793
|
+
return { amount: { minutes: 30 }, consumed: at + 3 - i };
|
|
794
|
+
}
|
|
795
|
+
const joined = joinedAmount(word(tokens[at]));
|
|
796
|
+
if (joined)
|
|
797
|
+
return { amount: joined, consumed: 1 };
|
|
798
|
+
const n = readWordNum(tokens, at);
|
|
799
|
+
if (!n)
|
|
800
|
+
return undefined;
|
|
801
|
+
at += n.consumed;
|
|
802
|
+
// "pour un autre jour" — 'autre' is transparent.
|
|
803
|
+
if (word(tokens[at]) === 'autre' || word(tokens[at]) === 'autres')
|
|
804
|
+
at += 1;
|
|
805
|
+
// "123,45 sec" / "3,5 ans": the comma vanished in tokenization, leaving two
|
|
806
|
+
// adjacent number tokens one character apart.
|
|
807
|
+
const t1 = tokens[at - 1];
|
|
808
|
+
const t2 = tokens[at];
|
|
809
|
+
let value = n.value;
|
|
810
|
+
if (t1?.type === 'number' && t2?.type === 'number' && !t2.ordinal &&
|
|
811
|
+
t2.start === t1.end + 1 && DUR_UNITS[word(tokens[at + 1]) ?? ''] !== undefined) {
|
|
812
|
+
value = n.value + t2.value / 10 ** (t2.end - t2.start);
|
|
813
|
+
at += 1;
|
|
814
|
+
}
|
|
815
|
+
// "trois week-ends" → 3 × 2 days.
|
|
816
|
+
if (word(tokens[at]) === 'week' && word(tokens[at + 1]) === '-' && /^ends?$/.test(word(tokens[at + 2]) ?? '')) {
|
|
817
|
+
return { amount: { days: 2 * value }, consumed: at + 3 - i };
|
|
818
|
+
}
|
|
819
|
+
const unit = DUR_UNITS[word(tokens[at]) ?? ''];
|
|
820
|
+
if (!unit)
|
|
821
|
+
return undefined;
|
|
822
|
+
return { amount: { [unit]: value }, consumed: at + 1 - i };
|
|
823
|
+
}
|
|
824
|
+
const DUR_MARKERS = [
|
|
825
|
+
['pour'], ['pendant'], ['durant'], ['dure'], ['durera'], ['dura'], ['depuis'],
|
|
826
|
+
['sur'], ['durée', 'de'], ['duree', 'de'], ['prendra'], ['faudra'], ['que'],
|
|
827
|
+
['durer', 'du', 'tout'], ['durer'],
|
|
828
|
+
];
|
|
829
|
+
/** Elided one-markers: "d'une seconde", "qu'une heure et demie", "d' une demi-heure". */
|
|
830
|
+
const DUR_ONE_MARKERS = ["d'une", "d'un", "qu'une", "qu'un"];
|
|
831
|
+
/** "pour 3 heures" / "depuis trois semaines" / "durera 2 heures 50 minutes". */
|
|
832
|
+
const frDuration = (tokens, i) => {
|
|
833
|
+
let at;
|
|
834
|
+
let markerLen = 0;
|
|
835
|
+
const w0 = word(tokens[i]);
|
|
836
|
+
// "d'une seconde" / "qu'une heure et demie" — marker fused with "one".
|
|
837
|
+
if (w0 !== undefined && DUR_ONE_MARKERS.includes(w0)) {
|
|
838
|
+
const unit = DUR_UNITS[word(tokens[i + 1]) ?? ''];
|
|
839
|
+
if (unit)
|
|
840
|
+
return finishDuration(tokens, i, i + 1, { amount: { [unit]: 1 }, consumed: 1 });
|
|
841
|
+
// "d' une demi-heure" variant handled below via component read.
|
|
842
|
+
const comp = readDurComponent(tokens, i + 1);
|
|
843
|
+
if (comp)
|
|
844
|
+
return finishDuration(tokens, i, i + 1, comp);
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
if ((w0 === 'd' || w0 === 'qu') && (word(tokens[i + 1]) === 'une' || word(tokens[i + 1]) === 'un')) {
|
|
848
|
+
const comp = readDurComponent(tokens, i + 1);
|
|
849
|
+
if (comp)
|
|
850
|
+
return finishDuration(tokens, i, i + 1, comp);
|
|
851
|
+
const unit = DUR_UNITS[word(tokens[i + 2]) ?? ''];
|
|
852
|
+
if (unit)
|
|
853
|
+
return finishDuration(tokens, i, i + 2, { amount: { [unit]: 1 }, consumed: 1 });
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
for (const marker of DUR_MARKERS) {
|
|
857
|
+
let k = 0;
|
|
858
|
+
while (k < marker.length && word(tokens[i + k]) === marker[k])
|
|
859
|
+
k += 1;
|
|
860
|
+
if (k === marker.length) {
|
|
861
|
+
at = i + k;
|
|
862
|
+
markerLen = k;
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (at === undefined)
|
|
867
|
+
return undefined;
|
|
868
|
+
const w = word(tokens[at]);
|
|
869
|
+
// "pour la journée" / "pour le mois" — the unit interval, not a duration.
|
|
870
|
+
if ((w === 'la' || w === 'le') && markerLen > 0) {
|
|
871
|
+
const unitWord = word(tokens[at + 1]);
|
|
872
|
+
const unit = unitWord === 'journée' || unitWord === 'journee' || unitWord === 'jour'
|
|
873
|
+
? 'day'
|
|
874
|
+
: unitWord === 'semaine'
|
|
875
|
+
? 'week'
|
|
876
|
+
: unitWord === 'mois'
|
|
877
|
+
? 'month'
|
|
878
|
+
: unitWord === 'année' || unitWord === 'annee'
|
|
879
|
+
? 'year'
|
|
880
|
+
: undefined;
|
|
881
|
+
if (unit && WEEKDAYS[word(tokens[at + 2]) ?? ''] === undefined && word(tokens[at + 2]) !== 'prochain') {
|
|
882
|
+
// Leave "pour le mois prochain" (rel-noun) alone.
|
|
883
|
+
const mod = word(tokens[at + 2]);
|
|
884
|
+
if (mod === undefined || (MONTHS[mod] === undefined && mod !== 'prochaine' && mod !== 'prochain' && mod !== 'dernier' && mod !== 'dernière')) {
|
|
885
|
+
return { expr: snapNow(unit), consumed: at + 2 - i, confidence: 0.9, role: 'date' };
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
if (w === 'une' || w === 'un') {
|
|
891
|
+
const comp = readDurComponent(tokens, at);
|
|
892
|
+
if (comp)
|
|
893
|
+
return finishDuration(tokens, i, at, comp);
|
|
894
|
+
at += 1;
|
|
895
|
+
}
|
|
896
|
+
const comp = readDurComponent(tokens, at);
|
|
897
|
+
if (!comp)
|
|
898
|
+
return undefined;
|
|
899
|
+
return finishDuration(tokens, i, at, comp);
|
|
900
|
+
};
|
|
901
|
+
function finishDuration(tokens, i, at, comp) {
|
|
902
|
+
let cursor = at + comp.consumed;
|
|
903
|
+
const amount = { ...comp.amount };
|
|
904
|
+
// "et vingt minutes" / "et demie" / bare second component ("2 heures 50 minutes").
|
|
905
|
+
let extraAt = cursor;
|
|
906
|
+
if (word(tokens[extraAt]) === 'et')
|
|
907
|
+
extraAt += 1;
|
|
908
|
+
if (word(tokens[extraAt]) === 'demie' || word(tokens[extraAt]) === 'demi') {
|
|
909
|
+
if (amount['years'] !== undefined)
|
|
910
|
+
amount['years'] += 0.5;
|
|
911
|
+
else
|
|
912
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 30;
|
|
913
|
+
cursor = extraAt + 1;
|
|
914
|
+
}
|
|
915
|
+
else if (word(tokens[extraAt]) === 'quart') {
|
|
916
|
+
if (amount['years'] !== undefined)
|
|
917
|
+
amount['years'] += 0.25;
|
|
918
|
+
else
|
|
919
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 15;
|
|
920
|
+
cursor = extraAt + 1;
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
const second = readDurComponent(tokens, extraAt);
|
|
924
|
+
if (second) {
|
|
925
|
+
for (const [k, v] of Object.entries(second.amount)) {
|
|
926
|
+
amount[k] = (amount[k] ?? 0) + v;
|
|
927
|
+
}
|
|
928
|
+
cursor = extraAt + second.consumed;
|
|
929
|
+
}
|
|
930
|
+
else if (word(tokens[extraAt]) === 'un' && word(tokens[extraAt + 1]) === 'quart') {
|
|
931
|
+
if (amount['years'] !== undefined)
|
|
932
|
+
amount['years'] += 0.25;
|
|
933
|
+
else
|
|
934
|
+
amount['minutes'] = (amount['minutes'] ?? 0) + 15;
|
|
935
|
+
cursor = extraAt + 2;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
expr: { op: 'amount', amount },
|
|
940
|
+
consumed: cursor - i,
|
|
941
|
+
confidence: 1,
|
|
942
|
+
role: 'duration',
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
/** "toute la journée" / "tout le mois" → one whole unit. */
|
|
946
|
+
const frAllUnit = (tokens, i) => {
|
|
947
|
+
const w0 = word(tokens[i]);
|
|
948
|
+
if (w0 !== 'tout' && w0 !== 'toute')
|
|
949
|
+
return undefined;
|
|
950
|
+
let at = i + 1;
|
|
951
|
+
const art = word(tokens[at]);
|
|
952
|
+
if (art === 'le' || art === 'la')
|
|
953
|
+
at += 1;
|
|
954
|
+
const unit = DUR_UNITS[word(tokens[at]) ?? ''];
|
|
955
|
+
if (!unit)
|
|
956
|
+
return undefined;
|
|
957
|
+
return {
|
|
958
|
+
expr: { op: 'amount', amount: { [unit]: 1 } },
|
|
959
|
+
consumed: at + 1 - i,
|
|
960
|
+
confidence: 1,
|
|
961
|
+
role: 'duration',
|
|
962
|
+
};
|
|
963
|
+
};
|
|
964
|
+
/** "[dans] les N unités à venir / précédentes / dernières" (adj after noun). */
|
|
965
|
+
const frNUnitPost = (tokens, i) => {
|
|
966
|
+
let at = i;
|
|
967
|
+
if (word(tokens[at]) === 'les' || word(tokens[at]) === 'des')
|
|
968
|
+
at += 1;
|
|
969
|
+
const n = readWordNum(tokens, at);
|
|
970
|
+
if (!n)
|
|
971
|
+
return undefined;
|
|
972
|
+
at += n.consumed;
|
|
973
|
+
const unitWord = word(tokens[at]) ?? '';
|
|
974
|
+
const unit = FR_LEXICON.units[unitWord];
|
|
975
|
+
if (!unit)
|
|
976
|
+
return undefined;
|
|
977
|
+
at += 1;
|
|
978
|
+
// "journées de travail" — skip the qualifier.
|
|
979
|
+
if (word(tokens[at]) === 'de' && word(tokens[at + 1]) === 'travail')
|
|
980
|
+
at += 2;
|
|
981
|
+
let sign;
|
|
982
|
+
const pw = word(tokens[at]);
|
|
983
|
+
if (pw === 'à' && word(tokens[at + 1]) === 'venir') {
|
|
984
|
+
sign = 1;
|
|
985
|
+
at += 2;
|
|
986
|
+
}
|
|
987
|
+
else if (pw !== undefined && ['précédentes', 'précédents', 'precedentes', 'precedents', 'dernières', 'derniers', 'dernieres', 'passées', 'passés', 'passees'].includes(pw)) {
|
|
988
|
+
sign = -1;
|
|
989
|
+
at += 1;
|
|
990
|
+
}
|
|
991
|
+
else if (pw !== undefined && ['suivantes', 'suivants', 'prochaines', 'prochains'].includes(pw)) {
|
|
992
|
+
sign = 1;
|
|
993
|
+
at += 1;
|
|
994
|
+
}
|
|
995
|
+
if (sign === undefined)
|
|
996
|
+
return undefined;
|
|
997
|
+
const amount = unit === 'quarter' ? { months: 3 * sign * n.value } : { [`${unit}s`]: sign * n.value };
|
|
998
|
+
return {
|
|
999
|
+
expr: { op: 'span', anchor: NOW, amount },
|
|
1000
|
+
consumed: at - i,
|
|
1001
|
+
confidence: 1,
|
|
1002
|
+
role: 'date',
|
|
1003
|
+
};
|
|
1004
|
+
};
|
|
1005
|
+
/** "les N prochains journées de travail" — factory misses the qualifier. */
|
|
1006
|
+
const frNextNWork = (tokens, i) => {
|
|
1007
|
+
let at = i;
|
|
1008
|
+
if (word(tokens[at]) === 'les' || word(tokens[at]) === 'des')
|
|
1009
|
+
at += 1;
|
|
1010
|
+
const n = readWordNum(tokens, at);
|
|
1011
|
+
if (!n)
|
|
1012
|
+
return undefined;
|
|
1013
|
+
at += n.consumed;
|
|
1014
|
+
const adj = word(tokens[at]) ?? '';
|
|
1015
|
+
let sign;
|
|
1016
|
+
if (['prochains', 'prochaines', 'suivants', 'suivantes'].includes(adj))
|
|
1017
|
+
sign = 1;
|
|
1018
|
+
else if (['derniers', 'dernières', 'dernieres'].includes(adj))
|
|
1019
|
+
sign = -1;
|
|
1020
|
+
else
|
|
1021
|
+
return undefined;
|
|
1022
|
+
at += 1;
|
|
1023
|
+
const unitWord = word(tokens[at]) ?? '';
|
|
1024
|
+
const unit = FR_LEXICON.units[unitWord];
|
|
1025
|
+
if (!unit)
|
|
1026
|
+
return undefined;
|
|
1027
|
+
at += 1;
|
|
1028
|
+
if (word(tokens[at]) === 'de' && word(tokens[at + 1]) === 'travail')
|
|
1029
|
+
at += 2;
|
|
1030
|
+
const amount = unit === 'quarter' ? { months: 3 * sign * n.value } : { [`${unit}s`]: sign * n.value };
|
|
1031
|
+
return {
|
|
1032
|
+
expr: { op: 'span', anchor: NOW, amount },
|
|
1033
|
+
consumed: at - i,
|
|
1034
|
+
confidence: 1,
|
|
1035
|
+
role: 'date',
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
/** "la journée" / "l'année" / "dans la semaine" — bare unit interval. */
|
|
1039
|
+
const frBareUnit = (tokens, i) => {
|
|
1040
|
+
let at = i;
|
|
1041
|
+
if (word(tokens[at]) === 'dans' || word(tokens[at]) === 'de')
|
|
1042
|
+
at += 1;
|
|
1043
|
+
const art = word(tokens[at]);
|
|
1044
|
+
let unitWord;
|
|
1045
|
+
if (art === 'le' || art === 'la') {
|
|
1046
|
+
unitWord = word(tokens[at + 1]);
|
|
1047
|
+
at += 2;
|
|
1048
|
+
}
|
|
1049
|
+
else if (art !== undefined && /^l['']/.test(art)) {
|
|
1050
|
+
unitWord = stripElision(art);
|
|
1051
|
+
at += 1;
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
return undefined;
|
|
1055
|
+
}
|
|
1056
|
+
const unit = FR_LEXICON.units[unitWord ?? ''];
|
|
1057
|
+
if (!unit || unit === 'hour' || unit === 'minute' || unit === 'second')
|
|
1058
|
+
return undefined;
|
|
1059
|
+
// A modifier follows → rel-noun owns it.
|
|
1060
|
+
const next = word(tokens[at]);
|
|
1061
|
+
if (next !== undefined && (FR_LEXICON.postMods[next] !== undefined || next === 'en' || next === 'de' || next === 'du')) {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
if (yearAt(tokens, at) !== undefined)
|
|
1065
|
+
return undefined;
|
|
1066
|
+
return { expr: snapNow(unit), consumed: at - i, confidence: 0.6, role: 'date' };
|
|
1067
|
+
};
|
|
1068
|
+
const HOLIDAYS = [
|
|
1069
|
+
{ words: ['noël'], name: 'christmas' },
|
|
1070
|
+
{ words: ['noel'], name: 'christmas' },
|
|
1071
|
+
{ words: ['pâques'], name: 'easter' },
|
|
1072
|
+
{ words: ['paques'], name: 'easter' },
|
|
1073
|
+
{ words: ['nouvel', 'an'], name: 'new-year' },
|
|
1074
|
+
{ words: ['jour', 'de', "l'an"], name: 'new-year' },
|
|
1075
|
+
{ words: ['vendredi', 'noir'], name: 'black-friday' },
|
|
1076
|
+
{ words: ['jour', 'de', 'la', 'terre'], name: 'earth-day' },
|
|
1077
|
+
{ words: ['saint', 'patrick'], name: 'st-patricks' },
|
|
1078
|
+
{ words: ['journée', 'internationale', 'des', 'travailleurs'], name: 'workers-day' },
|
|
1079
|
+
];
|
|
1080
|
+
/** "Noël", "Pâques 2018", "le prochain nouvel an", "la dernière Pâques". */
|
|
1081
|
+
const frHoliday = (tokens, i) => {
|
|
1082
|
+
let at = i;
|
|
1083
|
+
let dir;
|
|
1084
|
+
const w0 = word(tokens[at]);
|
|
1085
|
+
if (w0 === 'le' || w0 === 'la')
|
|
1086
|
+
at += 1;
|
|
1087
|
+
const mod0 = word(tokens[at]);
|
|
1088
|
+
if (mod0 === 'prochain' || mod0 === 'prochaine') {
|
|
1089
|
+
dir = 'next';
|
|
1090
|
+
at += 1;
|
|
1091
|
+
}
|
|
1092
|
+
else if (mod0 === 'dernier' || mod0 === 'dernière' || mod0 === 'derniere') {
|
|
1093
|
+
dir = 'prev';
|
|
1094
|
+
at += 1;
|
|
1095
|
+
}
|
|
1096
|
+
for (const h of HOLIDAYS) {
|
|
1097
|
+
let k = 0;
|
|
1098
|
+
while (k < h.words.length && word(tokens[at + k]) === h.words[k])
|
|
1099
|
+
k += 1;
|
|
1100
|
+
if (k !== h.words.length)
|
|
1101
|
+
continue;
|
|
1102
|
+
let end = at + k;
|
|
1103
|
+
const post = word(tokens[end]);
|
|
1104
|
+
if (dir === undefined) {
|
|
1105
|
+
if (post === 'prochain' || post === 'prochaine' || post === 'suivant' || post === 'suivante') {
|
|
1106
|
+
dir = 'next';
|
|
1107
|
+
end += 1;
|
|
1108
|
+
}
|
|
1109
|
+
else if (post === 'dernier' || post === 'dernière' || post === 'derniere' || post === 'précédente' || post === 'précédent') {
|
|
1110
|
+
dir = 'prev';
|
|
1111
|
+
end += 1;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const year = yearAt(tokens, end);
|
|
1115
|
+
const expr = { op: 'holiday', name: h.name };
|
|
1116
|
+
if (year !== undefined) {
|
|
1117
|
+
expr.year = year;
|
|
1118
|
+
end += 1;
|
|
1119
|
+
}
|
|
1120
|
+
if (dir !== undefined)
|
|
1121
|
+
expr.dir = dir;
|
|
1122
|
+
return { expr, consumed: end - i, confidence: 1, role: 'date' };
|
|
1123
|
+
}
|
|
1124
|
+
return undefined;
|
|
1125
|
+
};
|
|
1126
|
+
/** "les années 1990" / "1990s" / "90s" → decade span. */
|
|
1127
|
+
const frDecade = (tokens, i) => {
|
|
1128
|
+
const w = word(tokens[i]);
|
|
1129
|
+
const m = w?.match(/^(\d{2}|\d{4})s$/);
|
|
1130
|
+
if (m) {
|
|
1131
|
+
const y = m[1].length === 2 ? 1900 + Number(m[1]) : Number(m[1]);
|
|
1132
|
+
return {
|
|
1133
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { year: y } }, amount: { years: 10 } },
|
|
1134
|
+
consumed: 1,
|
|
1135
|
+
confidence: 0.95,
|
|
1136
|
+
role: 'date',
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
if ((w === 'les' || w === 'des') && (word(tokens[i + 1]) === 'années' || word(tokens[i + 1]) === 'annees')) {
|
|
1140
|
+
const t = tokens[i + 2];
|
|
1141
|
+
if (t?.type === 'number' && t.value >= 1000 && t.value % 10 === 0) {
|
|
1142
|
+
return {
|
|
1143
|
+
expr: { op: 'span', anchor: { op: 'literal', date: { year: t.value } }, amount: { years: 10 } },
|
|
1144
|
+
consumed: 3,
|
|
1145
|
+
confidence: 1,
|
|
1146
|
+
role: 'date',
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return undefined;
|
|
1151
|
+
};
|
|
1152
|
+
/** Scope after "fin de": a unit, deictic day, weekday, month, or year. */
|
|
1153
|
+
function readEdgeScope(tokens, i) {
|
|
1154
|
+
let at = i;
|
|
1155
|
+
const w0 = word(tokens[at]);
|
|
1156
|
+
if (w0 === 'la' || w0 === 'le' || w0 === 'ce' || w0 === 'cette' || w0 === 'cet')
|
|
1157
|
+
at += 1;
|
|
1158
|
+
let w = word(tokens[at]);
|
|
1159
|
+
const sw = stripElision(w);
|
|
1160
|
+
const deictic = FR_LEXICON.deictic[w ?? ''];
|
|
1161
|
+
if (deictic !== undefined) {
|
|
1162
|
+
return { expr: snapOffset(deictic, 'day'), kind: 'day', consumed: at + 1 - i };
|
|
1163
|
+
}
|
|
1164
|
+
const wd = WEEKDAYS[w ?? ''];
|
|
1165
|
+
if (wd) {
|
|
1166
|
+
return {
|
|
1167
|
+
expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd } },
|
|
1168
|
+
kind: 'day',
|
|
1169
|
+
consumed: at + 1 - i,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
const year = yearAt(tokens, at);
|
|
1173
|
+
if (year !== undefined) {
|
|
1174
|
+
return { expr: { op: 'literal', date: { year } }, kind: 'year', year, consumed: at + 1 - i };
|
|
1175
|
+
}
|
|
1176
|
+
const month = MONTHS[w ?? ''] ?? MONTHS[sw ?? ''];
|
|
1177
|
+
if (month !== undefined) {
|
|
1178
|
+
return { expr: { op: 'literal', date: { month } }, kind: 'month', month, consumed: at + 1 - i };
|
|
1179
|
+
}
|
|
1180
|
+
const pw = readPeriodWord(tokens, at);
|
|
1181
|
+
if (pw !== undefined) {
|
|
1182
|
+
return {
|
|
1183
|
+
expr: { op: 'intersect', parts: [snapNow('day'), { op: 'literal', dayPeriod: pw.period }] },
|
|
1184
|
+
kind: 'period',
|
|
1185
|
+
consumed: at + pw.consumed - i,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const unit = FR_LEXICON.units[w ?? ''] ?? FR_LEXICON.units[sw ?? ''];
|
|
1189
|
+
if (unit !== undefined) {
|
|
1190
|
+
// "cette année" / "l'année" / "la semaine" — current unit.
|
|
1191
|
+
const mod = word(tokens[at + 1]);
|
|
1192
|
+
let delta = 0;
|
|
1193
|
+
let consumed = at + 1 - i;
|
|
1194
|
+
if (mod !== undefined && FR_LEXICON.postMods[mod] !== undefined) {
|
|
1195
|
+
delta = FR_LEXICON.postMods[mod];
|
|
1196
|
+
consumed += 1;
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
expr: delta === 0 ? snapNow(unit) : snapOffset(delta, unit),
|
|
1200
|
+
kind: 'unit',
|
|
1201
|
+
unit,
|
|
1202
|
+
consumed,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
return undefined;
|
|
1206
|
+
}
|
|
1207
|
+
function edgeExpr(mode, scope) {
|
|
1208
|
+
if (scope.kind === 'day') {
|
|
1209
|
+
return { op: 'snap', base: scope.expr, unit: 'day', edge: mode === 'end' ? 'end' : 'start' };
|
|
1210
|
+
}
|
|
1211
|
+
if (scope.kind === 'year' && scope.year !== undefined) {
|
|
1212
|
+
const startMonth = mode === 'start' ? 1 : mode === 'mid' ? 5 : 9;
|
|
1213
|
+
return {
|
|
1214
|
+
op: 'span',
|
|
1215
|
+
anchor: { op: 'literal', date: { year: scope.year, month: startMonth } },
|
|
1216
|
+
amount: { months: 4 },
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
// Half-splits of the current week/month/year — fixed splits, deliberately
|
|
1220
|
+
// not clamped to the reference (matching en's "end of month").
|
|
1221
|
+
if (scope.kind === 'unit' && scope.unit !== undefined && scope.unit !== 'day') {
|
|
1222
|
+
const unit = scope.unit;
|
|
1223
|
+
const startPoint = { op: 'snap', base: scope.expr, unit, edge: 'start' };
|
|
1224
|
+
const endPoint = { op: 'snap', base: scope.expr, unit, edge: 'end' };
|
|
1225
|
+
const midOffset = unit === 'week'
|
|
1226
|
+
? { op: 'offset', base: startPoint, amount: 3, unit: 'day' }
|
|
1227
|
+
: unit === 'month'
|
|
1228
|
+
? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
|
|
1229
|
+
: unit === 'year'
|
|
1230
|
+
? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
|
|
1231
|
+
: { op: 'offset', base: startPoint, amount: 45, unit: 'day' };
|
|
1232
|
+
if (mode === 'end')
|
|
1233
|
+
return { op: 'between', start: midOffset, end: endPoint };
|
|
1234
|
+
if (mode === 'start')
|
|
1235
|
+
return { op: 'between', start: startPoint, end: midOffset };
|
|
1236
|
+
return { ...scope.expr, mod: 'mid' };
|
|
1237
|
+
}
|
|
1238
|
+
return { ...scope.expr, mod: mode === 'mid' ? 'mid' : mode };
|
|
1239
|
+
}
|
|
1240
|
+
/** "à la fin de cette année", "en fin de journée", "au début de 2008", "la mi-novembre". */
|
|
1241
|
+
const frEdge = (tokens, i) => {
|
|
1242
|
+
let at = i;
|
|
1243
|
+
const w0 = word(tokens[at]);
|
|
1244
|
+
if (w0 === 'à' || w0 === 'a' || w0 === 'au' || w0 === 'en')
|
|
1245
|
+
at += 1;
|
|
1246
|
+
if (word(tokens[at]) === 'la' || word(tokens[at]) === 'le')
|
|
1247
|
+
at += 1;
|
|
1248
|
+
let ew = word(tokens[at]);
|
|
1249
|
+
let mode;
|
|
1250
|
+
if (ew === 'fin')
|
|
1251
|
+
mode = 'end';
|
|
1252
|
+
else if (ew === 'début' || ew === 'debut')
|
|
1253
|
+
mode = 'start';
|
|
1254
|
+
else if (ew === 'milieu')
|
|
1255
|
+
mode = 'mid';
|
|
1256
|
+
else if (ew === 'mi')
|
|
1257
|
+
mode = 'mid';
|
|
1258
|
+
if (mode === undefined)
|
|
1259
|
+
return undefined;
|
|
1260
|
+
at += 1;
|
|
1261
|
+
if (mode === 'mid' && ew === 'mi') {
|
|
1262
|
+
// "mi-novembre" splits? no — "mi" is < 2 chars… but "mi septembre" & "mi" "-" month.
|
|
1263
|
+
if (word(tokens[at]) === '-')
|
|
1264
|
+
at += 1;
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
const dw = word(tokens[at]);
|
|
1268
|
+
if (dw === 'de' || dw === 'du' || dw === 'des') {
|
|
1269
|
+
at += 1;
|
|
1270
|
+
}
|
|
1271
|
+
else if (dw !== undefined && /^d['']/.test(dw)) {
|
|
1272
|
+
// "fin d'année": elided scope inside the next token — fake a rewind by
|
|
1273
|
+
// handling it directly.
|
|
1274
|
+
const sw = stripElision(dw);
|
|
1275
|
+
const unit = FR_LEXICON.units[sw ?? ''];
|
|
1276
|
+
const month = MONTHS[sw ?? ''];
|
|
1277
|
+
if (unit !== undefined) {
|
|
1278
|
+
return {
|
|
1279
|
+
expr: edgeExpr(mode, { expr: snapNow(unit), kind: 'unit', unit, consumed: 0 }),
|
|
1280
|
+
consumed: at + 1 - i,
|
|
1281
|
+
confidence: 0.95,
|
|
1282
|
+
role: 'date',
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
if (month !== undefined) {
|
|
1286
|
+
return {
|
|
1287
|
+
expr: edgeExpr(mode, { expr: { op: 'literal', date: { month } }, kind: 'month', month, consumed: 0 }),
|
|
1288
|
+
consumed: at + 1 - i,
|
|
1289
|
+
confidence: 0.95,
|
|
1290
|
+
role: 'date',
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
const period = PERIODS[sw ?? ''];
|
|
1294
|
+
if (period !== undefined) {
|
|
1295
|
+
return {
|
|
1296
|
+
expr: {
|
|
1297
|
+
op: 'intersect',
|
|
1298
|
+
parts: [snapNow('day'), { op: 'literal', dayPeriod: period }],
|
|
1299
|
+
mod: mode === 'mid' ? 'mid' : mode,
|
|
1300
|
+
},
|
|
1301
|
+
consumed: at + 1 - i,
|
|
1302
|
+
confidence: 0.95,
|
|
1303
|
+
role: 'datetime',
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return undefined;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const scope = readEdgeScope(tokens, at);
|
|
1310
|
+
if (!scope)
|
|
1311
|
+
return undefined;
|
|
1312
|
+
return {
|
|
1313
|
+
expr: edgeExpr(mode, scope),
|
|
1314
|
+
consumed: at + scope.consumed - i,
|
|
1315
|
+
confidence: 0.95,
|
|
1316
|
+
role: 'date',
|
|
1317
|
+
};
|
|
1318
|
+
};
|
|
1319
|
+
/** "tôt le matin", "plus tard dans l'après-midi", "tôt dans la journée". */
|
|
1320
|
+
const frEarlyLate = (tokens, i) => {
|
|
1321
|
+
let at = i;
|
|
1322
|
+
let mode;
|
|
1323
|
+
const w0 = word(tokens[at]);
|
|
1324
|
+
if (w0 === 'tôt' || w0 === 'tot') {
|
|
1325
|
+
mode = 'start';
|
|
1326
|
+
at += 1;
|
|
1327
|
+
}
|
|
1328
|
+
else if (w0 === 'tard') {
|
|
1329
|
+
mode = 'end';
|
|
1330
|
+
at += 1;
|
|
1331
|
+
}
|
|
1332
|
+
else if (w0 === 'plus') {
|
|
1333
|
+
const w1 = word(tokens[at + 1]);
|
|
1334
|
+
if (w1 === 'tôt' || w1 === 'tot')
|
|
1335
|
+
mode = 'start';
|
|
1336
|
+
else if (w1 === 'tard')
|
|
1337
|
+
mode = 'end';
|
|
1338
|
+
else
|
|
1339
|
+
return undefined;
|
|
1340
|
+
at += 2;
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
return undefined;
|
|
1344
|
+
}
|
|
1345
|
+
let filler = 0;
|
|
1346
|
+
while (filler < 2) {
|
|
1347
|
+
const fw = word(tokens[at]);
|
|
1348
|
+
if (fw === 'dans' || fw === 'le' || fw === 'la' || fw === 'ce' || fw === 'cet' || fw === 'cette' || fw === 'en') {
|
|
1349
|
+
at += 1;
|
|
1350
|
+
filler += 1;
|
|
1351
|
+
}
|
|
1352
|
+
else
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
const pw = readPeriodWord(tokens, at);
|
|
1356
|
+
if (pw !== undefined) {
|
|
1357
|
+
return {
|
|
1358
|
+
expr: {
|
|
1359
|
+
op: 'intersect',
|
|
1360
|
+
parts: [snapNow('day'), { op: 'literal', dayPeriod: pw.period }],
|
|
1361
|
+
mod: mode,
|
|
1362
|
+
},
|
|
1363
|
+
consumed: at + pw.consumed - i,
|
|
1364
|
+
confidence: 0.95,
|
|
1365
|
+
role: 'datetime',
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
const uw = word(tokens[at]);
|
|
1369
|
+
const unit = FR_LEXICON.units[uw ?? ''] ?? FR_LEXICON.units[stripElision(uw) ?? ''];
|
|
1370
|
+
if (unit !== undefined && unit !== 'hour' && unit !== 'minute' && unit !== 'second') {
|
|
1371
|
+
let end = at + 1;
|
|
1372
|
+
if (word(tokens[end]) === '-' && word(tokens[end + 1]) === 'ci')
|
|
1373
|
+
end += 2;
|
|
1374
|
+
return {
|
|
1375
|
+
expr: { ...snapNow(unit), mod: mode },
|
|
1376
|
+
consumed: end - i,
|
|
1377
|
+
confidence: 0.95,
|
|
1378
|
+
role: 'date',
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
const deictic = FR_LEXICON.deictic[uw ?? ''];
|
|
1382
|
+
if (deictic !== undefined) {
|
|
1383
|
+
return {
|
|
1384
|
+
expr: { ...snapOffset(deictic, 'day'), mod: mode },
|
|
1385
|
+
consumed: at + 1 - i,
|
|
1386
|
+
confidence: 0.95,
|
|
1387
|
+
role: 'date',
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
return undefined;
|
|
1391
|
+
};
|
|
1392
|
+
/** Quarters and halves: "2017-trimestre1", "q1 de 2017", "2019 H2", "trimestre3 2019". */
|
|
1393
|
+
function readQuarter(tokens, i) {
|
|
1394
|
+
const w = word(tokens[i]);
|
|
1395
|
+
let m = w?.match(/^([12]\d{3})-?(?:trimestre|q)([1-4])$/);
|
|
1396
|
+
if (m)
|
|
1397
|
+
return { year: Number(m[1]), start: (Number(m[2]) - 1) * 3 + 1, months: 3, consumed: 1 };
|
|
1398
|
+
m = w?.match(/^([12]\d{3})-?h([12])$/);
|
|
1399
|
+
if (m)
|
|
1400
|
+
return { year: Number(m[1]), start: (Number(m[2]) - 1) * 6 + 1, months: 6, consumed: 1 };
|
|
1401
|
+
m = w?.match(/^(?:trimestre|q)([1-4])(?:-([12]\d{3}))?$/);
|
|
1402
|
+
if (m) {
|
|
1403
|
+
const q = Number(m[1]);
|
|
1404
|
+
if (m[2] !== undefined)
|
|
1405
|
+
return { year: Number(m[2]), start: (q - 1) * 3 + 1, months: 3, consumed: 1 };
|
|
1406
|
+
let at = i + 1;
|
|
1407
|
+
if (word(tokens[at]) === 'de' || word(tokens[at]) === 'du')
|
|
1408
|
+
at += 1;
|
|
1409
|
+
const y = yearAt(tokens, at);
|
|
1410
|
+
if (y !== undefined)
|
|
1411
|
+
return { year: y, start: (q - 1) * 3 + 1, months: 3, consumed: at + 1 - i };
|
|
1412
|
+
return undefined;
|
|
1413
|
+
}
|
|
1414
|
+
m = w?.match(/^h([12])$/);
|
|
1415
|
+
if (m) {
|
|
1416
|
+
const y = yearAt(tokens, i + 1);
|
|
1417
|
+
if (y !== undefined)
|
|
1418
|
+
return { year: y, start: (Number(m[1]) - 1) * 6 + 1, months: 6, consumed: 2 };
|
|
1419
|
+
return undefined;
|
|
1420
|
+
}
|
|
1421
|
+
const y0 = yearAt(tokens, i);
|
|
1422
|
+
if (y0 !== undefined) {
|
|
1423
|
+
const nw = word(tokens[i + 1]);
|
|
1424
|
+
const qm = nw?.match(/^(?:trimestre|q)([1-4])$/);
|
|
1425
|
+
if (qm)
|
|
1426
|
+
return { year: y0, start: (Number(qm[1]) - 1) * 3 + 1, months: 3, consumed: 2 };
|
|
1427
|
+
const hm2 = nw?.match(/^h([12])$/);
|
|
1428
|
+
if (hm2)
|
|
1429
|
+
return { year: y0, start: (Number(hm2[1]) - 1) * 6 + 1, months: 6, consumed: 2 };
|
|
1430
|
+
}
|
|
1431
|
+
return undefined;
|
|
1432
|
+
}
|
|
1433
|
+
function quarterExpr(q) {
|
|
1434
|
+
return {
|
|
1435
|
+
op: 'span',
|
|
1436
|
+
anchor: { op: 'literal', date: { year: q.year, month: q.start } },
|
|
1437
|
+
amount: { months: q.months },
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
const frQuarter = (tokens, i) => {
|
|
1441
|
+
const q = readQuarter(tokens, i);
|
|
1442
|
+
if (!q)
|
|
1443
|
+
return undefined;
|
|
1444
|
+
return { expr: quarterExpr(q), consumed: q.consumed, confidence: 0.95, role: 'date' };
|
|
1445
|
+
};
|
|
1446
|
+
/** Slash dates with a month name: "oct/2", "02/oct", "23/sep/2020", "01/août/2019". */
|
|
1447
|
+
const frSlashDate = (tokens, i) => {
|
|
1448
|
+
const w = word(tokens[i]);
|
|
1449
|
+
if (w === undefined || !w.includes('/') || /^[\d/]+$/.test(w))
|
|
1450
|
+
return undefined;
|
|
1451
|
+
const segments = w.split('/');
|
|
1452
|
+
if (segments.length < 2 || segments.length > 3)
|
|
1453
|
+
return undefined;
|
|
1454
|
+
let month;
|
|
1455
|
+
let day;
|
|
1456
|
+
let year;
|
|
1457
|
+
for (const seg of segments) {
|
|
1458
|
+
const m = MONTHS[seg];
|
|
1459
|
+
if (m !== undefined) {
|
|
1460
|
+
if (month !== undefined)
|
|
1461
|
+
return undefined;
|
|
1462
|
+
month = m;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (!/^\d{1,4}$/.test(seg))
|
|
1466
|
+
return undefined;
|
|
1467
|
+
const v = Number(seg);
|
|
1468
|
+
if (v >= 1000) {
|
|
1469
|
+
if (year !== undefined)
|
|
1470
|
+
return undefined;
|
|
1471
|
+
year = v;
|
|
1472
|
+
}
|
|
1473
|
+
else if (v >= 1 && v <= 31) {
|
|
1474
|
+
if (day !== undefined)
|
|
1475
|
+
return undefined;
|
|
1476
|
+
day = v;
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
return undefined;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (month === undefined)
|
|
1483
|
+
return undefined;
|
|
1484
|
+
const date = { month };
|
|
1485
|
+
if (day !== undefined)
|
|
1486
|
+
date.day = day;
|
|
1487
|
+
if (year !== undefined)
|
|
1488
|
+
date.year = year;
|
|
1489
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.95, role: 'date' };
|
|
1490
|
+
};
|
|
1491
|
+
/** Space-separated numeric dates: "02 04 2009", "18 8 78". */
|
|
1492
|
+
const frSpacedDate = (tokens, i) => {
|
|
1493
|
+
const t1 = tokens[i];
|
|
1494
|
+
// "23-2020-septembre" → day · - · year · - · month tokens.
|
|
1495
|
+
if (t1?.type === 'number' && !t1.ordinal && t1.value >= 1 && t1.value <= 31 && word(tokens[i + 1]) === '-') {
|
|
1496
|
+
const yTok = tokens[i + 2];
|
|
1497
|
+
if (yTok?.type === 'number' && yTok.value >= 1000 && yTok.value <= 2999 && word(tokens[i + 3]) === '-') {
|
|
1498
|
+
const month = MONTHS[word(tokens[i + 4]) ?? ''];
|
|
1499
|
+
if (month !== undefined) {
|
|
1500
|
+
return {
|
|
1501
|
+
expr: { op: 'literal', date: { day: t1.value, month, year: yTok.value } },
|
|
1502
|
+
consumed: 5,
|
|
1503
|
+
confidence: 0.9,
|
|
1504
|
+
role: 'date',
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
const t2 = tokens[i + 1];
|
|
1510
|
+
const t3 = tokens[i + 2];
|
|
1511
|
+
if (t1?.type !== 'number' || t2?.type !== 'number' || t3?.type !== 'number')
|
|
1512
|
+
return undefined;
|
|
1513
|
+
if (t1.ordinal || t2.ordinal || t3.ordinal)
|
|
1514
|
+
return undefined;
|
|
1515
|
+
if (t3.value < 32 || t3.value > 2999 || (t3.value > 99 && t3.value < 1900))
|
|
1516
|
+
return undefined;
|
|
1517
|
+
const year = century(t3.value);
|
|
1518
|
+
const valid = (d, m) => d >= 1 && d <= 31 && m >= 1 && m <= 12;
|
|
1519
|
+
let day = t1.value;
|
|
1520
|
+
let month = t2.value;
|
|
1521
|
+
if (!valid(day, month)) {
|
|
1522
|
+
day = t2.value;
|
|
1523
|
+
month = t1.value;
|
|
1524
|
+
if (!valid(day, month))
|
|
1525
|
+
return undefined;
|
|
1526
|
+
}
|
|
1527
|
+
// Not a duration ("2 heures 50 minutes" never reaches here — t3 is a year).
|
|
1528
|
+
return {
|
|
1529
|
+
expr: { op: 'literal', date: { year, month, day } },
|
|
1530
|
+
consumed: 3,
|
|
1531
|
+
confidence: 0.9,
|
|
1532
|
+
role: 'date',
|
|
1533
|
+
};
|
|
1534
|
+
};
|
|
1535
|
+
/** Bare 4-digit year: "La gamme est de 2014". */
|
|
1536
|
+
const frBareYear = (tokens, i) => {
|
|
1537
|
+
const t = tokens[i];
|
|
1538
|
+
if (t?.type !== 'number' || t.ordinal || t.value < 1900 || t.value > 2159)
|
|
1539
|
+
return undefined;
|
|
1540
|
+
return {
|
|
1541
|
+
expr: { op: 'literal', date: { year: t.value } },
|
|
1542
|
+
consumed: 1,
|
|
1543
|
+
confidence: 0.6,
|
|
1544
|
+
role: 'date',
|
|
1545
|
+
};
|
|
1546
|
+
};
|
|
1547
|
+
/** "l'année calendaire 2008" / "cy 2008" / "cy18". */
|
|
1548
|
+
const frYearCal = (tokens, i) => {
|
|
1549
|
+
const w = word(tokens[i]);
|
|
1550
|
+
const m = w?.match(/^cy(\d{2,4})?$/);
|
|
1551
|
+
if (m) {
|
|
1552
|
+
if (m[1] !== undefined) {
|
|
1553
|
+
return {
|
|
1554
|
+
expr: { op: 'literal', date: { year: century(Number(m[1])) } },
|
|
1555
|
+
consumed: 1,
|
|
1556
|
+
confidence: 0.9,
|
|
1557
|
+
role: 'date',
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
const t = tokens[i + 1];
|
|
1561
|
+
if (t?.type === 'number' && !t.ordinal && (t.value >= 1900 || t.value <= 99)) {
|
|
1562
|
+
return {
|
|
1563
|
+
expr: { op: 'literal', date: { year: century(t.value) } },
|
|
1564
|
+
consumed: 2,
|
|
1565
|
+
confidence: 0.9,
|
|
1566
|
+
role: 'date',
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
return undefined;
|
|
1570
|
+
}
|
|
1571
|
+
const sw = stripElision(w);
|
|
1572
|
+
if ((sw === 'année' || sw === 'annee') && (word(tokens[i + 1]) === 'calendaire' || word(tokens[i + 1]) === 'civile')) {
|
|
1573
|
+
const y = yearAt(tokens, i + 2);
|
|
1574
|
+
if (y !== undefined) {
|
|
1575
|
+
return { expr: { op: 'literal', date: { year: y } }, consumed: 3, confidence: 1, role: 'date' };
|
|
1576
|
+
}
|
|
1577
|
+
return { expr: snapNow('year'), consumed: 2, confidence: 0.9, role: 'date' };
|
|
1578
|
+
}
|
|
1579
|
+
// "l'année 2008" → that year.
|
|
1580
|
+
if (sw === 'année' || sw === 'annee') {
|
|
1581
|
+
const y = yearAt(tokens, i + 1);
|
|
1582
|
+
if (y !== undefined) {
|
|
1583
|
+
return { expr: { op: 'literal', date: { year: y } }, consumed: 2, confidence: 1, role: 'date' };
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return undefined;
|
|
1587
|
+
};
|
|
1588
|
+
/** "3 jours à partir de mardi", "2 semaines avant Noël", "3 jours après le 12 janvier". */
|
|
1589
|
+
const frNAfterDate = (tokens, i, ctx) => {
|
|
1590
|
+
let at = i;
|
|
1591
|
+
if (word(tokens[at]) === 'dans' || word(tokens[at]) === 'en')
|
|
1592
|
+
at += 1;
|
|
1593
|
+
const n = readWordNum(tokens, at);
|
|
1594
|
+
if (!n)
|
|
1595
|
+
return undefined;
|
|
1596
|
+
at += n.consumed;
|
|
1597
|
+
const unit = FR_LEXICON.units[word(tokens[at]) ?? ''];
|
|
1598
|
+
if (!unit)
|
|
1599
|
+
return undefined;
|
|
1600
|
+
at += 1;
|
|
1601
|
+
let sign;
|
|
1602
|
+
const cw = word(tokens[at]);
|
|
1603
|
+
if (cw === 'après' || cw === 'apres' || cw === 'depuis') {
|
|
1604
|
+
sign = 1;
|
|
1605
|
+
at += 1;
|
|
1606
|
+
}
|
|
1607
|
+
else if (cw === 'avant') {
|
|
1608
|
+
sign = -1;
|
|
1609
|
+
at += 1;
|
|
1610
|
+
}
|
|
1611
|
+
else if (cw === 'à' || cw === 'a') {
|
|
1612
|
+
const c1 = word(tokens[at + 1]);
|
|
1613
|
+
if (c1 === 'partir' || c1 === 'compter') {
|
|
1614
|
+
sign = 1;
|
|
1615
|
+
at += 2;
|
|
1616
|
+
}
|
|
1617
|
+
else
|
|
1618
|
+
return undefined;
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
return undefined;
|
|
1622
|
+
}
|
|
1623
|
+
if (word(tokens[at]) === 'de' || word(tokens[at]) === 'du')
|
|
1624
|
+
at += 1;
|
|
1625
|
+
if (word(tokens[at]) === 'le')
|
|
1626
|
+
at += 1;
|
|
1627
|
+
if (stripElision(word(tokens[at])) === 'année' && yearAt(tokens, at + 1) !== undefined)
|
|
1628
|
+
at += 1;
|
|
1629
|
+
// Base: deictic day, weekday, holiday, year, or explicit date.
|
|
1630
|
+
let base;
|
|
1631
|
+
let consumed = 0;
|
|
1632
|
+
const baseYear = yearAt(tokens, at);
|
|
1633
|
+
if (baseYear !== undefined && MONTHS[word(tokens[at + 1]) ?? ''] === undefined) {
|
|
1634
|
+
// "en deux ans depuis 2011" → 2 years after the start of 2011.
|
|
1635
|
+
const shifted0 = {
|
|
1636
|
+
op: 'offset',
|
|
1637
|
+
base: { op: 'literal', date: { year: baseYear } },
|
|
1638
|
+
amount: sign * n.value,
|
|
1639
|
+
unit,
|
|
1640
|
+
};
|
|
1641
|
+
return {
|
|
1642
|
+
expr: { op: 'snap', base: shifted0, unit: 'day' },
|
|
1643
|
+
consumed: at + 1 - i,
|
|
1644
|
+
confidence: 1,
|
|
1645
|
+
role: 'date',
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
const bw = word(tokens[at]);
|
|
1649
|
+
const deictic = FR_LEXICON.deictic[bw ?? ''] ?? FR_LEXICON.deictic[stripElision(bw) === bw ? '' : `d'${stripElision(bw)}`];
|
|
1650
|
+
const deicticDirect = FR_LEXICON.deictic[bw ?? ''];
|
|
1651
|
+
if (deicticDirect !== undefined) {
|
|
1652
|
+
base = snapOffset(deicticDirect, 'day');
|
|
1653
|
+
consumed = 1;
|
|
1654
|
+
}
|
|
1655
|
+
else if (WEEKDAYS[bw ?? '']) {
|
|
1656
|
+
base = { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: WEEKDAYS[bw ?? ''] } };
|
|
1657
|
+
consumed = 1;
|
|
1658
|
+
const pmw = word(tokens[at + 1]);
|
|
1659
|
+
if (pmw === 'prochain' || pmw === 'suivant') {
|
|
1660
|
+
base = { op: 'seek', base: NOW, dir: 'next', target: { kind: 'weekday', weekday: WEEKDAYS[bw ?? ''] } };
|
|
1661
|
+
consumed = 2;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
else {
|
|
1665
|
+
const inner = frHoliday(tokens, at, ctx) ?? frDayMonth(tokens, at, ctx) ?? frNumdate(tokens, at, ctx);
|
|
1666
|
+
if (inner) {
|
|
1667
|
+
base = inner.expr;
|
|
1668
|
+
consumed = inner.consumed;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
void deictic;
|
|
1672
|
+
if (!base)
|
|
1673
|
+
return undefined;
|
|
1674
|
+
const shifted = { op: 'offset', base, amount: sign * n.value, unit };
|
|
1675
|
+
const subDay = unit === 'hour' || unit === 'minute' || unit === 'second';
|
|
1676
|
+
return {
|
|
1677
|
+
expr: subDay ? shifted : { op: 'snap', base: shifted, unit: 'day' },
|
|
1678
|
+
consumed: at + consumed - i,
|
|
1679
|
+
confidence: 1,
|
|
1680
|
+
role: 'date',
|
|
1681
|
+
};
|
|
1682
|
+
};
|
|
1683
|
+
/* ------------------------------ week-of ------------------------------ */
|
|
1684
|
+
/** "la semaine du 10 avril", "la première semaine de janvier 2015", "la 3e semaine de 2018". */
|
|
1685
|
+
const frWeekOf = (tokens, i, ctx) => {
|
|
1686
|
+
let at = i;
|
|
1687
|
+
if (word(tokens[at]) === 'la')
|
|
1688
|
+
at += 1;
|
|
1689
|
+
let nth;
|
|
1690
|
+
const nw = word(tokens[at]);
|
|
1691
|
+
if (nw !== undefined && ORDINAL_WORDS[nw] !== undefined) {
|
|
1692
|
+
nth = ORDINAL_WORDS[nw];
|
|
1693
|
+
at += 1;
|
|
1694
|
+
}
|
|
1695
|
+
else if (nw === 'dernière' || nw === 'derniere') {
|
|
1696
|
+
nth = 'last';
|
|
1697
|
+
at += 1;
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
const ow = ordinalWord(nw);
|
|
1701
|
+
if (ow !== undefined && ow <= 5) {
|
|
1702
|
+
nth = ow;
|
|
1703
|
+
at += 1;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
if (word(tokens[at]) !== 'semaine')
|
|
1707
|
+
return undefined;
|
|
1708
|
+
at += 1;
|
|
1709
|
+
// "la semaine commençant le 4 février" → week of that date.
|
|
1710
|
+
const cw = word(tokens[at]);
|
|
1711
|
+
if (cw === 'commençant' || cw === 'commencant' || cw === 'débutant') {
|
|
1712
|
+
const inner = frDayMonth(tokens, at + 1, ctx) ?? frNumdate(tokens, at + 1, ctx);
|
|
1713
|
+
if (inner) {
|
|
1714
|
+
return {
|
|
1715
|
+
expr: { op: 'snap', base: inner.expr, unit: 'week' },
|
|
1716
|
+
consumed: at + 1 + inner.consumed - i,
|
|
1717
|
+
confidence: 1,
|
|
1718
|
+
role: 'date',
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
return undefined;
|
|
1722
|
+
}
|
|
1723
|
+
let dw = word(tokens[at]);
|
|
1724
|
+
let scope;
|
|
1725
|
+
let scopeUnit = 'month';
|
|
1726
|
+
let consumed = 0;
|
|
1727
|
+
if (dw === 'du' || dw === 'de' || dw === "d'" || (dw !== undefined && /^d['']/.test(dw))) {
|
|
1728
|
+
const elided = dw !== undefined && /^d['']/.test(dw) && dw.length > 2 ? stripElision(dw) : undefined;
|
|
1729
|
+
if (elided !== undefined) {
|
|
1730
|
+
const month = MONTHS[elided];
|
|
1731
|
+
if (month !== undefined) {
|
|
1732
|
+
scope = { op: 'literal', date: { month } };
|
|
1733
|
+
consumed = 1;
|
|
1734
|
+
const y = yearAt(tokens, at + 1);
|
|
1735
|
+
if (y !== undefined) {
|
|
1736
|
+
scope = { op: 'literal', date: { month, year: y } };
|
|
1737
|
+
consumed = 2;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
return undefined;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
at += 1;
|
|
1746
|
+
// date scope: "du 10 avril" / "du 18"
|
|
1747
|
+
const inner = frDayMonth(tokens, at, ctx) ?? frNumdate(tokens, at, ctx);
|
|
1748
|
+
if (inner && nth === undefined) {
|
|
1749
|
+
return {
|
|
1750
|
+
expr: { op: 'snap', base: inner.expr, unit: 'week' },
|
|
1751
|
+
consumed: at + inner.consumed - i,
|
|
1752
|
+
confidence: 1,
|
|
1753
|
+
role: 'date',
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
const t0 = tokens[at];
|
|
1757
|
+
const mw = word(t0);
|
|
1758
|
+
const month = MONTHS[mw ?? ''];
|
|
1759
|
+
const y0 = yearAt(tokens, at);
|
|
1760
|
+
if (month !== undefined) {
|
|
1761
|
+
const y = yearAt(tokens, at + 1);
|
|
1762
|
+
scope = y !== undefined ? { op: 'literal', date: { month, year: y } } : { op: 'literal', date: { month } };
|
|
1763
|
+
consumed = y !== undefined ? 2 : 1;
|
|
1764
|
+
}
|
|
1765
|
+
else if (y0 !== undefined) {
|
|
1766
|
+
scope = { op: 'literal', date: { year: y0 } };
|
|
1767
|
+
scopeUnit = 'year';
|
|
1768
|
+
consumed = 1;
|
|
1769
|
+
}
|
|
1770
|
+
else if (t0?.type === 'number' && t0.value >= 1 && t0.value <= 31 && nth === undefined) {
|
|
1771
|
+
return {
|
|
1772
|
+
expr: { op: 'snap', base: { op: 'literal', date: { day: t0.value } }, unit: 'week' },
|
|
1773
|
+
consumed: at + 1 - i,
|
|
1774
|
+
confidence: 0.9,
|
|
1775
|
+
role: 'date',
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
else {
|
|
1779
|
+
return undefined;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
else if (yearAt(tokens, at) !== undefined) {
|
|
1784
|
+
scope = { op: 'literal', date: { year: yearAt(tokens, at) } };
|
|
1785
|
+
scopeUnit = 'year';
|
|
1786
|
+
consumed = 1;
|
|
1787
|
+
}
|
|
1788
|
+
else if (tokens[at]?.type === 'number' && !(tokens[at].ordinal) && tokens[at].value <= 53) {
|
|
1789
|
+
// "la semaine 31" → ISO week n of the current year.
|
|
1790
|
+
const n = tokens[at].value;
|
|
1791
|
+
const jan4 = { op: 'snap', base: { op: 'intersect', parts: [snapNow('year'), { op: 'literal', date: { month: 1, day: 4 } }] }, unit: 'week' };
|
|
1792
|
+
const expr = n === 1 ? jan4 : { op: 'offset', base: jan4, amount: n - 1, unit: 'week' };
|
|
1793
|
+
return { expr, consumed: at + 1 - i, confidence: 0.9, role: 'date' };
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
return undefined;
|
|
1797
|
+
}
|
|
1798
|
+
if (scope === undefined)
|
|
1799
|
+
return undefined;
|
|
1800
|
+
if (scope.op === 'literal' && scope.date?.year !== undefined && scope.date.month === undefined)
|
|
1801
|
+
scopeUnit = 'year';
|
|
1802
|
+
let expr;
|
|
1803
|
+
if (nth === undefined) {
|
|
1804
|
+
expr = { op: 'snap', base: scope, unit: 'week' };
|
|
1805
|
+
}
|
|
1806
|
+
else if (nth === 'last') {
|
|
1807
|
+
expr = {
|
|
1808
|
+
op: 'snap',
|
|
1809
|
+
base: {
|
|
1810
|
+
op: 'offset',
|
|
1811
|
+
base: { op: 'snap', base: scope, unit: scopeUnit, edge: 'end' },
|
|
1812
|
+
amount: -4,
|
|
1813
|
+
unit: 'day',
|
|
1814
|
+
},
|
|
1815
|
+
unit: 'week',
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
else {
|
|
1819
|
+
const first = {
|
|
1820
|
+
op: 'snap',
|
|
1821
|
+
base: { op: 'snap', base: scope, unit: scopeUnit, edge: 'start' },
|
|
1822
|
+
unit: 'week',
|
|
1823
|
+
};
|
|
1824
|
+
expr = nth === 1 ? first : { op: 'offset', base: first, amount: nth - 1, unit: 'week' };
|
|
1825
|
+
}
|
|
1826
|
+
return { expr, consumed: at + consumed - i, confidence: 1, role: 'date' };
|
|
1827
|
+
};
|
|
1828
|
+
function readRangeOperand(tokens, i, ctx, preferNum = false) {
|
|
1829
|
+
let at = i;
|
|
1830
|
+
let guard = 0;
|
|
1831
|
+
while (guard < 2) {
|
|
1832
|
+
const w0 = word(tokens[at]);
|
|
1833
|
+
if (w0 === 'le' || w0 === 'la' || w0 === 'les' || w0 === 'ce' || w0 === 'cette' || w0 === 'cet')
|
|
1834
|
+
at += 1;
|
|
1835
|
+
else
|
|
1836
|
+
break;
|
|
1837
|
+
guard += 1;
|
|
1838
|
+
}
|
|
1839
|
+
const t = tokens[at];
|
|
1840
|
+
const w = word(t);
|
|
1841
|
+
const sw = stripElision(w);
|
|
1842
|
+
if (w === 'maintenant')
|
|
1843
|
+
return { op: { kind: 'expr', expr: NOW }, consumed: at + 1 - i };
|
|
1844
|
+
if (w === 'jour' && at > i)
|
|
1845
|
+
return { op: { kind: 'expr', expr: snapNow('day') }, consumed: at + 1 - i };
|
|
1846
|
+
const deictic = FR_LEXICON.deictic[w ?? ''] ?? FR_LEXICON.deictic[sw ?? ''];
|
|
1847
|
+
if (deictic !== undefined) {
|
|
1848
|
+
return { op: { kind: 'expr', expr: snapOffset(deictic, 'day') }, consumed: at + 1 - i };
|
|
1849
|
+
}
|
|
1850
|
+
// fin/début/milieu of a scope.
|
|
1851
|
+
let ew = w;
|
|
1852
|
+
let mode;
|
|
1853
|
+
if (ew === 'fin')
|
|
1854
|
+
mode = 'end';
|
|
1855
|
+
else if (ew === 'début' || ew === 'debut')
|
|
1856
|
+
mode = 'start';
|
|
1857
|
+
else if (ew === 'milieu')
|
|
1858
|
+
mode = 'mid';
|
|
1859
|
+
else if (ew === 'mi')
|
|
1860
|
+
mode = 'mid';
|
|
1861
|
+
if (mode !== undefined) {
|
|
1862
|
+
let cursor = at + 1;
|
|
1863
|
+
const dw = word(tokens[cursor]);
|
|
1864
|
+
if (dw === 'de' || dw === 'du' || dw === '-')
|
|
1865
|
+
cursor += 1;
|
|
1866
|
+
let inner = readRangeOperand(tokens, cursor, ctx);
|
|
1867
|
+
if (!inner && dw !== undefined && /^d['']/.test(dw)) {
|
|
1868
|
+
// "fin d'année", "début d'octobre"
|
|
1869
|
+
const dsw = stripElision(dw);
|
|
1870
|
+
const month = MONTHS[dsw ?? ''];
|
|
1871
|
+
if (month !== undefined) {
|
|
1872
|
+
return { op: { kind: 'month', month, mod: mode }, consumed: at + 2 - i };
|
|
1873
|
+
}
|
|
1874
|
+
return undefined;
|
|
1875
|
+
}
|
|
1876
|
+
if (!inner)
|
|
1877
|
+
return undefined;
|
|
1878
|
+
const io = inner.op;
|
|
1879
|
+
const consumedAll = cursor + inner.consumed - i;
|
|
1880
|
+
// Range boundaries want points/dates, not sub-spans: "de 2007 à la fin de
|
|
1881
|
+
// 2008" runs through the END of 2008; "la mi-mars" is March 16.
|
|
1882
|
+
if (io.kind === 'year') {
|
|
1883
|
+
const lit = { op: 'literal', date: { year: io.year } };
|
|
1884
|
+
const expr = mode === 'mid'
|
|
1885
|
+
? { op: 'offset', base: { op: 'snap', base: lit, unit: 'year', edge: 'start' }, amount: 182, unit: 'day' }
|
|
1886
|
+
: { op: 'snap', base: lit, unit: 'year', edge: mode };
|
|
1887
|
+
return { op: { kind: 'expr', expr }, consumed: consumedAll };
|
|
1888
|
+
}
|
|
1889
|
+
if (io.kind === 'month') {
|
|
1890
|
+
const date = { month: io.month };
|
|
1891
|
+
if (io.year !== undefined)
|
|
1892
|
+
date.year = io.year;
|
|
1893
|
+
if (mode === 'mid') {
|
|
1894
|
+
return { op: { kind: 'date', date: { ...date, day: 16 } }, consumed: consumedAll };
|
|
1895
|
+
}
|
|
1896
|
+
return {
|
|
1897
|
+
op: { kind: 'expr', expr: { op: 'snap', base: { op: 'literal', date }, unit: 'month', edge: mode } },
|
|
1898
|
+
consumed: consumedAll,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
if (io.kind === 'expr') {
|
|
1902
|
+
return { op: { kind: 'expr', expr: { ...io.expr, mod: mode } }, consumed: consumedAll };
|
|
1903
|
+
}
|
|
1904
|
+
return undefined;
|
|
1905
|
+
}
|
|
1906
|
+
// clock forms
|
|
1907
|
+
const time = readTime(tokens, at);
|
|
1908
|
+
if (time) {
|
|
1909
|
+
let cursor = at + time.consumed;
|
|
1910
|
+
let pt = time.time;
|
|
1911
|
+
const ps = readPeriodSuffix(tokens, cursor);
|
|
1912
|
+
if (ps) {
|
|
1913
|
+
pt = applyPeriod(pt, ps.period);
|
|
1914
|
+
cursor += ps.consumed;
|
|
1915
|
+
}
|
|
1916
|
+
return { op: { kind: 'time', time: pt }, consumed: cursor - i };
|
|
1917
|
+
}
|
|
1918
|
+
if (t?.type === 'numdate') {
|
|
1919
|
+
const date = numdateToDate(t);
|
|
1920
|
+
if (date)
|
|
1921
|
+
return { op: { kind: 'date', date }, consumed: at + 1 - i };
|
|
1922
|
+
}
|
|
1923
|
+
const wd = WEEKDAYS[w ?? ''];
|
|
1924
|
+
if (wd) {
|
|
1925
|
+
let cursor = at + 1;
|
|
1926
|
+
let dir = 'nearest';
|
|
1927
|
+
const pm = word(tokens[cursor]);
|
|
1928
|
+
if (pm === 'prochain' || pm === 'suivant') {
|
|
1929
|
+
dir = 'next';
|
|
1930
|
+
cursor += 1;
|
|
1931
|
+
}
|
|
1932
|
+
else if (pm === 'dernier' || pm === 'précédent') {
|
|
1933
|
+
dir = 'prev';
|
|
1934
|
+
cursor += 1;
|
|
1935
|
+
}
|
|
1936
|
+
// weekday + explicit date → let date rules own it (skip weekday).
|
|
1937
|
+
const dm = frDayMonth(tokens, cursor, ctx) ?? frNumdate(tokens, cursor, ctx);
|
|
1938
|
+
if (dm && dm.expr.op === 'literal') {
|
|
1939
|
+
return { op: { kind: 'date', date: dm.expr.date }, consumed: cursor + dm.consumed - i };
|
|
1940
|
+
}
|
|
1941
|
+
return {
|
|
1942
|
+
op: { kind: 'expr', expr: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: wd } } },
|
|
1943
|
+
consumed: cursor - i,
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
// Quarters/halves as operands: "du q1 de 2017 au q3 de 2018".
|
|
1947
|
+
const q = readQuarter(tokens, at);
|
|
1948
|
+
if (q)
|
|
1949
|
+
return { op: { kind: 'expr', expr: quarterExpr(q) }, consumed: at + q.consumed - i };
|
|
1950
|
+
const year0 = yearAt(tokens, at);
|
|
1951
|
+
const month0 = MONTHS[w ?? ''] ?? MONTHS[sw ?? ''];
|
|
1952
|
+
// "de 5 a sept du matin": with a numeric/time left side, the ambiguous
|
|
1953
|
+
// "sept" reads as the number 7, not September.
|
|
1954
|
+
const numFirst = preferNum && (ONES[w ?? ''] !== undefined || TEENS[w ?? ''] !== undefined);
|
|
1955
|
+
if (month0 !== undefined && !numFirst) {
|
|
1956
|
+
const y = yearAt(tokens, at + 1);
|
|
1957
|
+
if (y !== undefined)
|
|
1958
|
+
return { op: { kind: 'month', month: month0, year: y }, consumed: at + 2 - i };
|
|
1959
|
+
return { op: { kind: 'month', month: month0 }, consumed: at + 1 - i };
|
|
1960
|
+
}
|
|
1961
|
+
// day + month (+ year)
|
|
1962
|
+
const dnum = readWordNum(tokens, at);
|
|
1963
|
+
if (dnum && dnum.value >= 1 && dnum.value <= 31) {
|
|
1964
|
+
let cursor = at + dnum.consumed;
|
|
1965
|
+
let mw = word(tokens[cursor]);
|
|
1966
|
+
let msw = stripElision(mw);
|
|
1967
|
+
if (mw === 'de' || mw === 'du') {
|
|
1968
|
+
const m2 = word(tokens[cursor + 1]);
|
|
1969
|
+
if (MONTHS[m2 ?? ''] !== undefined) {
|
|
1970
|
+
cursor += 1;
|
|
1971
|
+
mw = word(tokens[cursor]);
|
|
1972
|
+
msw = stripElision(mw);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
const month = MONTHS[mw ?? ''] ?? MONTHS[msw ?? ''];
|
|
1976
|
+
if (month !== undefined) {
|
|
1977
|
+
cursor += 1;
|
|
1978
|
+
let yAt = cursor;
|
|
1979
|
+
if (word(tokens[yAt]) === 'de')
|
|
1980
|
+
yAt += 1;
|
|
1981
|
+
const y = yearAt(tokens, yAt);
|
|
1982
|
+
const date = { day: dnum.value, month };
|
|
1983
|
+
if (y !== undefined) {
|
|
1984
|
+
date.year = y;
|
|
1985
|
+
cursor = yAt + 1;
|
|
1986
|
+
}
|
|
1987
|
+
return { op: { kind: 'date', date }, consumed: cursor - i };
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (year0 !== undefined)
|
|
1991
|
+
return { op: { kind: 'year', year: year0 }, consumed: at + 1 - i };
|
|
1992
|
+
if (dnum && dnum.value >= 0 && dnum.value <= 31) {
|
|
1993
|
+
let cursor = at + dnum.consumed;
|
|
1994
|
+
// "5 du matin" / "3 matin" → an hour with a period.
|
|
1995
|
+
const ps = readPeriodSuffix(tokens, cursor);
|
|
1996
|
+
const pd = ps ?? readPeriodWord(tokens, cursor);
|
|
1997
|
+
if (pd !== undefined && dnum.value <= 23) {
|
|
1998
|
+
cursor += pd.consumed;
|
|
1999
|
+
const time = { hour: dnum.value };
|
|
2000
|
+
if (dnum.value <= 12)
|
|
2001
|
+
time.meridiem = meridiemFor(pd.period);
|
|
2002
|
+
return { op: { kind: 'time', time }, consumed: cursor - i };
|
|
2003
|
+
}
|
|
2004
|
+
return { op: { kind: 'num', value: dnum.value }, consumed: cursor - i };
|
|
2005
|
+
}
|
|
2006
|
+
return undefined;
|
|
2007
|
+
}
|
|
2008
|
+
function operandToDateExpr(op) {
|
|
2009
|
+
switch (op.kind) {
|
|
2010
|
+
case 'date':
|
|
2011
|
+
return { op: 'literal', date: op.date };
|
|
2012
|
+
case 'month': {
|
|
2013
|
+
const date = { month: op.month };
|
|
2014
|
+
if (op.year !== undefined)
|
|
2015
|
+
date.year = op.year;
|
|
2016
|
+
const lit = { op: 'literal', date };
|
|
2017
|
+
return op.mod !== undefined ? { ...lit, mod: op.mod } : lit;
|
|
2018
|
+
}
|
|
2019
|
+
case 'year':
|
|
2020
|
+
return { op: 'literal', date: { year: op.year } };
|
|
2021
|
+
case 'expr':
|
|
2022
|
+
return op.expr;
|
|
2023
|
+
default:
|
|
2024
|
+
return undefined;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* A day-number pair followed by a month scope: "du 4 au 23 le mois prochain",
|
|
2029
|
+
* "de 4-23 mois prochain". Returns the full range match or undefined.
|
|
2030
|
+
*/
|
|
2031
|
+
function finishNumPair(tokens, at, n1, n2, ruleStart) {
|
|
2032
|
+
let cursor = at;
|
|
2033
|
+
if (word(tokens[cursor]) === 'le' || word(tokens[cursor]) === 'du' || word(tokens[cursor]) === 'de')
|
|
2034
|
+
cursor += 1;
|
|
2035
|
+
if (word(tokens[cursor]) === 'mois') {
|
|
2036
|
+
const mod = word(tokens[cursor + 1]);
|
|
2037
|
+
const delta = mod === 'prochain' || mod === 'suivant' ? 1 : mod === 'dernier' || mod === 'précédent' ? -1 : undefined;
|
|
2038
|
+
if (delta !== undefined && n1 >= 1 && n1 <= 31 && n2 >= 1 && n2 <= 31) {
|
|
2039
|
+
const monthExpr = snapOffset(delta, 'month');
|
|
2040
|
+
const mk = (d) => ({
|
|
2041
|
+
op: 'intersect',
|
|
2042
|
+
parts: [monthExpr, { op: 'literal', date: { day: d } }],
|
|
2043
|
+
});
|
|
2044
|
+
return {
|
|
2045
|
+
expr: { op: 'between', start: mk(n1), end: mk(n2) },
|
|
2046
|
+
consumed: cursor + 2 - ruleStart,
|
|
2047
|
+
confidence: 1,
|
|
2048
|
+
role: 'date',
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const month = MONTHS[word(tokens[cursor]) ?? ''] ?? MONTHS[stripElision(word(tokens[cursor])) ?? ''];
|
|
2053
|
+
if (month !== undefined && n1 >= 1 && n1 <= 31 && n2 >= 1 && n2 <= 31) {
|
|
2054
|
+
const y = yearAt(tokens, cursor + 1);
|
|
2055
|
+
const mk = (d) => y !== undefined
|
|
2056
|
+
? { op: 'literal', date: { day: d, month, year: y } }
|
|
2057
|
+
: { op: 'literal', date: { day: d, month } };
|
|
2058
|
+
return {
|
|
2059
|
+
expr: { op: 'between', start: mk(n1), end: mk(n2) },
|
|
2060
|
+
consumed: (y !== undefined ? cursor + 2 : cursor + 1) - ruleStart,
|
|
2061
|
+
confidence: 1,
|
|
2062
|
+
role: 'date',
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
return undefined;
|
|
2066
|
+
}
|
|
2067
|
+
/** "de X à Y" / "entre X et Y" / "du X au Y" ranges. */
|
|
2068
|
+
const frRange = (tokens, i, ctx) => {
|
|
2069
|
+
let at = i;
|
|
2070
|
+
const w0 = word(tokens[at]);
|
|
2071
|
+
let hasPrefix = false;
|
|
2072
|
+
let elidedFirst;
|
|
2073
|
+
if (w0 === 'de' || w0 === 'du' || w0 === 'entre' || w0 === 'des') {
|
|
2074
|
+
hasPrefix = true;
|
|
2075
|
+
at += 1;
|
|
2076
|
+
}
|
|
2077
|
+
else if (w0 !== undefined && /^d['']/.test(w0) && w0.length > 2) {
|
|
2078
|
+
// "d'avril à …" — prefix and operand fused in one token.
|
|
2079
|
+
hasPrefix = true;
|
|
2080
|
+
elidedFirst = stripElision(w0);
|
|
2081
|
+
}
|
|
2082
|
+
// Joined pair after a prefix: "de 4-23 mois prochain".
|
|
2083
|
+
const joinedPair = hasPrefix ? word(tokens[at])?.match(/^(\d{1,2})-(\d{1,2})$/) : null;
|
|
2084
|
+
if (joinedPair) {
|
|
2085
|
+
const scoped = finishNumPair(tokens, at + 1, Number(joinedPair[1]), Number(joinedPair[2]), i);
|
|
2086
|
+
if (scoped)
|
|
2087
|
+
return scoped;
|
|
2088
|
+
}
|
|
2089
|
+
let a;
|
|
2090
|
+
let consumedA;
|
|
2091
|
+
if (elidedFirst !== undefined) {
|
|
2092
|
+
const month = MONTHS[elidedFirst];
|
|
2093
|
+
const deictic = FR_LEXICON.deictic[word(tokens[at]) ?? ''];
|
|
2094
|
+
if (month !== undefined) {
|
|
2095
|
+
const y = yearAt(tokens, at + 1);
|
|
2096
|
+
if (y !== undefined) {
|
|
2097
|
+
a = { kind: 'month', month, year: y };
|
|
2098
|
+
consumedA = 2;
|
|
2099
|
+
}
|
|
2100
|
+
else {
|
|
2101
|
+
a = { kind: 'month', month };
|
|
2102
|
+
consumedA = 1;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
else if (deictic !== undefined) {
|
|
2106
|
+
a = { kind: 'expr', expr: snapOffset(deictic, 'day') };
|
|
2107
|
+
consumedA = 1;
|
|
2108
|
+
}
|
|
2109
|
+
else {
|
|
2110
|
+
return undefined;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
else {
|
|
2114
|
+
const read = readRangeOperand(tokens, at, ctx);
|
|
2115
|
+
if (!read)
|
|
2116
|
+
return undefined;
|
|
2117
|
+
a = read.op;
|
|
2118
|
+
consumedA = read.consumed;
|
|
2119
|
+
}
|
|
2120
|
+
at += consumedA;
|
|
2121
|
+
const conn = word(tokens[at]);
|
|
2122
|
+
const connOk = conn === 'à' || conn === 'a' || conn === 'au' || conn === 'aux' || conn === '-' || conn === '~' ||
|
|
2123
|
+
conn === "jusqu'à" || conn === "jusqu'au" || conn === "jusqu'a" ||
|
|
2124
|
+
(conn === 'et' && hasPrefix);
|
|
2125
|
+
if (!connOk)
|
|
2126
|
+
return undefined;
|
|
2127
|
+
// Plain "N à M" without prefix is too loose unless timeish.
|
|
2128
|
+
if (!hasPrefix && a.kind !== 'time' && a.kind !== 'num')
|
|
2129
|
+
return undefined;
|
|
2130
|
+
at += 1;
|
|
2131
|
+
const readB = readRangeOperand(tokens, at, ctx, a.kind === 'num' || a.kind === 'time');
|
|
2132
|
+
if (!readB)
|
|
2133
|
+
return undefined;
|
|
2134
|
+
let b = readB.op;
|
|
2135
|
+
at += readB.consumed;
|
|
2136
|
+
// Shared suffixes -----------------------------------------------------
|
|
2137
|
+
// Month scope after a number pair: "du 18 au 19 novembre", "du 9 au 12 de
|
|
2138
|
+
// juin", "du 4 au 23 le mois prochain".
|
|
2139
|
+
if (a.kind === 'num' && b.kind === 'num') {
|
|
2140
|
+
const scoped = finishNumPair(tokens, at, a.value, b.value, i);
|
|
2141
|
+
if (scoped)
|
|
2142
|
+
return scoped;
|
|
2143
|
+
}
|
|
2144
|
+
// Month applies backward when only B got it: "du 4 novembre au 5 février 2017"
|
|
2145
|
+
if (a.kind === 'num' && b.kind === 'date') {
|
|
2146
|
+
a = { kind: 'date', date: { day: a.value, month: b.date.month } };
|
|
2147
|
+
}
|
|
2148
|
+
// Shared trailing year for month/date pairs: "de janvier à février 2017".
|
|
2149
|
+
if (b.kind === 'month' && b.year !== undefined && a.kind === 'month' && a.year === undefined && a.month <= b.month) {
|
|
2150
|
+
a = { ...a, year: b.year };
|
|
2151
|
+
}
|
|
2152
|
+
if (b.kind === 'date' && b.date.year !== undefined && a.kind === 'date' && a.date.year === undefined) {
|
|
2153
|
+
const am = a.date.month ?? 0;
|
|
2154
|
+
const bm = b.date.month ?? 0;
|
|
2155
|
+
if (am < bm || (am === bm && (a.date.day ?? 0) <= (b.date.day ?? 32))) {
|
|
2156
|
+
a = { kind: 'date', date: { ...a.date, year: b.date.year } };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
// Trailing year applying to both dates: "du 01/05 au 07/05, 2020".
|
|
2160
|
+
if (a.kind === 'date' && b.kind === 'date' && a.date.year === undefined && b.date.year === undefined) {
|
|
2161
|
+
const y = yearAt(tokens, at);
|
|
2162
|
+
if (y !== undefined) {
|
|
2163
|
+
a = { kind: 'date', date: { ...a.date, year: y } };
|
|
2164
|
+
b = { kind: 'date', date: { ...b.date, year: y } };
|
|
2165
|
+
at += 1;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
// Trailing period: "entre 5h et 6h de l'après-midi".
|
|
2169
|
+
const ps = readPeriodSuffix(tokens, at);
|
|
2170
|
+
let sharedPeriod;
|
|
2171
|
+
if (ps && (a.kind === 'time' || a.kind === 'num') && (b.kind === 'time' || b.kind === 'num')) {
|
|
2172
|
+
sharedPeriod = ps.period;
|
|
2173
|
+
at += ps.consumed;
|
|
2174
|
+
}
|
|
2175
|
+
// Build ---------------------------------------------------------------
|
|
2176
|
+
const numToTime = (n) => (n <= 12 ? { hour: n, meridiem: 'unknown' } : { hour: n });
|
|
2177
|
+
const timeish = (o) => o.kind === 'time' || o.kind === 'num';
|
|
2178
|
+
const asTime = (o) => o.kind === 'time' ? o.time : o.kind === 'num' ? numToTime(o.value) : undefined;
|
|
2179
|
+
if (timeish(a) && timeish(b)) {
|
|
2180
|
+
let ta = asTime(a);
|
|
2181
|
+
let tb = asTime(b);
|
|
2182
|
+
if (sharedPeriod !== undefined) {
|
|
2183
|
+
ta = applyPeriod(ta, sharedPeriod);
|
|
2184
|
+
tb = applyPeriod(tb, sharedPeriod);
|
|
2185
|
+
}
|
|
2186
|
+
// Share B's meridiem with A within the same half of the day.
|
|
2187
|
+
if (ta.meridiem === 'unknown' && tb.meridiem !== undefined && tb.meridiem !== 'unknown' && (ta.hour ?? 0) < (tb.hour ?? 0)) {
|
|
2188
|
+
ta = { ...ta, meridiem: tb.meridiem };
|
|
2189
|
+
}
|
|
2190
|
+
return {
|
|
2191
|
+
expr: { op: 'between', start: timeLiteral(ta), end: timeLiteral(tb) },
|
|
2192
|
+
consumed: at - i,
|
|
2193
|
+
confidence: hasPrefix ? 0.95 : 0.85,
|
|
2194
|
+
role: 'time',
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
const dayLit = (o) => o.kind === 'num' ? { op: 'literal', date: { day: o.value } } : undefined;
|
|
2198
|
+
const ea = operandToDateExpr(a) ?? dayLit(a);
|
|
2199
|
+
const eb = operandToDateExpr(b) ?? dayLit(b);
|
|
2200
|
+
if (!ea || !eb)
|
|
2201
|
+
return undefined;
|
|
2202
|
+
if (!hasPrefix)
|
|
2203
|
+
return undefined;
|
|
2204
|
+
return {
|
|
2205
|
+
expr: { op: 'between', start: ea, end: eb },
|
|
2206
|
+
consumed: at - i,
|
|
2207
|
+
confidence: 0.95,
|
|
2208
|
+
role: 'date',
|
|
2209
|
+
};
|
|
2210
|
+
};
|
|
2211
|
+
/* ------------------------------- lexicon ------------------------------- */
|
|
2212
|
+
export const FR_LEXICON = {
|
|
2213
|
+
articles: ['le', 'la', 'les', 'l', 'un', 'une', 'des'],
|
|
2214
|
+
units: {
|
|
2215
|
+
seconde: 'second', secondes: 'second', sec: 'second',
|
|
2216
|
+
minute: 'minute', minutes: 'minute', min: 'minute',
|
|
2217
|
+
heure: 'hour', heures: 'hour', "l'heure": 'hour',
|
|
2218
|
+
jour: 'day', jours: 'day', journée: 'day', journee: 'day', journées: 'day', journees: 'day',
|
|
2219
|
+
semaine: 'week', semaines: 'week',
|
|
2220
|
+
mois: 'month',
|
|
2221
|
+
trimestre: 'quarter', trimestres: 'quarter',
|
|
2222
|
+
an: 'year', ans: 'year', année: 'year', annee: 'year', années: 'year', annees: 'year',
|
|
2223
|
+
"l'année": 'year', "l'annee": 'year',
|
|
2224
|
+
},
|
|
2225
|
+
weekdays: WEEKDAYS,
|
|
2226
|
+
months: {
|
|
2227
|
+
...MONTHS,
|
|
2228
|
+
"d'avril": 4, "d'août": 8, "d'aout": 8, "d'octobre": 10,
|
|
2229
|
+
},
|
|
2230
|
+
periods: PERIODS,
|
|
2231
|
+
smallNumbers: {
|
|
2232
|
+
...ONES, ...TEENS, ...TENS,
|
|
2233
|
+
quelques: 3, plusieurs: 3,
|
|
2234
|
+
},
|
|
2235
|
+
deictic: {
|
|
2236
|
+
"aujourd'hui": 0, "d'aujourd'hui": 0,
|
|
2237
|
+
hier: -1,
|
|
2238
|
+
demain: 1,
|
|
2239
|
+
lendemain: 1,
|
|
2240
|
+
'après-demain': 2, 'apres-demain': 2,
|
|
2241
|
+
veille: -1,
|
|
2242
|
+
},
|
|
2243
|
+
deicticPhrases: [
|
|
2244
|
+
{ words: ['avant', '-', 'hier'], delta: -2 },
|
|
2245
|
+
{ words: ['apres', '-', 'demain'], delta: 2 },
|
|
2246
|
+
],
|
|
2247
|
+
preMods: {
|
|
2248
|
+
ce: 0, cet: 0, cette: 0,
|
|
2249
|
+
prochain: 1, prochaine: 1,
|
|
2250
|
+
dernier: -1, dernière: -1, derniere: -1,
|
|
2251
|
+
},
|
|
2252
|
+
postMods: {
|
|
2253
|
+
prochain: 1, prochaine: 1,
|
|
2254
|
+
suivant: 1, suivante: 1,
|
|
2255
|
+
dernier: -1, dernière: -1, derniere: -1,
|
|
2256
|
+
'passé': -1, 'passée': -1, passe: -1, passee: -1,
|
|
2257
|
+
'précédent': -1, 'précédente': -1, precedent: -1, precedente: -1,
|
|
2258
|
+
},
|
|
2259
|
+
postModPhrases: [
|
|
2260
|
+
{ words: ['à', 'venir'], delta: 1 },
|
|
2261
|
+
{ words: ['a', 'venir'], delta: 1 },
|
|
2262
|
+
{ words: ['en', 'cours'], delta: 0 },
|
|
2263
|
+
{ words: ['-', 'ci'], delta: 0 },
|
|
2264
|
+
],
|
|
2265
|
+
agoPrefixes: [
|
|
2266
|
+
['il', 'y', 'a', 'à', 'peu', 'près'],
|
|
2267
|
+
['il', 'y', 'a', 'environ'],
|
|
2268
|
+
['il', 'y', 'a'],
|
|
2269
|
+
['il', 'ya'],
|
|
2270
|
+
],
|
|
2271
|
+
inPrefixes: [['dans'], ["d'ici"], ['en']],
|
|
2272
|
+
lastNAdjs: ['derniers', 'dernières', 'dernieres', 'passés', 'passées'],
|
|
2273
|
+
nextNAdjs: ['prochains', 'prochaines', 'suivants', 'suivantes'],
|
|
2274
|
+
atPhrases: [['à'], ['a'], ['vers']],
|
|
2275
|
+
periodMarkers: [
|
|
2276
|
+
['dans', 'la'], ['dans', 'le'], ['dans'],
|
|
2277
|
+
['du'], ['de', 'la'], ['de', 'le'],
|
|
2278
|
+
['le'], ['la'], ['en'], ['au'],
|
|
2279
|
+
],
|
|
2280
|
+
dateSep: ['de', 'du'],
|
|
2281
|
+
weekendPhrases: [['week', '-', 'end'], ['weekend'], ['week-end-là']],
|
|
2282
|
+
specialPhrases: [
|
|
2283
|
+
{ words: ['maintenant'], expr: NOW, role: 'datetime' },
|
|
2284
|
+
{ words: ['ce', 'jour'], expr: { op: 'snap', base: NOW, unit: 'day' }, role: 'date' },
|
|
2285
|
+
{ words: ['dîner'], expr: { op: 'literal', dayPeriod: 'evening' }, role: 'time' },
|
|
2286
|
+
{ words: ['diner'], expr: { op: 'literal', dayPeriod: 'evening' }, role: 'time' },
|
|
2287
|
+
{
|
|
2288
|
+
// Lunch block per Recognizers: 11:00–13:00.
|
|
2289
|
+
words: ['déjeuner'],
|
|
2290
|
+
expr: {
|
|
2291
|
+
op: 'between',
|
|
2292
|
+
start: { op: 'literal', time: { hour: 11 } },
|
|
2293
|
+
end: { op: 'literal', time: { hour: 13 } },
|
|
2294
|
+
},
|
|
2295
|
+
role: 'time',
|
|
2296
|
+
},
|
|
2297
|
+
{ words: ['le', 'plus', 'tôt', 'possible'], expr: NOW, role: 'datetime' },
|
|
2298
|
+
{ words: ['dès', 'que', 'possible'], expr: NOW, role: 'datetime' },
|
|
2299
|
+
// Recognizers-French reads "<day> soir" as the night block (20:00–24:00)
|
|
2300
|
+
// but "ce soir" as the evening; encode the day-qualified forms explicitly.
|
|
2301
|
+
{
|
|
2302
|
+
words: ['hier', 'soir'],
|
|
2303
|
+
expr: {
|
|
2304
|
+
op: 'intersect',
|
|
2305
|
+
parts: [snapOffset(-1, 'day'), { op: 'literal', dayPeriod: 'night' }],
|
|
2306
|
+
},
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
words: ['demain', 'soir'],
|
|
2310
|
+
expr: {
|
|
2311
|
+
op: 'intersect',
|
|
2312
|
+
parts: [snapOffset(1, 'day'), { op: 'literal', dayPeriod: 'night' }],
|
|
2313
|
+
},
|
|
2314
|
+
},
|
|
2315
|
+
{
|
|
2316
|
+
words: ['lendemain', 'soir'],
|
|
2317
|
+
expr: {
|
|
2318
|
+
op: 'intersect',
|
|
2319
|
+
parts: [snapOffset(1, 'day'), { op: 'literal', dayPeriod: 'night' }],
|
|
2320
|
+
},
|
|
2321
|
+
},
|
|
2322
|
+
],
|
|
2323
|
+
noonWords: ['midi'],
|
|
2324
|
+
midnightWords: ['minuit'],
|
|
2325
|
+
};
|
|
2326
|
+
const frExtras = [
|
|
2327
|
+
{ name: 'fr-range', rule: frRange },
|
|
2328
|
+
{ name: 'fr-week-of', rule: frWeekOf },
|
|
2329
|
+
{ name: 'fr-n-after-date', rule: frNAfterDate },
|
|
2330
|
+
{ name: 'fr-early-late', rule: frEarlyLate },
|
|
2331
|
+
{ name: 'fr-edge', rule: frEdge },
|
|
2332
|
+
{ name: 'fr-quarter', rule: frQuarter },
|
|
2333
|
+
{ name: 'fr-slash-date', rule: frSlashDate },
|
|
2334
|
+
{ name: 'fr-spaced-date', rule: frSpacedDate },
|
|
2335
|
+
{ name: 'fr-year-cal', rule: frYearCal },
|
|
2336
|
+
{ name: 'fr-bare-year', rule: frBareYear },
|
|
2337
|
+
{ name: 'fr-duration', rule: frDuration },
|
|
2338
|
+
{ name: 'fr-all-unit', rule: frAllUnit },
|
|
2339
|
+
{ name: 'fr-n-unit-post', rule: frNUnitPost },
|
|
2340
|
+
{ name: 'fr-next-n-work', rule: frNextNWork },
|
|
2341
|
+
{ name: 'fr-in-les-h', rule: frInLesH },
|
|
2342
|
+
{ name: 'fr-time', rule: frTime },
|
|
2343
|
+
{ name: 'fr-day-month', rule: frDayMonth },
|
|
2344
|
+
{ name: 'fr-month-first', rule: frMonthFirst },
|
|
2345
|
+
{ name: 'fr-numdate', rule: frNumdate },
|
|
2346
|
+
{ name: 'fr-weekday-day', rule: frWeekdayDay },
|
|
2347
|
+
{ name: 'fr-holiday', rule: frHoliday },
|
|
2348
|
+
{ name: 'fr-decade', rule: frDecade },
|
|
2349
|
+
{ name: 'fr-bare-day', rule: frBareDay },
|
|
2350
|
+
{ name: 'fr-bare-unit', rule: frBareUnit },
|
|
2351
|
+
{ name: 'fr-bare-period', rule: frBarePeriod },
|
|
2352
|
+
];
|
|
2353
|
+
export const FR_RULE_ENTRIES = makeLatinRules(FR_LEXICON, frExtras);
|
|
2354
|
+
/** Connector words allowed between merged date & time parts. */
|
|
2355
|
+
export const FR_CONNECTORS = [
|
|
2356
|
+
'à', 'a', 'au', 'aux', 'le', 'la', 'les', 'de', 'du', 'des', 'en', 'vers',
|
|
2357
|
+
'dans', 'ce', 'cet', 'cette', 'pour', '-',
|
|
2358
|
+
];
|