nl2time 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,692 @@
1
+ const NOW = { op: 'now' };
2
+ function snapNow(unit) {
3
+ return { op: 'snap', base: NOW, unit };
4
+ }
5
+ function snapOffset(amount, unit) {
6
+ return { op: 'snap', base: { op: 'offset', base: NOW, amount, unit }, unit };
7
+ }
8
+ function amountFor(unit, n) {
9
+ return unit === 'quarter' ? { months: 3 * n } : { [`${unit}s`]: n };
10
+ }
11
+ function word(t) {
12
+ return t?.type === 'word' ? t.value : undefined;
13
+ }
14
+ export function makeLatinRules(lex, extras = []) {
15
+ const isArticle = (w) => w !== undefined && lex.articles.includes(w);
16
+ /** Skip leading articles; returns first non-article index. */
17
+ const skipArticles = (tokens, i) => {
18
+ let at = i;
19
+ while (isArticle(word(tokens[at])))
20
+ at += 1;
21
+ return at;
22
+ };
23
+ const matchPhrase = (tokens, i, words) => {
24
+ for (const [k, w] of words.entries()) {
25
+ if (word(tokens[i + k]) !== w)
26
+ return undefined;
27
+ }
28
+ return i + words.length;
29
+ };
30
+ const readNum = (tokens, i) => {
31
+ const t = tokens[i];
32
+ if (t?.type === 'number')
33
+ return { value: t.value, consumed: 1 };
34
+ const w = word(t);
35
+ if (w !== undefined && lex.smallNumbers[w] !== undefined) {
36
+ const base = lex.smallNumbers[w];
37
+ // "dos mil dieciocho" → 2018
38
+ if ((lex.thousandWords ?? []).includes(word(tokens[i + 1]) ?? '')) {
39
+ let value = base * 1000;
40
+ let consumed = 2;
41
+ const rest = readNum(tokens, i + 2);
42
+ if (rest && rest.value < 1000) {
43
+ value += rest.value;
44
+ consumed += rest.consumed;
45
+ }
46
+ return { value, consumed };
47
+ }
48
+ return { value: base, consumed: 1 };
49
+ }
50
+ return undefined;
51
+ };
52
+ const yearAt = (tokens, i) => {
53
+ const t = tokens[i];
54
+ return t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 9999 ? t.value : undefined;
55
+ };
56
+ const subDay = (unit) => unit === 'second' || unit === 'minute' || unit === 'hour';
57
+ const relExpr = (delta, unit) => {
58
+ if (subDay(unit)) {
59
+ return delta === 0
60
+ ? snapNow(unit)
61
+ : { op: 'span', anchor: NOW, amount: amountFor(unit, delta) };
62
+ }
63
+ return delta === 0 ? snapNow(unit) : snapOffset(delta, unit);
64
+ };
65
+ const specials = (tokens, i) => {
66
+ for (const s of lex.specialPhrases ?? []) {
67
+ const end = matchPhrase(tokens, i, s.words);
68
+ if (end !== undefined) {
69
+ return { expr: s.expr, consumed: end - i, confidence: 1, role: s.role ?? 'datetime' };
70
+ }
71
+ }
72
+ return undefined;
73
+ };
74
+ const deictic = (tokens, i) => {
75
+ for (const p of lex.deicticPhrases ?? []) {
76
+ const end = matchPhrase(tokens, i, p.words);
77
+ if (end !== undefined) {
78
+ return { expr: snapOffset(p.delta, 'day'), consumed: end - i, confidence: 1, role: 'date' };
79
+ }
80
+ }
81
+ const w = word(tokens[i]);
82
+ if (w !== undefined && lex.deictic[w] !== undefined) {
83
+ const delta = lex.deictic[w];
84
+ return {
85
+ expr: delta === 0 ? snapNow('day') : snapOffset(delta, 'day'),
86
+ consumed: 1,
87
+ confidence: 1,
88
+ role: 'date',
89
+ };
90
+ }
91
+ return undefined;
92
+ };
93
+ /** [article]* (preMod noun | noun postMod) for unit / weekday / period / weekend nouns. */
94
+ const relNoun = (tokens, i) => {
95
+ const at = skipArticles(tokens, i);
96
+ if (at !== i && at === i + 2)
97
+ return undefined; // at most one article
98
+ const w0 = word(tokens[at]);
99
+ if (w0 === undefined)
100
+ return undefined;
101
+ const readPost = (nounEnd) => {
102
+ const pw = word(tokens[nounEnd]);
103
+ if (pw !== undefined && lex.postMods[pw] !== undefined) {
104
+ return { delta: lex.postMods[pw], end: nounEnd + 1 };
105
+ }
106
+ for (const p of lex.postModPhrases ?? []) {
107
+ const end = matchPhrase(tokens, nounEnd, p.words);
108
+ if (end !== undefined)
109
+ return { delta: p.delta, end };
110
+ }
111
+ return undefined;
112
+ };
113
+ const buildFor = (delta, nounWord, start, end) => {
114
+ const unit = lex.units[nounWord];
115
+ if (unit !== undefined) {
116
+ return { expr: relExpr(delta, unit), consumed: end - i, confidence: 1, role: 'date' };
117
+ }
118
+ const weekday = lex.weekdays[nounWord];
119
+ if (weekday !== undefined) {
120
+ const dir = delta === -1 ? 'prev' : delta === 1 ? 'next' : 'nearest';
121
+ return {
122
+ expr: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday } },
123
+ consumed: end - i,
124
+ confidence: 1,
125
+ role: 'date',
126
+ };
127
+ }
128
+ const period = lex.periods[nounWord];
129
+ if (period !== undefined && lex.deictic[nounWord] === undefined) {
130
+ const dayExpr = delta === 0 ? snapNow('day') : snapOffset(delta, 'day');
131
+ return {
132
+ expr: { op: 'intersect', parts: [dayExpr, { op: 'literal', dayPeriod: period }] },
133
+ consumed: end - i,
134
+ confidence: 1,
135
+ role: 'datetime',
136
+ };
137
+ }
138
+ for (const wp of lex.weekendPhrases) {
139
+ if (matchPhrase(tokens, start, wp) === end || matchPhrase(tokens, start, wp) !== undefined) {
140
+ const dir = delta === -1 ? 'prev' : delta === 1 ? 'next' : 'nearest';
141
+ return {
142
+ expr: {
143
+ op: 'span',
144
+ anchor: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: 'sat' } },
145
+ amount: { days: 2 },
146
+ },
147
+ consumed: (matchPhrase(tokens, start, wp) ?? end) - i,
148
+ confidence: 1,
149
+ role: 'date',
150
+ };
151
+ }
152
+ }
153
+ return undefined;
154
+ };
155
+ // preMod + noun (incl. weekend phrase)
156
+ if (lex.preMods[w0] !== undefined) {
157
+ const delta = lex.preMods[w0];
158
+ const nounAt = skipArticles(tokens, at + 1);
159
+ const nw = word(tokens[nounAt]);
160
+ if (nw !== undefined) {
161
+ for (const wp of lex.weekendPhrases) {
162
+ const end = matchPhrase(tokens, nounAt, wp);
163
+ if (end !== undefined) {
164
+ const dir = delta === -1 ? 'prev' : delta === 1 ? 'next' : 'nearest';
165
+ return {
166
+ expr: {
167
+ op: 'span',
168
+ anchor: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: 'sat' } },
169
+ amount: { days: 2 },
170
+ },
171
+ consumed: end - i,
172
+ confidence: 1,
173
+ role: 'date',
174
+ };
175
+ }
176
+ }
177
+ const built = buildFor(delta, nw, nounAt, nounAt + 1);
178
+ if (built)
179
+ return built;
180
+ }
181
+ return undefined;
182
+ }
183
+ // noun + postMod
184
+ const post = readPost(at + 1);
185
+ if (post) {
186
+ const built = buildFor(post.delta, w0, at, post.end);
187
+ if (built)
188
+ return built;
189
+ }
190
+ // weekend phrase (bare or postmodified)
191
+ for (const wp of lex.weekendPhrases) {
192
+ const end = matchPhrase(tokens, at, wp);
193
+ if (end !== undefined) {
194
+ const post2 = readPost(end);
195
+ const delta = post2 ? post2.delta : 0;
196
+ const dir = delta === -1 ? 'prev' : delta === 1 ? 'next' : 'nearest';
197
+ return {
198
+ expr: {
199
+ op: 'span',
200
+ anchor: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: 'sat' } },
201
+ amount: { days: 2 },
202
+ },
203
+ consumed: (post2 ? post2.end : end) - i,
204
+ confidence: 0.9,
205
+ role: 'date',
206
+ };
207
+ }
208
+ }
209
+ return undefined;
210
+ };
211
+ /** Bare weekday (with optional article): "el martes" → nearest dual. */
212
+ const bareWeekday = (tokens, i) => {
213
+ const at = skipArticles(tokens, i);
214
+ const w = word(tokens[at]);
215
+ const weekday = w !== undefined ? lex.weekdays[w] : undefined;
216
+ if (!weekday)
217
+ return undefined;
218
+ // A following postmod belongs to relNoun.
219
+ const pw = word(tokens[at + 1]);
220
+ if (pw !== undefined && lex.postMods[pw] !== undefined)
221
+ return undefined;
222
+ return {
223
+ expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday } },
224
+ consumed: at + 1 - i,
225
+ confidence: 0.9,
226
+ role: 'date',
227
+ };
228
+ };
229
+ /** "hace 3 días" / "il y a 3 jours" / "vor 3 Tagen" → past offset. */
230
+ const agoRule = (tokens, i) => {
231
+ for (const prefix of lex.agoPrefixes) {
232
+ const numAt = matchPhrase(tokens, i, prefix);
233
+ if (numAt === undefined)
234
+ continue;
235
+ const n = readNum(tokens, numAt);
236
+ if (!n)
237
+ continue;
238
+ const unit = lex.units[word(tokens[numAt + n.consumed]) ?? ''];
239
+ if (!unit)
240
+ continue;
241
+ const shifted = { op: 'offset', base: NOW, amount: -n.value, unit };
242
+ return {
243
+ expr: subDay(unit) ? shifted : { op: 'snap', base: shifted, unit: 'day' },
244
+ consumed: numAt + n.consumed + 1 - i,
245
+ confidence: 1,
246
+ role: 'datetime',
247
+ };
248
+ }
249
+ return undefined;
250
+ };
251
+ /** "en 3 días" / "dans 3 jours" / "in 3 Tagen" → future offset. */
252
+ const inRule = (tokens, i) => {
253
+ for (const prefix of lex.inPrefixes) {
254
+ const numAt = matchPhrase(tokens, i, prefix);
255
+ if (numAt === undefined)
256
+ continue;
257
+ const n = readNum(tokens, numAt);
258
+ if (!n)
259
+ continue;
260
+ const unit = lex.units[word(tokens[numAt + n.consumed]) ?? ''];
261
+ if (!unit)
262
+ continue;
263
+ const shifted = { op: 'offset', base: NOW, amount: n.value, unit };
264
+ return {
265
+ expr: subDay(unit) ? shifted : { op: 'snap', base: shifted, unit: 'day' },
266
+ consumed: numAt + n.consumed + 1 - i,
267
+ confidence: 1,
268
+ role: 'datetime',
269
+ };
270
+ }
271
+ return undefined;
272
+ };
273
+ /** "[los] últimos 3 días" / "les 3 derniers jours" → span. */
274
+ const lastNextN = (tokens, i) => {
275
+ const at = skipArticles(tokens, i);
276
+ const tryOrder = (adjFirst) => {
277
+ let cursor = at;
278
+ let sign;
279
+ const readAdj = () => {
280
+ const w = word(tokens[cursor]);
281
+ if (w !== undefined && lex.lastNAdjs.includes(w)) {
282
+ sign = -1;
283
+ cursor += 1;
284
+ return true;
285
+ }
286
+ if (w !== undefined && lex.nextNAdjs.includes(w)) {
287
+ sign = 1;
288
+ cursor += 1;
289
+ return true;
290
+ }
291
+ return false;
292
+ };
293
+ if (adjFirst && !readAdj())
294
+ return undefined;
295
+ const n = readNum(tokens, cursor);
296
+ if (!n)
297
+ return undefined;
298
+ cursor += n.consumed;
299
+ if (!adjFirst && !readAdj())
300
+ return undefined;
301
+ const unit = lex.units[word(tokens[cursor]) ?? ''];
302
+ if (!unit || sign === undefined)
303
+ return undefined;
304
+ return {
305
+ expr: { op: 'span', anchor: NOW, amount: amountFor(unit, sign * n.value) },
306
+ consumed: cursor + 1 - i,
307
+ confidence: 1,
308
+ role: 'date',
309
+ };
310
+ };
311
+ return tryOrder(true) ?? tryOrder(false);
312
+ };
313
+ /** "15 de marzo [de 2026]" / "15. März [2026]" / "marzo de 2026". */
314
+ const monthDay = (tokens, i) => {
315
+ const at = skipArticles(tokens, i);
316
+ const sep = (k) => {
317
+ let c = k;
318
+ while (lex.dateSep.includes(word(tokens[c]) ?? '') || word(tokens[c]) === '-')
319
+ c += 1;
320
+ return c;
321
+ };
322
+ // Numeric dates: "27/11", "27/11/2019" (numdate tokens; DMY default).
323
+ const nd = tokens[at];
324
+ if (nd?.type === 'numdate') {
325
+ const [a, b, c] = nd.parts;
326
+ const order = lex.dateOrder ?? 'DMY';
327
+ let day = order === 'DMY' ? a : b;
328
+ let month = order === 'DMY' ? b : a;
329
+ if (month > 12 && day <= 12)
330
+ [day, month] = [month, day];
331
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
332
+ const date = { month, day };
333
+ if (c !== undefined)
334
+ date.year = c < 100 ? 2000 + c : c;
335
+ return { expr: { op: 'literal', date }, consumed: at + 1 - i, confidence: 0.9, role: 'date' };
336
+ }
337
+ }
338
+ const ow = word(tokens[at]);
339
+ const ordDay = ow !== undefined ? (lex.dayOrdinals ?? {})[ow] : undefined;
340
+ if (ordDay !== undefined) {
341
+ const mAt = sep(at + 1);
342
+ const month = lex.months[word(tokens[mAt]) ?? ''];
343
+ if (month !== undefined) {
344
+ const yAt = sep(mAt + 1);
345
+ const year = yearAt(tokens, yAt);
346
+ const date = { month, day: ordDay };
347
+ if (year !== undefined)
348
+ date.year = year;
349
+ return {
350
+ expr: { op: 'literal', date },
351
+ consumed: (year !== undefined ? yAt + 1 : mAt + 1) - i,
352
+ confidence: 1,
353
+ role: 'date',
354
+ };
355
+ }
356
+ }
357
+ const dayN = readNum(tokens, at);
358
+ if (dayN && dayN.value >= 1 && dayN.value <= 31 && tokens[at]?.type !== 'word' || (dayN && word(tokens[at]) !== undefined && lex.smallNumbers[word(tokens[at])] !== undefined)) {
359
+ const mAt = sep(at + (dayN?.consumed ?? 0));
360
+ const month = lex.months[word(tokens[mAt]) ?? ''];
361
+ if (month !== undefined && dayN && dayN.value >= 1 && dayN.value <= 31) {
362
+ const yAt = sep(mAt + 1);
363
+ const year = yearAt(tokens, yAt) ?? (() => {
364
+ const yn = readNum(tokens, yAt);
365
+ return yn && yn.value >= 1000 && yn.value <= 2199 ? yn.value : undefined;
366
+ })();
367
+ const yn = readNum(tokens, yAt);
368
+ const yearConsumed = yearAt(tokens, yAt) !== undefined ? 1 : yn && yn.value >= 1000 ? yn.consumed : 0;
369
+ const date = { month, day: dayN.value };
370
+ if (year !== undefined)
371
+ date.year = year;
372
+ return {
373
+ expr: { op: 'literal', date },
374
+ consumed: (year !== undefined ? yAt + yearConsumed : mAt + 1) - i,
375
+ confidence: 1,
376
+ role: 'date',
377
+ };
378
+ }
379
+ }
380
+ const month0 = lex.months[word(tokens[at]) ?? ''];
381
+ if (month0 !== undefined) {
382
+ const yAt = sep(at + 1);
383
+ const year = yearAt(tokens, yAt);
384
+ if (year !== undefined) {
385
+ return {
386
+ expr: { op: 'literal', date: { month: month0, year } },
387
+ consumed: yAt + 1 - i,
388
+ confidence: 1,
389
+ role: 'date',
390
+ };
391
+ }
392
+ return {
393
+ expr: { op: 'literal', date: { month: month0 } },
394
+ consumed: at + 1 - i,
395
+ confidence: 0.8,
396
+ role: 'date',
397
+ };
398
+ }
399
+ return undefined;
400
+ };
401
+ /** "por la mañana" / "le matin" / "am Abend" → period literal (merges with dates). */
402
+ const periodMarker = (tokens, i) => {
403
+ for (const marker of lex.periodMarkers) {
404
+ const pAt = matchPhrase(tokens, i, marker);
405
+ if (pAt === undefined)
406
+ continue;
407
+ const w = word(tokens[pAt]);
408
+ const period = w !== undefined ? lex.periods[w] : undefined;
409
+ if (period !== undefined) {
410
+ return {
411
+ expr: { op: 'literal', dayPeriod: period },
412
+ consumed: pAt + 1 - i,
413
+ confidence: 0.9,
414
+ role: 'time',
415
+ };
416
+ }
417
+ }
418
+ // Bare period noun (only when unambiguous — not a deictic word).
419
+ const at = skipArticles(tokens, i);
420
+ const w = word(tokens[at]);
421
+ const period = w !== undefined ? lex.periods[w] : undefined;
422
+ if (period !== undefined && lex.deictic[w] === undefined && at > i) {
423
+ return { expr: { op: 'literal', dayPeriod: period }, consumed: at + 1 - i, confidence: 0.6, role: 'time' };
424
+ }
425
+ return undefined;
426
+ };
427
+ /** "a las 3" / "à 15" / "um 15" (+ noon/midnight words). */
428
+ const clockLead = (tokens, i) => {
429
+ const w = word(tokens[i]);
430
+ if (w !== undefined && (lex.noonWords ?? []).includes(w)) {
431
+ return { expr: { op: 'literal', time: { hour: 12, meridiem: 'pm' } }, consumed: 1, confidence: 1, role: 'time' };
432
+ }
433
+ if (w !== undefined && (lex.midnightWords ?? []).includes(w)) {
434
+ return { expr: { op: 'literal', time: { hour: 12, meridiem: 'am' } }, consumed: 1, confidence: 1, role: 'time' };
435
+ }
436
+ for (const at of lex.atPhrases) {
437
+ const hAt = matchPhrase(tokens, i, at);
438
+ if (hAt === undefined)
439
+ continue;
440
+ const t = tokens[hAt];
441
+ if (t?.type === 'clock') {
442
+ const time = { hour: t.hour };
443
+ if (t.explicitMinute)
444
+ time.minute = t.minute;
445
+ if (t.second !== undefined)
446
+ time.second = t.second;
447
+ if (t.meridiem)
448
+ time.meridiem = t.meridiem;
449
+ else if (t.hour <= 12)
450
+ time.meridiem = 'unknown';
451
+ let consumed2 = hAt + 1 - i;
452
+ for (const marker of lex.periodMarkers) {
453
+ const pAt = matchPhrase(tokens, i + consumed2, marker);
454
+ if (pAt === undefined)
455
+ continue;
456
+ const pw = word(tokens[pAt]);
457
+ const period = pw !== undefined ? lex.periods[pw] : undefined;
458
+ if (period !== undefined && (time.meridiem === undefined || time.meridiem === 'unknown')) {
459
+ time.meridiem = period === 'morning' ? 'am' : 'pm';
460
+ consumed2 = pAt + 1 - i;
461
+ break;
462
+ }
463
+ }
464
+ return { expr: { op: 'literal', time }, consumed: consumed2, confidence: 1, role: 'time' };
465
+ }
466
+ const hn = t?.type === 'number' && !t.ordinal ? { value: t.value, consumed: 1 } : readNum(tokens, hAt);
467
+ if (hn && hn.value >= 0 && hn.value <= 24) {
468
+ const hour = hn.value === 24 ? 0 : hn.value;
469
+ const time = { hour };
470
+ if (hn.value !== 24 && hour <= 12)
471
+ time.meridiem = 'unknown';
472
+ let consumed = hAt + hn.consumed - i;
473
+ // "a las siete de la tarde" — a trailing period marker fixes the meridiem.
474
+ for (const marker of lex.periodMarkers) {
475
+ const pAt = matchPhrase(tokens, i + consumed, marker);
476
+ if (pAt === undefined)
477
+ continue;
478
+ const pw = word(tokens[pAt]);
479
+ const period = pw !== undefined ? lex.periods[pw] : undefined;
480
+ if (period !== undefined) {
481
+ time.meridiem = period === 'morning' ? 'am' : 'pm';
482
+ consumed = pAt + 1 - i;
483
+ break;
484
+ }
485
+ }
486
+ return { expr: { op: 'literal', time }, consumed, confidence: 0.95, role: 'time' };
487
+ }
488
+ }
489
+ return undefined;
490
+ };
491
+ /** Article skip that leaves number-words alone ("la una" keeps una=1). */
492
+ const skipArticlesSafe = (tokens, i) => {
493
+ let at = i;
494
+ while (isArticle(word(tokens[at])) && lex.smallNumbers[word(tokens[at])] === undefined)
495
+ at += 1;
496
+ return at;
497
+ };
498
+ /** "desde la una hasta las tres" / "entre 2008 y 2011" / "entre el 1 de
499
+ * diciembre y el 4 de febrero" / "entre 3 y 12 de septiembre". */
500
+ const rangeRule = (tokens, i) => {
501
+ const froms = lex.rangeFrom ?? [];
502
+ const readOperand = (k0) => {
503
+ const k = skipArticlesSafe(tokens, k0);
504
+ const md = monthDay(tokens, k, undefined);
505
+ if (md)
506
+ return { kind: 'date', expr: md.expr, consumed: k + md.consumed - k0 };
507
+ const y = yearAt(tokens, k);
508
+ if (y !== undefined)
509
+ return { kind: 'year', value: y, consumed: k + 1 - k0 };
510
+ const n = readNum(tokens, k);
511
+ if (n && n.value >= 1000 && n.value <= 2199)
512
+ return { kind: 'year', value: n.value, consumed: k + n.consumed - k0 };
513
+ if (n && n.value >= 0 && n.value <= 24)
514
+ return { kind: 'time', value: n.value, consumed: k + n.consumed - k0 };
515
+ return undefined;
516
+ };
517
+ for (const fromMarker of froms) {
518
+ const aAt0 = matchPhrase(tokens, i, fromMarker);
519
+ if (aAt0 === undefined)
520
+ continue;
521
+ const a = readOperand(aAt0);
522
+ if (!a)
523
+ continue;
524
+ const cAt = aAt0 + a.consumed;
525
+ let matchedConn;
526
+ for (const toMarker of lex.rangeTo ?? []) {
527
+ const end = matchPhrase(tokens, cAt, toMarker);
528
+ if (end !== undefined) {
529
+ matchedConn = end;
530
+ break;
531
+ }
532
+ }
533
+ if (matchedConn === undefined && (lex.rangeAnd ?? []).includes(word(tokens[cAt]) ?? '')) {
534
+ matchedConn = cAt + 1;
535
+ }
536
+ if (matchedConn === undefined)
537
+ continue;
538
+ const b = readOperand(matchedConn);
539
+ if (!b)
540
+ continue;
541
+ const mkTime = (v) => ({
542
+ op: 'literal',
543
+ time: { hour: v === 24 ? 0 : v, ...(v <= 12 && v !== 24 ? { meridiem: 'unknown' } : {}) },
544
+ });
545
+ let expr;
546
+ if (a.kind === 'date' && b.kind === 'date') {
547
+ expr = { op: 'between', start: a.expr, end: b.expr };
548
+ }
549
+ else if (a.kind === 'year' && b.kind === 'year') {
550
+ expr = {
551
+ op: 'between',
552
+ start: { op: 'literal', date: { year: a.value } },
553
+ end: { op: 'literal', date: { year: b.value } },
554
+ };
555
+ }
556
+ else if ((a.kind === 'time' || a.kind === 'daynum') && b.kind === 'date') {
557
+ // "entre 3 y 12 de septiembre": A is a bare day sharing B's month.
558
+ if (b.expr.op === 'literal' && b.expr.date?.day !== undefined && b.expr.date.month !== undefined) {
559
+ const month = b.expr.date.month;
560
+ expr = {
561
+ op: 'intersect',
562
+ parts: [
563
+ { op: 'literal', date: { month, ...(b.expr.date.year !== undefined ? { year: b.expr.date.year } : {}) } },
564
+ {
565
+ op: 'between',
566
+ start: { op: 'literal', date: { day: a.value } },
567
+ end: { op: 'literal', date: { day: b.expr.date.day } },
568
+ },
569
+ ],
570
+ };
571
+ }
572
+ }
573
+ else if (a.kind === 'time' && b.kind === 'time') {
574
+ expr = { op: 'between', start: mkTime(a.value), end: mkTime(b.value) };
575
+ }
576
+ if (!expr)
577
+ continue;
578
+ return {
579
+ expr,
580
+ consumed: matchedConn + b.consumed - i,
581
+ confidence: 0.95,
582
+ role: a.kind === 'time' && b.kind === 'time' ? 'time' : 'date',
583
+ };
584
+ }
585
+ return undefined;
586
+ };
587
+ /** "durará 30 minutos" / "pendant 2 heures" / "für 3 Stunden" → amount. */
588
+ const durationRule = (tokens, i) => {
589
+ if (!(lex.durationTriggers ?? []).includes(word(tokens[i]) ?? ''))
590
+ return undefined;
591
+ const n = readNum(tokens, i + 1);
592
+ if (!n)
593
+ return undefined;
594
+ const unit = lex.units[word(tokens[i + 1 + n.consumed]) ?? ''];
595
+ if (!unit)
596
+ return undefined;
597
+ return {
598
+ expr: { op: 'amount', amount: amountFor(unit, n.value) },
599
+ consumed: n.consumed + 2,
600
+ confidence: 1,
601
+ role: 'duration',
602
+ };
603
+ };
604
+ /** "al final del domingo" / "à la fin de demain" → end-of-day point. */
605
+ const endOfRule = (tokens, i, ctx) => {
606
+ for (const marker of lex.endOfMarkers ?? []) {
607
+ const at = matchPhrase(tokens, i, marker);
608
+ if (at === undefined)
609
+ continue;
610
+ const inner = deictic(tokens, at, ctx) ?? bareWeekday(tokens, at, ctx);
611
+ if (!inner)
612
+ continue;
613
+ return {
614
+ expr: { op: 'snap', base: inner.expr, unit: 'day', edge: 'end' },
615
+ consumed: at + inner.consumed - i,
616
+ confidence: 0.95,
617
+ role: 'date',
618
+ };
619
+ }
620
+ return undefined;
621
+ };
622
+ /** "la última semana de julio" / "la primera semana de julio". */
623
+ const scopedWeek = (tokens, i) => {
624
+ const at = skipArticles(tokens, i);
625
+ const adj = word(tokens[at]);
626
+ if (adj === undefined)
627
+ return undefined;
628
+ const isLast = (lex.lastAdjs ?? []).includes(adj);
629
+ const isFirst = (lex.firstAdjs ?? []).includes(adj);
630
+ if (!isLast && !isFirst)
631
+ return undefined;
632
+ const unit = lex.units[word(tokens[at + 1]) ?? ''];
633
+ if (unit !== 'week')
634
+ return undefined;
635
+ let mAt = at + 2;
636
+ while (lex.dateSep.includes(word(tokens[mAt]) ?? ''))
637
+ mAt += 1;
638
+ const month = lex.months[word(tokens[mAt]) ?? ''];
639
+ if (month === undefined)
640
+ return undefined;
641
+ const yAt = (() => {
642
+ let c = mAt + 1;
643
+ while (lex.dateSep.includes(word(tokens[c]) ?? ''))
644
+ c += 1;
645
+ return c;
646
+ })();
647
+ const year = yearAt(tokens, yAt);
648
+ const scope = {
649
+ op: 'literal',
650
+ date: { month, ...(year !== undefined ? { year } : {}) },
651
+ };
652
+ const expr = isLast
653
+ ? {
654
+ op: 'snap',
655
+ base: {
656
+ op: 'offset',
657
+ base: { op: 'snap', base: scope, unit: 'month', edge: 'end' },
658
+ amount: -4,
659
+ unit: 'day',
660
+ },
661
+ unit: 'week',
662
+ }
663
+ : {
664
+ op: 'snap',
665
+ base: { op: 'snap', base: scope, unit: 'month', edge: 'start' },
666
+ unit: 'week',
667
+ };
668
+ return {
669
+ expr,
670
+ consumed: (year !== undefined ? yAt + 1 : mAt + 1) - i,
671
+ confidence: 1,
672
+ role: 'date',
673
+ };
674
+ };
675
+ return [
676
+ ...extras,
677
+ { name: 'scoped-week', rule: scopedWeek },
678
+ { name: 'end-of', rule: endOfRule },
679
+ { name: 'range', rule: rangeRule },
680
+ { name: 'duration', rule: durationRule },
681
+ { name: 'special', rule: specials },
682
+ { name: 'deictic-day', rule: deictic },
683
+ { name: 'ago', rule: agoRule },
684
+ { name: 'in-n', rule: inRule },
685
+ { name: 'last-next-n', rule: lastNextN },
686
+ { name: 'rel-noun', rule: relNoun },
687
+ { name: 'month-day', rule: monthDay },
688
+ { name: 'clock-lead', rule: clockLead },
689
+ { name: 'period-marker', rule: periodMarker },
690
+ { name: 'bare-weekday', rule: bareWeekday },
691
+ ];
692
+ }