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,1360 @@
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 word(t) {
9
+ return t?.type === 'word' ? t.value : undefined;
10
+ }
11
+ /** Match consecutive single-char word tokens spelling `s`; returns end index. */
12
+ function seq(tokens, i, s) {
13
+ let at = i;
14
+ for (const c of s) {
15
+ if (word(tokens[at]) !== c)
16
+ return undefined;
17
+ at += 1;
18
+ }
19
+ return at;
20
+ }
21
+ /** First phrase from `list` matching at i (list should be longest-first). */
22
+ function seqAny(tokens, i, list) {
23
+ for (const s of list) {
24
+ const end = seq(tokens, i, s);
25
+ if (end !== undefined)
26
+ return { end, text: s };
27
+ }
28
+ return undefined;
29
+ }
30
+ // --- numbers ---------------------------------------------------------------
31
+ const HAN_DIGIT = {
32
+ 零: 0, 〇: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 两: 2, 兩: 2,
33
+ };
34
+ /** Hanzi numeral: 十, 十五, 三十, 二十三, or positional digit runs 二零零四. */
35
+ function readHanNum(tokens, i) {
36
+ const w0 = word(tokens[i]);
37
+ if (w0 === '十') {
38
+ const d1 = HAN_DIGIT[word(tokens[i + 1]) ?? ''];
39
+ if (d1 !== undefined && d1 >= 1 && d1 <= 9)
40
+ return { value: 10 + d1, consumed: 2, hanzi: true };
41
+ return { value: 10, consumed: 1, hanzi: true };
42
+ }
43
+ const d0 = w0 !== undefined ? HAN_DIGIT[w0] : undefined;
44
+ if (d0 === undefined)
45
+ return undefined;
46
+ if (word(tokens[i + 1]) === '十') {
47
+ const d2 = HAN_DIGIT[word(tokens[i + 2]) ?? ''];
48
+ if (d2 !== undefined && d2 >= 1 && d2 <= 9)
49
+ return { value: d0 * 10 + d2, consumed: 3, hanzi: true };
50
+ return { value: d0 * 10, consumed: 2, hanzi: true };
51
+ }
52
+ // Positional digit run (year style): 二零零四 → 2004, 零八 → 8 (2 digits).
53
+ const digits = [d0];
54
+ let at = i + 1;
55
+ for (;;) {
56
+ const w = word(tokens[at]);
57
+ const d = w !== undefined ? HAN_DIGIT[w] : undefined;
58
+ if (d === undefined || w === '两' || w === '兩')
59
+ break;
60
+ digits.push(d);
61
+ at += 1;
62
+ }
63
+ if (digits.length >= 2) {
64
+ return {
65
+ value: digits.reduce((a, b) => a * 10 + b, 0),
66
+ consumed: digits.length,
67
+ hanzi: true,
68
+ digitCount: digits.length,
69
+ };
70
+ }
71
+ return { value: d0, consumed: 1, hanzi: true };
72
+ }
73
+ /** ASCII number token or hanzi numeral. */
74
+ function readNum(tokens, i) {
75
+ const t = tokens[i];
76
+ if (t?.type === 'number' && !t.ordinal) {
77
+ return { value: t.value, consumed: 1, hanzi: false, digitCount: t.end - t.start };
78
+ }
79
+ // A range dash glued to the following number ("6月1日-6月30日" → word "-6")
80
+ // reads as the plain number; the dash is the (absorbed) range connector.
81
+ const w = word(t);
82
+ const dashed = w?.match(/^[-~](\d{1,4})$/);
83
+ if (dashed)
84
+ return { value: Number(dashed[1]), consumed: 1, hanzi: false, digitCount: dashed[1].length };
85
+ return readHanNum(tokens, i);
86
+ }
87
+ function expandYear2(v) {
88
+ return v >= 70 ? 1900 + v : 2000 + v;
89
+ }
90
+ // --- calendar date reading -------------------------------------------------
91
+ /** Regnal eras seen in the corpus: era + N年 → gregorian year (era start + N − 1). */
92
+ const ERAS = [
93
+ ['神龙', 705], ['神龍', 705],
94
+ ['康熙', 1662],
95
+ ['雍正', 1723],
96
+ ['民国', 1912], ['民國', 1912],
97
+ ];
98
+ const YEAR_WORDS = ['年'];
99
+ const DAY_SUFFIXES = ['日', '号', '號'];
100
+ /** Year (incl. era years and positional hanzi) ending with 年; consumed includes 年. */
101
+ function readYearMarked(tokens, i) {
102
+ for (const [era, start] of ERAS) {
103
+ const afterEra = seq(tokens, i, era);
104
+ if (afterEra === undefined)
105
+ continue;
106
+ let n;
107
+ if (word(tokens[afterEra]) === '元')
108
+ n = { value: 1, consumed: 1, hanzi: true };
109
+ else
110
+ n = readNum(tokens, afterEra);
111
+ if (!n || n.value < 1 || n.value > 99)
112
+ continue;
113
+ const end = seqAny(tokens, afterEra + n.consumed, YEAR_WORDS);
114
+ if (!end)
115
+ continue;
116
+ return { year: start + n.value - 1, consumed: end.end - i };
117
+ }
118
+ const n = readNum(tokens, i);
119
+ if (!n)
120
+ return undefined;
121
+ const end = seqAny(tokens, i + n.consumed, YEAR_WORDS);
122
+ if (!end)
123
+ return undefined;
124
+ // Years need at least two written digits: 2016年, 18年, 零八年, 二零零四年.
125
+ // Plain counting numerals (一年, 二十年) are durations, not years.
126
+ if (n.digitCount === undefined || n.digitCount < 2)
127
+ return undefined;
128
+ if (n.digitCount <= 2 && n.value < 100)
129
+ return { year: expandYear2(n.value), consumed: end.end - i };
130
+ if (n.value >= 1000 && n.value <= 9999)
131
+ return { year: n.value, consumed: end.end - i };
132
+ return undefined;
133
+ }
134
+ /** Month number (1-12, 正 = 1) followed by 月. */
135
+ function readMonthMarked(tokens, i) {
136
+ if (word(tokens[i]) === '正' && word(tokens[i + 1]) === '月')
137
+ return { month: 1, consumed: 2 };
138
+ const n = readNum(tokens, i);
139
+ if (!n || n.value < 1 || n.value > 12)
140
+ return undefined;
141
+ if (word(tokens[i + n.consumed]) !== '月')
142
+ return undefined;
143
+ return { month: n.value, consumed: n.consumed + 1 };
144
+ }
145
+ /**
146
+ * Day of month. Suffixed (10日 / 19号) always; hanzi numerals may be bare
147
+ * (八月十五, 正月三十) or 初-prefixed lunar style (初一).
148
+ */
149
+ function readDay(tokens, i, requireSuffix) {
150
+ let at = i;
151
+ let chu = false;
152
+ if (word(tokens[at]) === '初') {
153
+ chu = true;
154
+ at += 1;
155
+ }
156
+ const n = readNum(tokens, at);
157
+ if (!n || n.value < 1 || n.value > 31)
158
+ return undefined;
159
+ const suffix = seqAny(tokens, at + n.consumed, DAY_SUFFIXES);
160
+ if (suffix)
161
+ return { day: n.value, consumed: suffix.end - i };
162
+ if (chu)
163
+ return { day: n.value, consumed: at + n.consumed - i };
164
+ if (!requireSuffix && n.hanzi)
165
+ return { day: n.value, consumed: at + n.consumed - i };
166
+ return undefined;
167
+ }
168
+ /** [农历] [year年] [M月 [D日]] | [year年] 大年三十 | D日-alone. */
169
+ function readDateParts(tokens, i) {
170
+ let at = i;
171
+ const date = {};
172
+ const lunar = seqAny(tokens, at, ['农历', '農曆']);
173
+ if (lunar)
174
+ at = lunar.end;
175
+ const y = readYearMarked(tokens, at);
176
+ if (y) {
177
+ date.year = y.year;
178
+ at += y.consumed;
179
+ }
180
+ const nye = seq(tokens, at, '大年三十');
181
+ if (nye !== undefined) {
182
+ // Lunar new year's eve, mapped like Recognizers to month 1 day 30.
183
+ return { date: { ...date, month: 1, day: 30 }, consumed: nye - i };
184
+ }
185
+ const m = readMonthMarked(tokens, at);
186
+ if (m) {
187
+ date.month = m.month;
188
+ at += m.consumed;
189
+ const d = readDay(tokens, at, false);
190
+ if (d) {
191
+ date.day = d.day;
192
+ at += d.consumed;
193
+ }
194
+ }
195
+ else if (date.year === undefined) {
196
+ const d = readDay(tokens, at, true);
197
+ if (!d)
198
+ return undefined;
199
+ date.day = d.day;
200
+ at += d.consumed;
201
+ }
202
+ if (at === i || (lunar && at === lunar.end))
203
+ return undefined;
204
+ return { date, consumed: at - i };
205
+ }
206
+ const TIME_PREFIXES = [
207
+ { words: '上午', mer: 'am', period: 'morning' },
208
+ { words: '早上', mer: 'am', period: 'morning' },
209
+ { words: '早晨', mer: 'am', period: 'morning' },
210
+ { words: '清晨', mer: 'am', period: 'morning' },
211
+ { words: '早间', mer: 'am', period: 'morning' },
212
+ { words: '早間', mer: 'am', period: 'morning' },
213
+ { words: '凌晨', mer: 'small-am', period: 'night' },
214
+ { words: '中午', mer: 'noon' },
215
+ { words: '正午', mer: 'noon' },
216
+ { words: '下午', mer: 'pm', period: 'afternoon' },
217
+ { words: '午后', mer: 'pm', period: 'afternoon' },
218
+ { words: '午後', mer: 'pm', period: 'afternoon' },
219
+ { words: '晚上', mer: 'pm', period: 'evening' },
220
+ { words: '傍晚', mer: 'pm', period: 'evening' },
221
+ { words: '晚间', mer: 'pm', period: 'evening' },
222
+ { words: '晚間', mer: 'pm', period: 'evening' },
223
+ { words: '夜里', mer: 'pm', period: 'night' },
224
+ { words: '夜裡', mer: 'pm', period: 'night' },
225
+ { words: '夜间', mer: 'pm', period: 'night' },
226
+ { words: '深夜', mer: 'pm', period: 'night' },
227
+ { words: '半夜', mer: 'midnight' },
228
+ { words: '午夜', mer: 'midnight' },
229
+ ];
230
+ function matchTimePrefix(tokens, i) {
231
+ for (const p of TIME_PREFIXES) {
232
+ const end = seq(tokens, i, p.words);
233
+ if (end !== undefined) {
234
+ const out = { end };
235
+ if (p.mer !== undefined)
236
+ out.mer = p.mer;
237
+ if (p.period !== undefined)
238
+ out.period = p.period;
239
+ return out;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+ const AMPM_WORDS = {
245
+ 'am': 'am', 'a.m': 'am', 'a.m.': 'am', 'a': 'am',
246
+ 'pm': 'pm', 'p.m': 'pm', 'p.m.': 'pm', 'p': 'pm',
247
+ };
248
+ const HOUR_MARKERS = ['点', '點', '时', '時'];
249
+ /** A clock time: ASCII clock token [+ a.m./p.m. word] or N点/N时 forms. */
250
+ function readClock(tokens, i) {
251
+ const t = tokens[i];
252
+ if (t?.type === 'clock') {
253
+ let at = i + 1;
254
+ let mer = t.meridiem;
255
+ const w = word(tokens[at]);
256
+ if (!mer && w !== undefined && AMPM_WORDS[w] !== undefined) {
257
+ mer = AMPM_WORDS[w];
258
+ at += 1;
259
+ }
260
+ const time = { hour: t.hour };
261
+ if (t.explicitMinute)
262
+ time.minute = t.minute;
263
+ if (t.second !== undefined)
264
+ time.second = t.second;
265
+ if (mer)
266
+ time.meridiem = mer;
267
+ return { time, consumed: at - i };
268
+ }
269
+ const h = readNum(tokens, i);
270
+ if (!h || h.value > 24 || !Number.isInteger(h.value))
271
+ return undefined;
272
+ let at = i + h.consumed;
273
+ if (seqAny(tokens, at, HOUR_MARKERS) === undefined)
274
+ return undefined;
275
+ at += 1;
276
+ const time = { hour: h.value };
277
+ if (word(tokens[at]) === '半') {
278
+ time.minute = 30;
279
+ at += 1;
280
+ }
281
+ else {
282
+ const q = seqAny(tokens, at, ['一刻', '三刻', '二刻']);
283
+ if (q) {
284
+ time.minute = q.text === '一刻' ? 15 : q.text === '二刻' ? 30 : 45;
285
+ at = q.end;
286
+ }
287
+ else if (word(tokens[at]) === '整') {
288
+ time.minute = 0;
289
+ at += 1;
290
+ }
291
+ else {
292
+ const m = readNum(tokens, at);
293
+ if (m && m.value <= 59 && Number.isInteger(m.value)) {
294
+ let mAt = at + m.consumed;
295
+ const isFen = word(tokens[mAt]) === '分';
296
+ if (isFen) {
297
+ mAt += 1;
298
+ time.minute = m.value;
299
+ const s = readNum(tokens, mAt);
300
+ if (s && s.value <= 59 && word(tokens[mAt + s.consumed]) === '秒') {
301
+ time.second = s.value;
302
+ mAt += s.consumed + 1;
303
+ }
304
+ at = mAt;
305
+ }
306
+ else {
307
+ // Bare trailing minutes: 九点二十 → 9:20.
308
+ time.minute = m.value;
309
+ at += m.consumed;
310
+ }
311
+ }
312
+ }
313
+ }
314
+ return { time, consumed: at - i };
315
+ }
316
+ /** Apply a meridiem prefix to an hour reading, Recognizers-style. */
317
+ function applyMeridiem(time, mer) {
318
+ const out = { ...time };
319
+ const hour = out.hour ?? 0;
320
+ if (out.meridiem !== undefined)
321
+ return out;
322
+ if (mer === undefined) {
323
+ if (hour >= 1 && hour <= 12)
324
+ out.meridiem = 'unknown';
325
+ return out;
326
+ }
327
+ if (hour >= 13)
328
+ return out; // already 24-hour
329
+ if (mer === 'am') {
330
+ if (hour >= 1 && hour <= 11)
331
+ out.meridiem = 'am';
332
+ // 上午12点 stays 12:00.
333
+ }
334
+ else if (mer === 'pm' || mer === 'noon') {
335
+ if (hour >= 1 && hour <= 12)
336
+ out.meridiem = 'pm';
337
+ }
338
+ else if (mer === 'small-am' || mer === 'midnight') {
339
+ if (hour >= 1 && hour <= 11)
340
+ out.meridiem = 'am';
341
+ }
342
+ return out;
343
+ }
344
+ /** [大约] [meridiem/period prefix] clock — the combined time reader. */
345
+ function readClockWithPrefix(tokens, i) {
346
+ let at = i;
347
+ const approx = seqAny(tokens, at, ['大约', '大約', '大概', '差不多']);
348
+ if (approx)
349
+ at = approx.end;
350
+ const pfx = matchTimePrefix(tokens, at);
351
+ const clockAt = pfx ? pfx.end : at;
352
+ const clk = readClock(tokens, clockAt);
353
+ if (!clk)
354
+ return undefined;
355
+ const time = applyMeridiem(clk.time, pfx?.mer);
356
+ const out = {
357
+ time,
358
+ consumed: clockAt + clk.consumed - i,
359
+ };
360
+ if (pfx?.period !== undefined)
361
+ out.period = pfx.period;
362
+ return out;
363
+ }
364
+ // --- deictic days / weeks / months / years ---------------------------------
365
+ const DEICTIC_DAYS = [
366
+ ['大后天', 3], ['大後天', 3], ['大前天', -3],
367
+ ['今天', 0], ['今日', 0],
368
+ ['昨天', -1], ['昨日', -1],
369
+ ['明天', 1], ['明日', 1],
370
+ ['前天', -2],
371
+ ['后天', 2], ['後天', 2],
372
+ ];
373
+ const DEICTIC_DAY_PERIODS = [
374
+ ['今晚', 0, 'evening'],
375
+ ['明晚', 1, 'evening'],
376
+ ['昨晚', -1, 'evening'],
377
+ ['今晨', 0, 'morning'],
378
+ ['今早', 0, 'morning'],
379
+ ['明早', 1, 'morning'],
380
+ ['明晨', 1, 'morning'],
381
+ ['昨晨', -1, 'morning'],
382
+ ];
383
+ function dayExprFor(delta) {
384
+ return delta === 0 ? snapNow('day') : snapOffset(delta, 'day');
385
+ }
386
+ /** Deictic day for use in composites: returns delta + consumed. */
387
+ function readDeicticDay(tokens, i) {
388
+ for (const [s, delta] of DEICTIC_DAYS) {
389
+ const end = seq(tokens, i, s);
390
+ if (end !== undefined)
391
+ return { delta, consumed: end - i };
392
+ }
393
+ return undefined;
394
+ }
395
+ const WEEKDAY_CHARS = {
396
+ 一: 'mon', 二: 'tue', 三: 'wed', 四: 'thu', 五: 'fri', 六: 'sat', 日: 'sun', 天: 'sun',
397
+ };
398
+ const WEEK_MARKERS = ['星期', '礼拜', '禮拜', '周', '週'];
399
+ function weekendOf(delta) {
400
+ if (delta === 0) {
401
+ return {
402
+ op: 'span',
403
+ anchor: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: 'sat' } },
404
+ amount: { days: 2 },
405
+ };
406
+ }
407
+ return {
408
+ op: 'span',
409
+ anchor: {
410
+ op: 'seek',
411
+ base: snapOffset(delta, 'week'),
412
+ dir: 'next',
413
+ target: { kind: 'weekday', weekday: 'sat' },
414
+ n: 1,
415
+ },
416
+ amount: { days: 2 },
417
+ };
418
+ }
419
+ // --- duration units --------------------------------------------------------
420
+ const MULTI_UNITS = [
421
+ ['星期', 'week'],
422
+ ['小时', 'hour'], ['小時', 'hour'],
423
+ ['钟头', 'hour'], ['鐘頭', 'hour'],
424
+ ['分钟', 'minute'], ['分鐘', 'minute'],
425
+ ['秒钟', 'second'], ['秒鐘', 'second'],
426
+ ];
427
+ /** [个] unit-word. 月 requires the 个 classifier (三月 is March, 三个月 is 3 months). */
428
+ function readDurUnit(tokens, i) {
429
+ let at = i;
430
+ let ge = false;
431
+ const w0 = word(tokens[at]);
432
+ if (w0 === '个' || w0 === '個') {
433
+ ge = true;
434
+ at += 1;
435
+ }
436
+ let unit;
437
+ let end = at;
438
+ for (const [s, u] of MULTI_UNITS) {
439
+ const e = seq(tokens, at, s);
440
+ if (e !== undefined) {
441
+ unit = u;
442
+ end = e;
443
+ break;
444
+ }
445
+ }
446
+ if (unit === undefined) {
447
+ const c = word(tokens[at]);
448
+ if (c === '年')
449
+ unit = 'year';
450
+ else if (c === '天')
451
+ unit = 'day';
452
+ else if (c === '周' || c === '週')
453
+ unit = 'week';
454
+ else if (c === '时' || c === '時')
455
+ unit = 'hour';
456
+ else if (c === '秒')
457
+ unit = 'second';
458
+ else if ((c === '月') && ge)
459
+ unit = 'month';
460
+ if (unit !== undefined)
461
+ end = at + 1;
462
+ }
463
+ if (unit === undefined)
464
+ return undefined;
465
+ // "18周岁" is an age, not 18 weeks.
466
+ const after = word(tokens[end]);
467
+ if (after === '岁' || after === '歲')
468
+ return undefined;
469
+ return { unit, consumed: end - i };
470
+ }
471
+ function unitField(unit) {
472
+ return `${unit}s`;
473
+ }
474
+ function amountFor(unit, n) {
475
+ return { [unitField(unit)]: n };
476
+ }
477
+ const SUB_DAY = new Set(['second', 'minute', 'hour']);
478
+ // === rules =================================================================
479
+ /** 今天/昨天/明天/前天/后天…, 最近, and day+period compounds 今晚/昨晚/今早. */
480
+ const ruleDeicticDay = (tokens, i) => {
481
+ for (const [s, delta, period] of DEICTIC_DAY_PERIODS) {
482
+ const end = seq(tokens, i, s);
483
+ if (end !== undefined) {
484
+ return {
485
+ expr: { op: 'intersect', parts: [dayExprFor(delta), { op: 'literal', dayPeriod: period }] },
486
+ consumed: end - i,
487
+ confidence: 1,
488
+ role: 'datetime',
489
+ };
490
+ }
491
+ }
492
+ const d = readDeicticDay(tokens, i);
493
+ if (d)
494
+ return { expr: dayExprFor(d.delta), consumed: d.consumed, confidence: 1, role: 'date' };
495
+ const recent = seqAny(tokens, i, ['最近']);
496
+ if (recent)
497
+ return { expr: snapNow('day'), consumed: recent.end - i, confidence: 0.9, role: 'date' };
498
+ return undefined;
499
+ };
500
+ /** 今年 / 去年 / 明年 / 前年 / 后年. */
501
+ const ruleDeicticYear = (tokens, i) => {
502
+ const YEARS = [
503
+ ['今年', 0], ['去年', -1], ['明年', 1], ['前年', -2], ['后年', 2], ['後年', 2],
504
+ ];
505
+ for (const [s, delta] of YEARS) {
506
+ const end = seq(tokens, i, s);
507
+ if (end !== undefined) {
508
+ // Standalone 今年 reads as year-to-date (Recognizers convention);
509
+ // scoped uses (今年的最后3周, 今年下半年) go through readYearScope.
510
+ const expr = delta === 0
511
+ ? {
512
+ op: 'between',
513
+ start: { op: 'snap', base: NOW, unit: 'year', edge: 'start' },
514
+ end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
515
+ }
516
+ : snapOffset(delta, 'year');
517
+ return { expr, consumed: end - i, confidence: 1, role: 'date' };
518
+ }
519
+ }
520
+ return undefined;
521
+ };
522
+ /** Deictic year as a sub-reader for scoped rules. */
523
+ function readYearScope(tokens, i) {
524
+ const y = readYearMarked(tokens, i);
525
+ if (y)
526
+ return { expr: { op: 'literal', date: { year: y.year } }, year: y.year, consumed: y.consumed };
527
+ const YEARS = [
528
+ ['今年', 0], ['去年', -1], ['明年', 1], ['前年', -2], ['后年', 2], ['後年', 2],
529
+ ];
530
+ for (const [s, delta] of YEARS) {
531
+ const end = seq(tokens, i, s);
532
+ if (end !== undefined) {
533
+ return { expr: delta === 0 ? snapNow('year') : snapOffset(delta, 'year'), consumed: end - i };
534
+ }
535
+ }
536
+ return undefined;
537
+ }
538
+ /**
539
+ * (上|下)+ / 这 / 本 [个] 周|星期|月|小时… with optional weekday / 末 tail:
540
+ * 上周三, 下下周末, 这个月, 上个小时, 本月十日.
541
+ */
542
+ const ruleRelPeriod = (tokens, i) => {
543
+ let at = i;
544
+ let delta = 0;
545
+ let matched = false;
546
+ const first = word(tokens[at]);
547
+ if (first === '上' || first === '下') {
548
+ const sign = first === '上' ? -1 : 1;
549
+ while (word(tokens[at]) === first) {
550
+ delta += sign;
551
+ at += 1;
552
+ matched = true;
553
+ }
554
+ }
555
+ else if (first === '这' || first === '這' || first === '本') {
556
+ at += 1;
557
+ matched = true;
558
+ }
559
+ if (!matched)
560
+ return undefined;
561
+ const ge = word(tokens[at]);
562
+ if (ge === '个' || ge === '個')
563
+ at += 1;
564
+ // Week nouns, with optional weekday or 末 suffix.
565
+ const wk = seqAny(tokens, at, WEEK_MARKERS);
566
+ if (wk) {
567
+ at = wk.end;
568
+ if (word(tokens[at]) === '末') {
569
+ return { expr: weekendOf(delta), consumed: at + 1 - i, confidence: 1, role: 'date' };
570
+ }
571
+ const wdChar = word(tokens[at]);
572
+ const wd = wdChar !== undefined ? WEEKDAY_CHARS[wdChar] : undefined;
573
+ if (wd !== undefined) {
574
+ const base = delta === 0 ? snapNow('week') : snapOffset(delta, 'week');
575
+ return {
576
+ expr: { op: 'seek', base, dir: 'next', target: { kind: 'weekday', weekday: wd }, n: 1 },
577
+ consumed: at + 1 - i,
578
+ confidence: 1,
579
+ role: 'date',
580
+ };
581
+ }
582
+ return {
583
+ expr: delta === 0 ? snapNow('week') : snapOffset(delta, 'week'),
584
+ consumed: at - i,
585
+ confidence: 1,
586
+ role: 'date',
587
+ };
588
+ }
589
+ const c = word(tokens[at]);
590
+ if (c === '月') {
591
+ at += 1;
592
+ const monthExpr = delta === 0 ? snapNow('month') : snapOffset(delta, 'month');
593
+ // 本月十日 — a day within the relative month.
594
+ const d = readDay(tokens, at, true);
595
+ if (d) {
596
+ return {
597
+ expr: { op: 'intersect', parts: [monthExpr, { op: 'literal', date: { day: d.day } }] },
598
+ consumed: at + d.consumed - i,
599
+ confidence: 1,
600
+ role: 'date',
601
+ };
602
+ }
603
+ return { expr: monthExpr, consumed: at - i, confidence: 1, role: 'date' };
604
+ }
605
+ if (c === '年') {
606
+ return {
607
+ expr: delta === 0 ? snapNow('year') : snapOffset(delta, 'year'),
608
+ consumed: at + 1 - i,
609
+ confidence: 0.9,
610
+ role: 'date',
611
+ };
612
+ }
613
+ const hour = seqAny(tokens, at, ['小时', '小時', '钟头', '鐘頭']);
614
+ if (hour) {
615
+ const expr = delta === 0 ? snapNow('hour') : { op: 'span', anchor: NOW, amount: { hours: delta } };
616
+ return { expr, consumed: hour.end - i, confidence: 1, role: 'datetime' };
617
+ }
618
+ const min = seqAny(tokens, at, ['分钟', '分鐘']);
619
+ if (min) {
620
+ const expr = delta === 0 ? snapNow('minute') : { op: 'span', anchor: NOW, amount: { minutes: delta } };
621
+ return { expr, consumed: min.end - i, confidence: 1, role: 'datetime' };
622
+ }
623
+ return undefined;
624
+ };
625
+ /** Bare 周末 / 週末. */
626
+ const ruleWeekend = (tokens, i) => {
627
+ const m = seqAny(tokens, i, ['周末', '週末']);
628
+ if (!m)
629
+ return undefined;
630
+ return { expr: weekendOf(0), consumed: m.end - i, confidence: 0.9, role: 'date' };
631
+ };
632
+ /** Bare weekday: 星期一 / 周三 / 禮拜五 / 星期天 → nearest (dual candidates). */
633
+ const ruleBareWeekday = (tokens, i) => {
634
+ const mk = seqAny(tokens, i, WEEK_MARKERS);
635
+ if (!mk)
636
+ return undefined;
637
+ const wdChar = word(tokens[mk.end]);
638
+ const wd = wdChar !== undefined ? WEEKDAY_CHARS[wdChar] : undefined;
639
+ if (wd === undefined)
640
+ return undefined;
641
+ return {
642
+ expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd } },
643
+ consumed: mk.end + 1 - i,
644
+ confidence: 0.9,
645
+ role: 'date',
646
+ };
647
+ };
648
+ /** Calendar dates: [农历] [Y年] [M月 [D日]], 29日, 正月三十, 康熙三年五月. */
649
+ const ruleDate = (tokens, i) => {
650
+ const dp = readDateParts(tokens, i);
651
+ if (!dp)
652
+ return undefined;
653
+ const conf = dp.date.day !== undefined ? (dp.date.month !== undefined ? 0.95 : 0.9)
654
+ : dp.date.month !== undefined ? (dp.date.year !== undefined ? 0.95 : 0.8)
655
+ : 0.95;
656
+ return { expr: { op: 'literal', date: dp.date }, consumed: dp.consumed, confidence: conf, role: 'date' };
657
+ };
658
+ /** Numeric-date tokens: 2010/01/29 (YMD), 1/2/2020 (DMY), 12-11-10, 12/1. */
659
+ const ruleNumDate = (tokens, i) => {
660
+ const t = tokens[i];
661
+ if (t?.type !== 'numdate')
662
+ return undefined;
663
+ const date = numDateParts(t.parts);
664
+ if (!date)
665
+ return undefined;
666
+ return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.9, role: 'date' };
667
+ };
668
+ function numDateParts(parts) {
669
+ const valid = (d) => d.month !== undefined && d.month >= 1 && d.month <= 12 &&
670
+ (d.day === undefined || (d.day >= 1 && d.day <= 31))
671
+ ? d
672
+ : undefined;
673
+ if (parts.length === 3) {
674
+ const [a, b, c] = parts;
675
+ if (a >= 1000)
676
+ return valid({ year: a, month: b, day: c });
677
+ if (c >= 1000)
678
+ return valid({ year: c, month: b, day: a });
679
+ if (a <= 99 && b <= 12 && c <= 31)
680
+ return valid({ year: expandYear2(a), month: b, day: c });
681
+ return undefined;
682
+ }
683
+ if (parts.length === 2) {
684
+ const [a, b] = parts;
685
+ return valid({ month: a, day: b });
686
+ }
687
+ return undefined;
688
+ }
689
+ /** Bare 4-digit year (e.g. "= 2019"). */
690
+ const ruleBareYear = (tokens, i) => {
691
+ const t = tokens[i];
692
+ if (t?.type !== 'number' || t.ordinal || t.value < 1500 || t.value > 2199)
693
+ return undefined;
694
+ if (t.end - t.start !== 4)
695
+ return undefined;
696
+ return { expr: { op: 'literal', date: { year: t.value } }, consumed: 1, confidence: 0.7, role: 'date' };
697
+ };
698
+ /** Clock times & day periods: 下午5:00, 凌晨2点半, 15时20分, 上午, 晚上. */
699
+ const ruleClock = (tokens, i) => {
700
+ const clk = readClockWithPrefix(tokens, i);
701
+ if (clk) {
702
+ return {
703
+ expr: { op: 'literal', time: clk.time },
704
+ consumed: clk.consumed,
705
+ confidence: 1,
706
+ role: 'time',
707
+ };
708
+ }
709
+ // Prefix alone: 上午 → morning period; 中午 → 12:00; 半夜 → 00:00.
710
+ let at = i;
711
+ const approx = seqAny(tokens, at, ['大约', '大約', '大概', '差不多']);
712
+ if (approx)
713
+ at = approx.end;
714
+ const pfx = matchTimePrefix(tokens, at);
715
+ if (!pfx)
716
+ return undefined;
717
+ if (pfx.mer === 'noon') {
718
+ return {
719
+ expr: { op: 'literal', time: { hour: 12, minute: 0 } },
720
+ consumed: pfx.end - i,
721
+ confidence: 0.9,
722
+ role: 'time',
723
+ };
724
+ }
725
+ if (pfx.mer === 'midnight') {
726
+ return {
727
+ expr: { op: 'literal', time: { hour: 0, minute: 0 } },
728
+ consumed: pfx.end - i,
729
+ confidence: 0.9,
730
+ role: 'time',
731
+ };
732
+ }
733
+ if (pfx.period !== undefined) {
734
+ return {
735
+ expr: { op: 'literal', dayPeriod: pfx.period },
736
+ consumed: pfx.end - i,
737
+ confidence: 0.8,
738
+ role: 'time',
739
+ };
740
+ }
741
+ return undefined;
742
+ };
743
+ /** N单位前/后 (offsets): 5年前, 10分钟之前, 2小时后. Day+ snaps to the day. */
744
+ const ruleNAgoLater = (tokens, i) => {
745
+ const n = readNum(tokens, i);
746
+ if (!n || !Number.isInteger(n.value))
747
+ return undefined;
748
+ const u = readDurUnit(tokens, i + n.consumed);
749
+ if (!u)
750
+ return undefined;
751
+ const tailAt = i + n.consumed + u.consumed;
752
+ const tail = seqAny(tokens, tailAt, ['之前', '以前', '之后', '之後', '以后', '以後', '前', '后', '後']);
753
+ if (!tail)
754
+ return undefined;
755
+ const sign = tail.text.includes('前') ? -1 : 1;
756
+ const shifted = { op: 'offset', base: NOW, amount: sign * n.value, unit: u.unit };
757
+ const expr = SUB_DAY.has(u.unit) ? shifted : { op: 'snap', base: shifted, unit: 'day' };
758
+ return { expr, consumed: tail.end - i, confidence: 1, role: 'datetime' };
759
+ };
760
+ /**
761
+ * Prefixed spans: 前1周 / 后1年 / 过去十年 / 之前3小时 / 之后5分钟 / 还剩5分钟.
762
+ * Day-and-coarser anchors at day boundaries (Recognizers convention).
763
+ */
764
+ const rulePrefixSpan = (tokens, i) => {
765
+ const pfx = seqAny(tokens, i, [
766
+ '过去', '過去', '之前', '以前', '之后', '之後', '以后', '以後',
767
+ '还剩下', '还剩', '还有', '還剩', '還有', '前', '后', '後',
768
+ ]);
769
+ if (!pfx)
770
+ return undefined;
771
+ const guarded = ['还剩下', '还剩', '还有', '還剩', '還有'].includes(pfx.text);
772
+ const past = ['过去', '過去', '之前', '以前', '前'].includes(pfx.text);
773
+ const n = readNum(tokens, pfx.end);
774
+ if (!n || !Number.isInteger(n.value))
775
+ return undefined;
776
+ const u = readDurUnit(tokens, pfx.end + n.consumed);
777
+ if (!u)
778
+ return undefined;
779
+ const end = pfx.end + n.consumed + u.consumed;
780
+ // 还剩8天20时 is a compound duration, not a span — leave it to the duration rule.
781
+ if (guarded) {
782
+ const next = tokens[end];
783
+ if (next?.type === 'number')
784
+ return undefined;
785
+ const w = word(next);
786
+ if (w !== undefined && (HAN_DIGIT[w] !== undefined || w === '又' || w === '半' || w === '十')) {
787
+ return undefined;
788
+ }
789
+ }
790
+ const sign = past ? -1 : 1;
791
+ let anchor;
792
+ if (SUB_DAY.has(u.unit)) {
793
+ anchor = NOW;
794
+ }
795
+ else if (past) {
796
+ anchor = { op: 'snap', base: NOW, unit: 'day', edge: 'start' };
797
+ }
798
+ else {
799
+ anchor = { op: 'snap', base: { op: 'offset', base: NOW, amount: 1, unit: 'day' }, unit: 'day', edge: 'start' };
800
+ }
801
+ return {
802
+ expr: { op: 'span', anchor, amount: amountFor(u.unit, sign * n.value) },
803
+ consumed: end - i,
804
+ confidence: 0.95,
805
+ role: SUB_DAY.has(u.unit) ? 'datetime' : 'date',
806
+ };
807
+ };
808
+ /** Compound durations: 两年, 半个月, 三年半, 8天20小时, 一年又一个月21天. */
809
+ const ruleDuration = (tokens, i) => {
810
+ const amount = {};
811
+ let at = i;
812
+ let segments = 0;
813
+ for (;;) {
814
+ let value;
815
+ let numEnd = at;
816
+ if (word(tokens[at]) === '半') {
817
+ value = 0.5;
818
+ numEnd = at + 1;
819
+ }
820
+ else {
821
+ const n = readNum(tokens, at);
822
+ if (n && Number.isInteger(n.value) && n.value >= 1) {
823
+ value = n.value;
824
+ numEnd = at + n.consumed;
825
+ }
826
+ }
827
+ if (value === undefined)
828
+ break;
829
+ const u = readDurUnit(tokens, numEnd);
830
+ if (!u)
831
+ break;
832
+ let segEnd = numEnd + u.consumed;
833
+ if (word(tokens[segEnd]) === '半' && Number.isInteger(value)) {
834
+ // 三年半 → 3.5 years.
835
+ value += 0.5;
836
+ segEnd += 1;
837
+ }
838
+ amount[unitField(u.unit)] = (amount[unitField(u.unit)] ?? 0) + value;
839
+ segments += 1;
840
+ at = segEnd;
841
+ const conn = word(tokens[at]);
842
+ if (conn === '又' || conn === '多' || conn === '余' || conn === '零')
843
+ at += 1;
844
+ }
845
+ if (segments === 0)
846
+ return undefined;
847
+ return {
848
+ expr: { op: 'amount', amount },
849
+ consumed: at - i,
850
+ confidence: 0.8,
851
+ role: 'duration',
852
+ };
853
+ };
854
+ /** Half periods: [year] 上半年/下半年, 上半月, 下半周, 上个半年. */
855
+ const ruleHalf = (tokens, i) => {
856
+ let at = i;
857
+ let baseYear;
858
+ const scope = readYearScope(tokens, at);
859
+ if (scope) {
860
+ baseYear = scope.expr;
861
+ at = scope.consumed + i;
862
+ }
863
+ const half = seqAny(tokens, at, [
864
+ '上半年', '前半年', '下半年', '后半年', '後半年',
865
+ '上半月', '下半月', '上半周', '上半週', '下半周', '下半週',
866
+ '上个半年', '上個半年',
867
+ ]);
868
+ if (!half)
869
+ return undefined;
870
+ const h = half.text;
871
+ const consumed = half.end - i;
872
+ const mk = (expr) => ({ expr, consumed, confidence: 0.95, role: 'date' });
873
+ if (h.endsWith('半年') && h.length === 3) {
874
+ const base = baseYear ?? snapNow('year');
875
+ const start = { op: 'snap', base, unit: 'year', edge: 'start' };
876
+ if (h.startsWith('上') || h.startsWith('前')) {
877
+ return mk({ op: 'span', anchor: start, amount: { months: 6 } });
878
+ }
879
+ return mk({
880
+ op: 'span',
881
+ anchor: { op: 'offset', base: start, amount: 6, unit: 'month' },
882
+ amount: { months: 6 },
883
+ });
884
+ }
885
+ if (baseYear !== undefined)
886
+ return undefined; // 2017年上半月 — not a thing
887
+ if (h === '上个半年' || h === '上個半年') {
888
+ return mk({
889
+ op: 'span',
890
+ anchor: { op: 'snap', base: NOW, unit: 'year', edge: 'start' },
891
+ amount: { months: 6 },
892
+ });
893
+ }
894
+ if (h === '上半月') {
895
+ return mk({
896
+ op: 'span',
897
+ anchor: { op: 'snap', base: NOW, unit: 'month', edge: 'start' },
898
+ amount: { days: 15 },
899
+ });
900
+ }
901
+ if (h === '下半月') {
902
+ return mk({
903
+ op: 'between',
904
+ start: { op: 'offset', base: { op: 'snap', base: NOW, unit: 'month', edge: 'start' }, amount: 15, unit: 'day' },
905
+ end: { op: 'snap', base: NOW, unit: 'month', edge: 'end' },
906
+ });
907
+ }
908
+ const weekStart = { op: 'snap', base: NOW, unit: 'week', edge: 'start' };
909
+ if (h === '上半周' || h === '上半週') {
910
+ return mk({ op: 'span', anchor: weekStart, amount: { days: 3 } });
911
+ }
912
+ return mk({
913
+ op: 'span',
914
+ anchor: { op: 'offset', base: weekStart, amount: 3, unit: 'day' },
915
+ amount: { days: 4 },
916
+ });
917
+ };
918
+ // --- scoped ordinals: X的第N周, 前N天, 最后N个月, 最后一个星期一 --------------
919
+ function readMonthScope(tokens, i) {
920
+ const m = readMonthMarked(tokens, i);
921
+ if (!m)
922
+ return undefined;
923
+ return { expr: { op: 'literal', date: { month: m.month } }, consumed: m.consumed };
924
+ }
925
+ const ruleScopedOrdinal = (tokens, i) => {
926
+ let at = i;
927
+ let base;
928
+ let baseYearValue;
929
+ let baseUnit;
930
+ const y = readYearScope(tokens, at);
931
+ if (y) {
932
+ base = y.expr;
933
+ baseYearValue = y.year;
934
+ baseUnit = 'year';
935
+ at += y.consumed;
936
+ }
937
+ else {
938
+ const m = readMonthScope(tokens, at);
939
+ if (!m)
940
+ return undefined;
941
+ base = m.expr;
942
+ baseUnit = 'month';
943
+ at += m.consumed;
944
+ }
945
+ if (word(tokens[at]) === '的')
946
+ at += 1;
947
+ const baseStart = { op: 'snap', base, unit: baseUnit, edge: 'start' };
948
+ const baseEnd = { op: 'snap', base, unit: baseUnit, edge: 'end' };
949
+ // First week mostly inside the scope: week containing (start + 3 days).
950
+ const firstWeek = {
951
+ op: 'snap',
952
+ base: { op: 'offset', base: baseStart, amount: 3, unit: 'day' },
953
+ unit: 'week',
954
+ };
955
+ const lastWeekEnd = {
956
+ op: 'snap',
957
+ base: { op: 'offset', base: baseEnd, amount: -4, unit: 'day' },
958
+ unit: 'week',
959
+ edge: 'end',
960
+ };
961
+ const done = (expr, end) => ({
962
+ expr,
963
+ consumed: end - i,
964
+ confidence: 0.95,
965
+ role: 'date',
966
+ });
967
+ const kind = seqAny(tokens, at, ['第', '前', '最后', '最後']);
968
+ if (!kind)
969
+ return undefined;
970
+ const isNth = kind.text === '第';
971
+ const isFirstN = kind.text === '前';
972
+ at = kind.end;
973
+ // 最后一个星期一 (last weekday of a month scope).
974
+ if (!isNth && !isFirstN) {
975
+ const one = seqAny(tokens, at, ['一个', '一個']);
976
+ const wkAt = one ? one.end : at;
977
+ const mk = seqAny(tokens, wkAt, WEEK_MARKERS);
978
+ if (mk) {
979
+ const wdChar = word(tokens[mk.end]);
980
+ const wd = wdChar !== undefined ? WEEKDAY_CHARS[wdChar] : undefined;
981
+ if (wd !== undefined) {
982
+ // n:1 pins strict occurrence counting (the nextWeekday dialect policy
983
+ // must not skip a week when walking back from the scope end).
984
+ return done({ op: 'seek', base: baseEnd, dir: 'prev', target: { kind: 'weekday', weekday: wd }, n: 1 }, mk.end + 1);
985
+ }
986
+ }
987
+ }
988
+ const n = readNum(tokens, at);
989
+ if (!n || !Number.isInteger(n.value) || n.value < 1 || n.value > 60)
990
+ return undefined;
991
+ at += n.consumed;
992
+ const ge = word(tokens[at]);
993
+ const hasGe = ge === '个' || ge === '個';
994
+ if (hasGe)
995
+ at += 1;
996
+ const wk = seqAny(tokens, at, ['星期', '周', '週']);
997
+ if (wk) {
998
+ const end = wk.end;
999
+ if (isNth) {
1000
+ const expr = n.value === 1 ? firstWeek : { op: 'offset', base: firstWeek, amount: n.value - 1, unit: 'week' };
1001
+ return done(expr, end);
1002
+ }
1003
+ if (isFirstN) {
1004
+ return done({ op: 'span', anchor: { ...firstWeek, edge: 'start' }, amount: { weeks: n.value } }, end);
1005
+ }
1006
+ return done({ op: 'span', anchor: lastWeekEnd, amount: { weeks: -n.value } }, end);
1007
+ }
1008
+ if (word(tokens[at]) === '月' && baseUnit === 'year') {
1009
+ const end = at + 1;
1010
+ if (isNth) {
1011
+ if (n.value > 12)
1012
+ return undefined;
1013
+ const expr = baseYearValue !== undefined
1014
+ ? { op: 'literal', date: { year: baseYearValue, month: n.value } }
1015
+ : { op: 'intersect', parts: [base, { op: 'literal', date: { month: n.value } }] };
1016
+ return done(expr, end);
1017
+ }
1018
+ if (isFirstN)
1019
+ return done({ op: 'span', anchor: baseStart, amount: { months: n.value } }, end);
1020
+ return done({ op: 'span', anchor: baseEnd, amount: { months: -n.value } }, end);
1021
+ }
1022
+ if ((word(tokens[at]) === '天' || word(tokens[at]) === '日') && !hasGe) {
1023
+ const end = at + 1;
1024
+ if (isNth)
1025
+ return undefined;
1026
+ if (isFirstN)
1027
+ return done({ op: 'span', anchor: baseStart, amount: { days: n.value } }, end);
1028
+ return done({ op: 'span', anchor: baseEnd, amount: { days: -n.value } }, end);
1029
+ }
1030
+ return undefined;
1031
+ };
1032
+ /** Decades: 十九世纪七十年代 → the 1870s. */
1033
+ const ruleDecade = (tokens, i) => {
1034
+ const c = readNum(tokens, i);
1035
+ if (!c || c.value < 1 || c.value > 21)
1036
+ return undefined;
1037
+ const cent = seqAny(tokens, i + c.consumed, ['世纪', '世紀']);
1038
+ if (!cent)
1039
+ return undefined;
1040
+ const d = readNum(tokens, cent.end);
1041
+ if (!d || d.value < 10 || d.value > 90 || d.value % 10 !== 0)
1042
+ return undefined;
1043
+ const era = seqAny(tokens, cent.end + d.consumed, ['年代']);
1044
+ if (!era)
1045
+ return undefined;
1046
+ const year = (c.value - 1) * 100 + d.value;
1047
+ return {
1048
+ expr: { op: 'span', anchor: { op: 'literal', date: { year } }, amount: { years: 10 } },
1049
+ consumed: era.end - i,
1050
+ confidence: 0.95,
1051
+ role: 'date',
1052
+ };
1053
+ };
1054
+ function readRangeOperand(tokens, i) {
1055
+ // Month scope + 最后一个星期X ("四月的最后一个星期一").
1056
+ const m = readMonthScope(tokens, i);
1057
+ if (m) {
1058
+ let at = i + m.consumed;
1059
+ if (word(tokens[at]) === '的')
1060
+ at += 1;
1061
+ const last = seqAny(tokens, at, ['最后一个', '最後一個', '最后一個', '最後一个', '最后', '最後']);
1062
+ if (last) {
1063
+ const mk = seqAny(tokens, last.end, WEEK_MARKERS);
1064
+ const wdChar = mk ? word(tokens[mk.end]) : undefined;
1065
+ const wd = wdChar !== undefined ? WEEKDAY_CHARS[wdChar] : undefined;
1066
+ if (mk && wd !== undefined) {
1067
+ return {
1068
+ kind: 'dateExpr',
1069
+ expr: {
1070
+ op: 'seek',
1071
+ base: { op: 'snap', base: m.expr, unit: 'month', edge: 'end' },
1072
+ dir: 'prev',
1073
+ target: { kind: 'weekday', weekday: wd },
1074
+ n: 1,
1075
+ },
1076
+ consumed: mk.end + 1 - i,
1077
+ };
1078
+ }
1079
+ }
1080
+ }
1081
+ const dp = readDateParts(tokens, i);
1082
+ if (dp) {
1083
+ const clk = readClockWithPrefix(tokens, i + dp.consumed);
1084
+ if (clk) {
1085
+ return {
1086
+ kind: 'datetime',
1087
+ dayExpr: { op: 'literal', date: dp.date },
1088
+ date: dp.date,
1089
+ time: clk.time,
1090
+ consumed: dp.consumed + clk.consumed,
1091
+ };
1092
+ }
1093
+ return { kind: 'date', date: dp.date, consumed: dp.consumed };
1094
+ }
1095
+ const deictic = readDeicticDay(tokens, i);
1096
+ if (deictic) {
1097
+ const dayExpr = dayExprFor(deictic.delta);
1098
+ const clk = readClockWithPrefix(tokens, i + deictic.consumed);
1099
+ if (clk) {
1100
+ return { kind: 'datetime', dayExpr, time: clk.time, consumed: deictic.consumed + clk.consumed };
1101
+ }
1102
+ return { kind: 'dateExpr', expr: dayExpr, consumed: deictic.consumed };
1103
+ }
1104
+ const clk = readClockWithPrefix(tokens, i);
1105
+ if (clk)
1106
+ return { kind: 'time', time: clk.time, consumed: clk.consumed };
1107
+ // Bare 4-digit year ("1995 - 1997年").
1108
+ const t = tokens[i];
1109
+ if (t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 2999 && t.end - t.start === 4) {
1110
+ return { kind: 'date', date: { year: t.value }, consumed: 1 };
1111
+ }
1112
+ return undefined;
1113
+ }
1114
+ const RANGE_CONNECTORS = ['到', '至', '-', '~', '~'];
1115
+ const AND_CONNECTORS = ['和', '与', '與', '跟'];
1116
+ /** Range ends: `between` treats non-point ends as exclusive at their start,
1117
+ * which is exactly the Chinese (and Recognizers) convention — 6月1日到6月30日
1118
+ * ends at 06-30T00:00. A plain literal is the right end operand. */
1119
+ function rangeEndExpr(date) {
1120
+ return { op: 'literal', date };
1121
+ }
1122
+ /** Share meridiem across a same-day time pair; 12 with am context stays 12:00. */
1123
+ function shareMeridiem(ta, tb, rangeMer) {
1124
+ let a = { ...ta };
1125
+ let b = { ...tb };
1126
+ const bare = (t) => {
1127
+ const { meridiem: _m, ...rest } = t;
1128
+ return rest;
1129
+ };
1130
+ if (rangeMer !== undefined) {
1131
+ if (a.meridiem === 'unknown' || a.meridiem === undefined)
1132
+ a = applyMeridiem(bare(a), rangeMer);
1133
+ if (b.meridiem === 'unknown' || b.meridiem === undefined)
1134
+ b = applyMeridiem(bare(b), rangeMer);
1135
+ }
1136
+ if (a.meridiem !== undefined && a.meridiem !== 'unknown' && b.meridiem === 'unknown') {
1137
+ if (b.hour === 12)
1138
+ delete b.meridiem;
1139
+ else
1140
+ b.meridiem = a.meridiem;
1141
+ }
1142
+ else if (b.meridiem !== undefined && b.meridiem !== 'unknown' && a.meridiem === 'unknown') {
1143
+ if ((a.hour ?? 0) < (b.hour ?? 0))
1144
+ a.meridiem = b.meridiem;
1145
+ }
1146
+ return [a, b];
1147
+ }
1148
+ function datesCompatibleForAdjacency(a, b) {
1149
+ const yearOnly = (d) => d.year !== undefined && d.month === undefined && d.day === undefined;
1150
+ const monthOnly = (d) => d.month !== undefined && d.day === undefined && d.year === undefined;
1151
+ const monthDay = (d) => d.month !== undefined && d.day !== undefined;
1152
+ if (yearOnly(a) && yearOnly(b))
1153
+ return true;
1154
+ if (monthOnly(a) && monthOnly(b))
1155
+ return true;
1156
+ if (monthDay(a) && monthDay(b) && b.year === undefined)
1157
+ return true;
1158
+ return false;
1159
+ }
1160
+ /** Joined range words the tokenizer can't split: 17:55:23-18:33:02, 1995-1997年. */
1161
+ function joinedRange(tokens, i) {
1162
+ const w = word(tokens[i]);
1163
+ if (w === undefined)
1164
+ return undefined;
1165
+ let m = w.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?[-~](\d{1,2}):(\d{2})(?::(\d{2}))?$/);
1166
+ if (m) {
1167
+ const mkTime = (h, mi, s) => {
1168
+ const time = { hour: Number(h), minute: Number(mi) };
1169
+ if (s !== undefined)
1170
+ time.second = Number(s);
1171
+ if (time.hour !== undefined && time.hour >= 1 && time.hour <= 12)
1172
+ time.meridiem = 'unknown';
1173
+ return { op: 'literal', time };
1174
+ };
1175
+ return {
1176
+ expr: { op: 'between', start: mkTime(m[1], m[2], m[3]), end: mkTime(m[4], m[5], m[6]) },
1177
+ consumed: 1,
1178
+ confidence: 0.95,
1179
+ role: 'time',
1180
+ };
1181
+ }
1182
+ m = w.match(/^(\d{4})[-~](\d{4})$/);
1183
+ if (m) {
1184
+ const y1 = Number(m[1]);
1185
+ const y2 = Number(m[2]);
1186
+ if (y1 >= 1000 && y1 <= 2999 && y2 > y1 && y2 <= 2999) {
1187
+ const hasNian = word(tokens[i + 1]) === '年';
1188
+ return {
1189
+ expr: {
1190
+ op: 'between',
1191
+ start: { op: 'literal', date: { year: y1 } },
1192
+ end: { op: 'literal', date: { year: y2 } },
1193
+ },
1194
+ consumed: hasNian ? 2 : 1,
1195
+ confidence: hasNian ? 0.95 : 0.85,
1196
+ role: 'date',
1197
+ };
1198
+ }
1199
+ }
1200
+ m = w.match(/^(\d{1,4})\/(\d{1,2})\/(\d{1,4})[-~](\d{1,4})\/(\d{1,2})\/(\d{1,4})$/);
1201
+ if (m) {
1202
+ const d1 = numDateParts([Number(m[1]), Number(m[2]), Number(m[3])]);
1203
+ const d2 = numDateParts([Number(m[4]), Number(m[5]), Number(m[6])]);
1204
+ if (d1 && d2) {
1205
+ return {
1206
+ expr: { op: 'between', start: { op: 'literal', date: d1 }, end: rangeEndExpr(d2) },
1207
+ consumed: 1,
1208
+ confidence: 0.95,
1209
+ role: 'date',
1210
+ };
1211
+ }
1212
+ }
1213
+ return undefined;
1214
+ }
1215
+ const ruleRange = (tokens, i) => {
1216
+ let at = i;
1217
+ const cong = word(tokens[at]);
1218
+ const hasCong = cong === '从' || cong === '從' || cong === '自';
1219
+ if (hasCong)
1220
+ at += 1;
1221
+ const joined = joinedRange(tokens, at);
1222
+ if (joined)
1223
+ return { ...joined, consumed: at + joined.consumed - i };
1224
+ // Range-level day-period prefix over a bare-hour start: 早上五到六点.
1225
+ let rangeMer;
1226
+ let a;
1227
+ const pfx = matchTimePrefix(tokens, at);
1228
+ if (pfx && readClock(tokens, pfx.end) === undefined) {
1229
+ const n = readNum(tokens, pfx.end);
1230
+ if (n && Number.isInteger(n.value) && n.value >= 0 && n.value <= 24) {
1231
+ const connW = word(tokens[pfx.end + n.consumed]);
1232
+ if (connW !== undefined && RANGE_CONNECTORS.includes(connW)) {
1233
+ rangeMer = pfx.mer;
1234
+ a = { kind: 'time', time: { hour: n.value }, consumed: pfx.end + n.consumed - at };
1235
+ }
1236
+ }
1237
+ }
1238
+ if (!a)
1239
+ a = readRangeOperand(tokens, at);
1240
+ if (!a)
1241
+ return undefined;
1242
+ at += a.consumed;
1243
+ let needSuffix = false;
1244
+ let hasConnector = false;
1245
+ const connW = word(tokens[at]);
1246
+ if (connW !== undefined && RANGE_CONNECTORS.includes(connW)) {
1247
+ hasConnector = true;
1248
+ at += 1;
1249
+ }
1250
+ else if (connW !== undefined && AND_CONNECTORS.includes(connW)) {
1251
+ hasConnector = true;
1252
+ needSuffix = true;
1253
+ at += 1;
1254
+ }
1255
+ const b = readRangeOperand(tokens, at);
1256
+ if (!b)
1257
+ return undefined;
1258
+ if (!hasConnector) {
1259
+ // Only dash-absorbed adjacencies: 2019年6月1日[-]6月30日, 5月[-]10月,
1260
+ // 1995[–]1997年, 昨天5:00[-]6:00.
1261
+ const ok = (a.kind === 'date' && b.kind === 'date' && datesCompatibleForAdjacency(a.date, b.date)) ||
1262
+ ((a.kind === 'datetime' || a.kind === 'time') && b.kind === 'time');
1263
+ if (!ok)
1264
+ return undefined;
1265
+ }
1266
+ at += b.consumed;
1267
+ if (needSuffix) {
1268
+ const sfx = seqAny(tokens, at, ['之间', '之間', '间', '間']);
1269
+ if (!sfx)
1270
+ return undefined;
1271
+ at = sfx.end;
1272
+ }
1273
+ const mk = (expr, role) => ({
1274
+ expr,
1275
+ consumed: at - i,
1276
+ confidence: 0.95,
1277
+ role,
1278
+ });
1279
+ // time – time
1280
+ if (a.kind === 'time' && b.kind === 'time') {
1281
+ const [ta, tb] = shareMeridiem(a.time, b.time, rangeMer);
1282
+ return mk({ op: 'between', start: { op: 'literal', time: ta }, end: { op: 'literal', time: tb } }, 'time');
1283
+ }
1284
+ // datetime|dateExpr+time – time (same-day end): 昨天5:00-6:00, …晚上7:00到8:00
1285
+ if (a.kind === 'datetime' && b.kind === 'time') {
1286
+ const [ta, tb] = shareMeridiem(a.time, b.time, rangeMer);
1287
+ return mk({
1288
+ op: 'between',
1289
+ start: { op: 'intersect', parts: [a.dayExpr, { op: 'literal', time: ta }] },
1290
+ end: { op: 'intersect', parts: [a.dayExpr, { op: 'literal', time: tb }] },
1291
+ }, 'datetime');
1292
+ }
1293
+ // datetime – datetime: 昨天下午两点到明天四点, 1月15号4点和2月3号9点之间.
1294
+ // Ambiguous hours read as-written (04:00, not 16:00) — Recognizers'
1295
+ // convention, and it keeps the candidate cartesian under the engine cap.
1296
+ const asDatetimeExpr = (o) => {
1297
+ if (o.kind === 'datetime') {
1298
+ let time = o.time;
1299
+ if (time.meridiem === 'unknown') {
1300
+ const { meridiem: _m, ...rest } = time;
1301
+ time = rest;
1302
+ }
1303
+ return { op: 'intersect', parts: [o.dayExpr, { op: 'literal', time }] };
1304
+ }
1305
+ return undefined;
1306
+ };
1307
+ const da = asDatetimeExpr(a);
1308
+ const db = asDatetimeExpr(b);
1309
+ if (da && db)
1310
+ return mk({ op: 'between', start: da, end: db }, 'datetime');
1311
+ if (da && b.kind === 'date') {
1312
+ return mk({ op: 'between', start: da, end: rangeEndExpr(b.date) }, 'datetime');
1313
+ }
1314
+ if (a.kind === 'dateExpr' && db)
1315
+ return mk({ op: 'between', start: a.expr, end: db }, 'datetime');
1316
+ // date-ish – date-ish, with month/year inheritance and exclusive ends.
1317
+ const aStart = a.kind === 'date' ? { op: 'literal', date: a.date } : a.kind === 'dateExpr' ? a.expr : undefined;
1318
+ if (aStart === undefined)
1319
+ return undefined;
1320
+ let bEnd;
1321
+ if (b.kind === 'date') {
1322
+ const bd = { ...b.date };
1323
+ if (a.kind === 'date') {
1324
+ if (bd.month === undefined && bd.day !== undefined && a.date.month !== undefined) {
1325
+ bd.month = a.date.month;
1326
+ }
1327
+ if (bd.year === undefined && a.date.year !== undefined && bd.month !== undefined) {
1328
+ bd.year = a.date.year;
1329
+ }
1330
+ }
1331
+ bEnd = rangeEndExpr(bd);
1332
+ }
1333
+ else if (b.kind === 'dateExpr') {
1334
+ bEnd = b.expr;
1335
+ }
1336
+ else {
1337
+ return undefined;
1338
+ }
1339
+ return mk({ op: 'between', start: aStart, end: bEnd }, 'date');
1340
+ };
1341
+ // === registry ==============================================================
1342
+ export const ZH_RULE_ENTRIES = [
1343
+ { name: 'range', rule: ruleRange },
1344
+ { name: 'scoped-ordinal', rule: ruleScopedOrdinal },
1345
+ { name: 'half', rule: ruleHalf },
1346
+ { name: 'date', rule: ruleDate },
1347
+ { name: 'numdate', rule: ruleNumDate },
1348
+ { name: 'rel-period', rule: ruleRelPeriod },
1349
+ { name: 'deictic-day', rule: ruleDeicticDay },
1350
+ { name: 'deictic-year', rule: ruleDeicticYear },
1351
+ { name: 'prefix-span', rule: rulePrefixSpan },
1352
+ { name: 'n-ago-later', rule: ruleNAgoLater },
1353
+ { name: 'duration', rule: ruleDuration },
1354
+ { name: 'clock', rule: ruleClock },
1355
+ { name: 'weekend', rule: ruleWeekend },
1356
+ { name: 'bare-weekday', rule: ruleBareWeekday },
1357
+ { name: 'decade', rule: ruleDecade },
1358
+ { name: 'bare-year', rule: ruleBareYear },
1359
+ ];
1360
+ export const ZH_CONNECTORS = ['的'];