nl2time 0.1.1 → 0.3.1

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,1207 @@
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 w(tokens, i) {
9
+ const t = tokens[i];
10
+ return t?.type === 'word' ? t.value : undefined;
11
+ }
12
+ /** Match consecutive single-character word tokens spelling `s`; returns count consumed. */
13
+ function seq(tokens, i, s) {
14
+ let k = i;
15
+ for (const ch of s) {
16
+ if (w(tokens, k) !== ch)
17
+ return undefined;
18
+ k += 1;
19
+ }
20
+ return k - i;
21
+ }
22
+ // --- numbers ---------------------------------------------------------------
23
+ const KDIGIT = {
24
+ '〇': 0, '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
25
+ '六': 6, '七': 7, '八': 8, '九': 9,
26
+ };
27
+ const KMULT = { '十': 10, '百': 100, '千': 1000 };
28
+ /** ASCII number token or kanji numeral run (三十 = 30, 二〇一八 = 2018). */
29
+ function readInt(tokens, i) {
30
+ const t = tokens[i];
31
+ if (t?.type === 'number' && !t.ordinal)
32
+ return { value: t.value, consumed: 1 };
33
+ const chars = [];
34
+ let k = i;
35
+ for (;;) {
36
+ const c = w(tokens, k);
37
+ if (c !== undefined && (KDIGIT[c] !== undefined || KMULT[c] !== undefined)) {
38
+ chars.push(c);
39
+ k += 1;
40
+ }
41
+ else
42
+ break;
43
+ }
44
+ if (chars.length === 0)
45
+ return undefined;
46
+ let value = 0;
47
+ if (chars.some((c) => KMULT[c] !== undefined)) {
48
+ let cur = 0;
49
+ for (const c of chars) {
50
+ if (KDIGIT[c] !== undefined)
51
+ cur = cur * 10 + KDIGIT[c];
52
+ else {
53
+ value += (cur === 0 ? 1 : cur) * KMULT[c];
54
+ cur = 0;
55
+ }
56
+ }
57
+ value += cur;
58
+ }
59
+ else {
60
+ for (const c of chars)
61
+ value = value * 10 + KDIGIT[c];
62
+ }
63
+ return { value, consumed: chars.length };
64
+ }
65
+ /** Digits, kanji numbers, decimals ("123.45"), 数 = "several" (3). */
66
+ function readCount(tokens, i) {
67
+ const r = readInt(tokens, i);
68
+ if (r)
69
+ return r;
70
+ const t = tokens[i];
71
+ if (t?.type === 'word' && /^\d+\.\d+$/.test(t.value))
72
+ return { value: Number(t.value), consumed: 1 };
73
+ if (w(tokens, i) === '数')
74
+ return { value: 3, consumed: 1 };
75
+ return undefined;
76
+ }
77
+ // --- years / eras ----------------------------------------------------------
78
+ const ERAS = [
79
+ { name: '令和', base: 2018 },
80
+ { name: '平成', base: 1988 },
81
+ { name: '昭和', base: 1925 },
82
+ { name: '大正', base: 1911 },
83
+ { name: '明治', base: 1867 },
84
+ ];
85
+ /** A year value (before the 年 marker): 4-digit, era+N (平成13 → 2001), era+元. */
86
+ function readYear(tokens, i) {
87
+ for (const era of ERAS) {
88
+ const c = seq(tokens, i, era.name);
89
+ if (c === undefined)
90
+ continue;
91
+ if (w(tokens, i + c) === '元')
92
+ return { value: era.base + 1, consumed: c + 1 };
93
+ const n = readInt(tokens, i + c);
94
+ if (n && n.value >= 1 && n.value <= 99)
95
+ return { value: era.base + n.value, consumed: c + n.consumed };
96
+ return undefined;
97
+ }
98
+ const n = readInt(tokens, i);
99
+ if (n && n.value >= 1000 && n.value <= 9999)
100
+ return n;
101
+ if (n && n.value >= 10 && n.value <= 99)
102
+ return { value: 2000 + n.value, consumed: n.consumed, short: true };
103
+ return undefined;
104
+ }
105
+ // --- dates -----------------------------------------------------------------
106
+ const WEEKDAY_CHARS = {
107
+ '月': 'mon', '火': 'tue', '水': 'wed', '木': 'thu', '金': 'fri', '土': 'sat', '日': 'sun',
108
+ };
109
+ function readWeekdayName(tokens, i) {
110
+ const c = w(tokens, i);
111
+ if (c === undefined || WEEKDAY_CHARS[c] === undefined)
112
+ return undefined;
113
+ if (w(tokens, i + 1) !== '曜')
114
+ return undefined;
115
+ return { weekday: WEEKDAY_CHARS[c], consumed: w(tokens, i + 2) === '日' ? 3 : 2 };
116
+ }
117
+ /** [YYYY年|era年] [の] [M月|正月] [の] [D日] [weekday] — at least one component. */
118
+ function readDateCore(tokens, i) {
119
+ let at = i;
120
+ const date = {};
121
+ let grain;
122
+ let shortYear = false;
123
+ const y = readYear(tokens, at);
124
+ if (y && w(tokens, at + y.consumed) === '年') {
125
+ date.year = y.value;
126
+ shortYear = y.short === true;
127
+ at += y.consumed + 1;
128
+ grain = 'year';
129
+ }
130
+ // month
131
+ {
132
+ let mAt = at;
133
+ if (grain !== undefined && w(tokens, mAt) === 'の')
134
+ mAt += 1;
135
+ const mm = readInt(tokens, mAt);
136
+ if (mm && w(tokens, mAt + mm.consumed) === '月' && mm.value >= 1 && mm.value <= 12) {
137
+ date.month = mm.value;
138
+ at = mAt + mm.consumed + 1;
139
+ grain = 'month';
140
+ }
141
+ else if (w(tokens, mAt) === '正' && w(tokens, mAt + 1) === '月') {
142
+ date.month = 1;
143
+ at = mAt + 2;
144
+ grain = 'month';
145
+ }
146
+ else if (w(tokens, mAt) === 'お' && seq(tokens, mAt + 1, '正月') !== undefined) {
147
+ date.month = 1;
148
+ at = mAt + 3;
149
+ grain = 'month';
150
+ }
151
+ }
152
+ if (shortYear && date.month === undefined)
153
+ return undefined;
154
+ // day
155
+ {
156
+ let dAt = at;
157
+ if (grain !== undefined && w(tokens, dAt) === 'の')
158
+ dAt += 1;
159
+ const dd = readInt(tokens, dAt);
160
+ if (dd && w(tokens, dAt + dd.consumed) === '日' && dd.value >= 1 && dd.value <= 31) {
161
+ // "3日間" belongs to the duration reading, and bare kanji 一日 is the
162
+ // idiomatic "one whole day" — neither is a day-of-month here.
163
+ const after = w(tokens, dAt + dd.consumed + 1);
164
+ const kanjiOne = grain === undefined && w(tokens, dAt) === '一' && dd.consumed === 1;
165
+ if (after !== '間' && !kanjiOne) {
166
+ date.day = dd.value;
167
+ at = dAt + dd.consumed + 1;
168
+ grain = 'day';
169
+ }
170
+ }
171
+ }
172
+ if (grain === undefined)
173
+ return undefined;
174
+ // Trailing redundant weekday: "7月6日金曜日".
175
+ if (date.day !== undefined) {
176
+ const wd = readWeekdayName(tokens, at);
177
+ if (wd)
178
+ at += wd.consumed;
179
+ }
180
+ return { date, consumed: at - i, grain };
181
+ }
182
+ function grainUnit(grain) {
183
+ return grain;
184
+ }
185
+ // --- deictic tables --------------------------------------------------------
186
+ function dayPeriodOn(dayDelta, period) {
187
+ return {
188
+ op: 'intersect',
189
+ parts: [
190
+ dayDelta === 0 ? snapNow('day') : snapOffset(dayDelta, 'day'),
191
+ { op: 'literal', dayPeriod: period },
192
+ ],
193
+ };
194
+ }
195
+ const DAY_DEICTIC = [
196
+ ['一昨日', -2], ['明後日', 2], ['今日', 0], ['本日', 0], ['当日', 0], ['この日', 0],
197
+ ['昨日', -1], ['明日', 1], ['前日', -1], ['翌日', 1],
198
+ ];
199
+ const UNIT_DEICTIC = [
200
+ ['再来週', 2, 'week'], ['先々週', -2, 'week'], ['次の週', 1, 'week'], ['前の週', -1, 'week'],
201
+ ['その週', 0, 'week'], ['同じ週', 0, 'week'],
202
+ ['今週', 0, 'week'], ['先週', -1, 'week'], ['来週', 1, 'week'], ['翌週', 1, 'week'],
203
+ ['再来月', 2, 'month'], ['その月', 0, 'month'], ['同じ月', 0, 'month'],
204
+ ['今月', 0, 'month'], ['先月', -1, 'month'], ['来月', 1, 'month'], ['翌月', 1, 'month'],
205
+ ['再来年', 2, 'year'], ['一昨年', -2, 'year'], ['前の年', -1, 'year'],
206
+ ['その年', 0, 'year'], ['同じ年', 0, 'year'],
207
+ ['今年', 0, 'year'], ['去年', -1, 'year'], ['昨年', -1, 'year'], ['来年', 1, 'year'],
208
+ ['翌年', 1, 'year'],
209
+ ];
210
+ function readDeicticDay(tokens, i) {
211
+ for (const [s, delta] of DAY_DEICTIC) {
212
+ const c = seq(tokens, i, s);
213
+ if (c !== undefined) {
214
+ return { expr: delta === 0 ? snapNow('day') : snapOffset(delta, 'day'), consumed: c };
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+ function readDeicticUnit(tokens, i) {
220
+ for (const [s, delta, unit] of UNIT_DEICTIC) {
221
+ const c = seq(tokens, i, s);
222
+ if (c !== undefined) {
223
+ return { expr: delta === 0 ? snapNow(unit) : snapOffset(delta, unit), unit, consumed: c };
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+ function readUnitWord(tokens, i) {
229
+ const c = w(tokens, i);
230
+ if (c === undefined)
231
+ return undefined;
232
+ const kan = (base, unit) => ({
233
+ unit,
234
+ consumed: w(tokens, i + base) === '間' ? base + 1 : base,
235
+ });
236
+ if (seq(tokens, i, '営業日') !== undefined)
237
+ return { unit: 'day', consumed: 3, business: true };
238
+ if (c === '日') {
239
+ const withKan = w(tokens, i + 1) === '間';
240
+ return { unit: 'day', consumed: withKan ? 2 : 1, bareDay: !withKan };
241
+ }
242
+ if (c === '週' && w(tokens, i + 1) === '間')
243
+ return { unit: 'week', consumed: 2 };
244
+ if ((c === 'か' || c === 'ヶ' || c === 'ケ' || c === 'カ' || c === '箇') && w(tokens, i + 1) === '月') {
245
+ return kan(2, 'month');
246
+ }
247
+ if (c === '月' && w(tokens, i + 1) === '間')
248
+ return { unit: 'month', consumed: 2 };
249
+ if (c === '時' && w(tokens, i + 1) === '間')
250
+ return { unit: 'hour', consumed: 2 };
251
+ if (c === '年')
252
+ return kan(1, 'year');
253
+ if (c === '分')
254
+ return kan(1, 'minute');
255
+ if (c === '秒')
256
+ return kan(1, 'second');
257
+ return undefined;
258
+ }
259
+ const UNIT_RANK = {
260
+ year: 0, quarter: 1, month: 2, week: 3, day: 4, hour: 5, minute: 6, second: 7,
261
+ };
262
+ function amountField(unit) {
263
+ return `${unit}s`;
264
+ }
265
+ /** N unit [半] [M finer-unit …]: 3年半, 1年3か月, 1時間30分, 半年, 数日間. */
266
+ function readAmount(tokens, i) {
267
+ let at = i;
268
+ const amount = {};
269
+ let smallest;
270
+ let business = false;
271
+ let bareDay = false;
272
+ let kanjiOneDay = false;
273
+ for (let part = 0; part < 3; part += 1) {
274
+ let n;
275
+ let nc;
276
+ let kanjiOne = false;
277
+ if (w(tokens, at) === '半' && part === 0) {
278
+ n = 0.5;
279
+ nc = 1;
280
+ }
281
+ else {
282
+ const r = readCount(tokens, at);
283
+ if (!r)
284
+ break;
285
+ n = r.value;
286
+ nc = r.consumed;
287
+ kanjiOne = r.consumed === 1 && w(tokens, at) === '一';
288
+ }
289
+ const u = readUnitWord(tokens, at + nc);
290
+ if (!u)
291
+ break;
292
+ if (u.unit === 'year' && n >= 1000)
293
+ break; // "2018年" is a date, not 2018 years
294
+ if (smallest !== undefined && UNIT_RANK[u.unit] <= UNIT_RANK[smallest])
295
+ break;
296
+ let half = 0;
297
+ let hc = 0;
298
+ if (w(tokens, at + nc + u.consumed) === '半' && Number.isInteger(n)) {
299
+ half = 0.5;
300
+ hc = 1;
301
+ }
302
+ amount[amountField(u.unit)] = (amount[amountField(u.unit)] ?? 0) + n + half;
303
+ if (u.business)
304
+ business = true;
305
+ if (u.bareDay === true && u.business !== true) {
306
+ bareDay = true;
307
+ kanjiOneDay = kanjiOne;
308
+ }
309
+ smallest = u.unit;
310
+ at += nc + u.consumed + hc;
311
+ if (half > 0)
312
+ break;
313
+ }
314
+ if (smallest === undefined)
315
+ return undefined;
316
+ return { amount, consumed: at - i, smallest, business, bareDay, kanjiOneDay };
317
+ }
318
+ function isSubDay(unit) {
319
+ return unit === 'hour' || unit === 'minute' || unit === 'second';
320
+ }
321
+ /** Chain signed offsets for each amount field; snap to day for coarse results. */
322
+ function offsetExpr(base, amount, sign, smallest) {
323
+ let expr = base;
324
+ for (const [field, value] of Object.entries(amount)) {
325
+ if (typeof value !== 'number' || value === 0)
326
+ continue;
327
+ const unit = field.slice(0, -1);
328
+ expr = { op: 'offset', base: expr, amount: sign * value, unit };
329
+ }
330
+ return isSubDay(smallest) ? expr : { op: 'snap', base: expr, unit: 'day' };
331
+ }
332
+ function signedAmount(amount, sign) {
333
+ if (sign === 1)
334
+ return amount;
335
+ const out = {};
336
+ for (const [field, value] of Object.entries(amount)) {
337
+ if (typeof value === 'number')
338
+ out[field] = -value;
339
+ }
340
+ return out;
341
+ }
342
+ // --- clock times -----------------------------------------------------------
343
+ const MERIDIEM_PREFIXES = [
344
+ ['午前', 'am'], ['午後', 'pm'], ['朝の', 'am'], ['朝', 'am'],
345
+ ['夜の', 'pm'], ['夜', 'pm'], ['晩の', 'pm'], ['晩', 'pm'],
346
+ ['深夜', 'pm'], ['夕方の', 'pm'], ['夕方', 'pm'],
347
+ ];
348
+ /** [午前|午後|朝|夜…] N時 [半|M分] [S秒] · 正午 · 真夜中. */
349
+ function readClock(tokens, i) {
350
+ {
351
+ const c = seq(tokens, i, '正午');
352
+ if (c !== undefined) {
353
+ // "正午12時" — redundant hour after noon.
354
+ const n = readInt(tokens, i + c);
355
+ const extra = n && n.value === 12 && w(tokens, i + c + n.consumed) === '時' ? n.consumed + 1 : 0;
356
+ return { time: { hour: 12, meridiem: 'pm' }, consumed: c + extra };
357
+ }
358
+ }
359
+ {
360
+ const c = seq(tokens, i, '真夜中');
361
+ if (c !== undefined)
362
+ return { time: { hour: 0 }, consumed: c };
363
+ }
364
+ let at = i;
365
+ let meridiem;
366
+ for (const [prefix, m] of MERIDIEM_PREFIXES) {
367
+ const c = seq(tokens, at, prefix);
368
+ if (c !== undefined) {
369
+ meridiem = m;
370
+ at += c;
371
+ break;
372
+ }
373
+ }
374
+ const h = readInt(tokens, at);
375
+ if (!h || h.value > 23 || w(tokens, at + h.consumed) !== '時')
376
+ return undefined;
377
+ if (w(tokens, at + h.consumed + 1) === '間')
378
+ return undefined; // duration ("1時間")
379
+ at += h.consumed + 1;
380
+ const time = { hour: h.value };
381
+ if (h.value >= 1 && h.value <= 12)
382
+ time.meridiem = meridiem ?? 'unknown';
383
+ if (w(tokens, at) === '半') {
384
+ time.minute = 30;
385
+ at += 1;
386
+ }
387
+ else {
388
+ const m = readInt(tokens, at);
389
+ if (m && m.value <= 59 && w(tokens, at + m.consumed) === '分') {
390
+ time.minute = m.value;
391
+ at += m.consumed + 1;
392
+ }
393
+ }
394
+ const s = readInt(tokens, at);
395
+ if (s && s.value <= 59 && w(tokens, at + s.consumed) === '秒') {
396
+ time.second = s.value;
397
+ if (time.minute === undefined)
398
+ time.minute = 0;
399
+ at += s.consumed + 1;
400
+ }
401
+ return { time, consumed: at - i };
402
+ }
403
+ // --- scoped weeks / nth weekdays -------------------------------------------
404
+ function readScope(tokens, i) {
405
+ const d = readDateCore(tokens, i);
406
+ if (d && d.grain !== 'day') {
407
+ return { expr: { op: 'literal', date: d.date }, unit: d.grain, consumed: d.consumed };
408
+ }
409
+ const du = readDeicticUnit(tokens, i);
410
+ if (du && (du.unit === 'month' || du.unit === 'year')) {
411
+ return { expr: du.expr, unit: du.unit, consumed: du.consumed };
412
+ }
413
+ return undefined;
414
+ }
415
+ function firstWeekOf(scope, unit) {
416
+ return { op: 'snap', base: { op: 'snap', base: scope, unit, edge: 'start' }, unit: 'week' };
417
+ }
418
+ function lastWeekOf(scope, unit) {
419
+ return {
420
+ op: 'snap',
421
+ base: {
422
+ op: 'offset',
423
+ base: { op: 'snap', base: scope, unit, edge: 'end' },
424
+ amount: -4,
425
+ unit: 'day',
426
+ },
427
+ unit: 'week',
428
+ };
429
+ }
430
+ /** "[scopeの]第N週 / 第N月曜日 / 最終月曜日 / 最初の週 / 最後の週 / <date>の週". */
431
+ function readWeekOf(tokens, i) {
432
+ let at = i;
433
+ let scope;
434
+ const s = readScope(tokens, i);
435
+ if (s) {
436
+ const after = i + s.consumed;
437
+ if (w(tokens, after) === 'の') {
438
+ scope = s;
439
+ at = after + 1;
440
+ }
441
+ else if (w(tokens, after) === '最' || w(tokens, after) === '第') {
442
+ // "四月最後の月曜日" — the の after the scope is optional.
443
+ scope = s;
444
+ at = after;
445
+ }
446
+ }
447
+ if (scope) {
448
+ const first = seq(tokens, at, '最初の週');
449
+ if (first !== undefined)
450
+ return { expr: firstWeekOf(scope.expr, scope.unit), consumed: at + first - i };
451
+ const last = seq(tokens, at, '最後の週');
452
+ if (last !== undefined)
453
+ return { expr: lastWeekOf(scope.expr, scope.unit), consumed: at + last - i };
454
+ }
455
+ // 最終月曜日 / 最後の月曜日 (scope required)
456
+ if (scope) {
457
+ for (const lead of ['最終', '最後の']) {
458
+ const c = seq(tokens, at, lead);
459
+ if (c === undefined)
460
+ continue;
461
+ const wd = readWeekdayName(tokens, at + c);
462
+ if (!wd)
463
+ continue;
464
+ return {
465
+ expr: {
466
+ op: 'seek',
467
+ base: { op: 'snap', base: scope.expr, unit: scope.unit, edge: 'end' },
468
+ dir: 'prev',
469
+ target: { kind: 'weekday', weekday: wd.weekday },
470
+ n: 1,
471
+ },
472
+ consumed: at + c + wd.consumed - i,
473
+ };
474
+ }
475
+ }
476
+ // 第N週 / 第N月曜日 (scope optional; weeks default to the current year,
477
+ // nth weekdays to the current month)
478
+ if (w(tokens, at) === '第') {
479
+ const n = readInt(tokens, at + 1);
480
+ if (n && n.value >= 1) {
481
+ const uAt = at + 1 + n.consumed;
482
+ if (w(tokens, uAt) === '週' && n.value <= 53) {
483
+ const base = scope?.expr ?? snapNow('year');
484
+ const baseUnit = scope?.unit ?? 'year';
485
+ // ISO-style week 1: the week containing (period start + 3 days) — the
486
+ // first week with 4+ days inside the period.
487
+ const week1 = {
488
+ op: 'snap',
489
+ base: {
490
+ op: 'offset',
491
+ base: { op: 'snap', base, unit: baseUnit, edge: 'start' },
492
+ amount: 3,
493
+ unit: 'day',
494
+ },
495
+ unit: 'week',
496
+ };
497
+ const shifted = n.value === 1 ? week1 : { op: 'offset', base: week1, amount: n.value - 1, unit: 'week' };
498
+ return { expr: shifted, consumed: uAt + 1 - i };
499
+ }
500
+ const wd = readWeekdayName(tokens, uAt);
501
+ if (wd && n.value <= 5) {
502
+ return {
503
+ expr: {
504
+ op: 'seek',
505
+ base: scope?.expr ?? snapNow('month'),
506
+ dir: 'next',
507
+ target: { kind: 'weekday', weekday: wd.weekday },
508
+ n: n.value,
509
+ },
510
+ consumed: uAt + wd.consumed - i,
511
+ };
512
+ }
513
+ }
514
+ }
515
+ // "<date>の週" — the week containing that date.
516
+ const d = readDateCore(tokens, i);
517
+ if (d && d.grain === 'day') {
518
+ const c = seq(tokens, i + d.consumed, 'の週');
519
+ if (c !== undefined) {
520
+ return {
521
+ expr: { op: 'snap', base: { op: 'literal', date: d.date }, unit: 'week' },
522
+ consumed: d.consumed + c,
523
+ };
524
+ }
525
+ }
526
+ return undefined;
527
+ }
528
+ function readOperand(tokens, i, prev) {
529
+ // Scoped nth/last weekday or week ("4月の最終月曜日").
530
+ const wo = readWeekOf(tokens, i);
531
+ if (wo)
532
+ return { op: { kind: 'date', expr: wo.expr, grain: 'day' }, consumed: wo.consumed };
533
+ // Deictic unit + の + partial date ("来年の2月", "来月の4日").
534
+ const du = readDeicticUnit(tokens, i);
535
+ if (du) {
536
+ const at = i + du.consumed;
537
+ if (w(tokens, at) === 'の') {
538
+ const inner = readDateCore(tokens, at + 1);
539
+ if (inner && inner.date.year === undefined) {
540
+ return {
541
+ op: {
542
+ kind: 'date',
543
+ expr: { op: 'intersect', parts: [du.expr, { op: 'literal', date: inner.date }] },
544
+ grain: inner.grain,
545
+ date: inner.date,
546
+ },
547
+ consumed: at + 1 + inner.consumed - i,
548
+ };
549
+ }
550
+ }
551
+ return { op: { kind: 'date', expr: du.expr, grain: du.unit === 'year' ? 'year' : 'month' }, consumed: du.consumed };
552
+ }
553
+ // Deictic day, optionally with a time ("昨日午後2時", "明日四時").
554
+ const dd = readDeicticDay(tokens, i);
555
+ if (dd) {
556
+ let at = i + dd.consumed;
557
+ if (w(tokens, at) === 'の') {
558
+ const t = readClock(tokens, at + 1);
559
+ if (t)
560
+ return { op: { kind: 'datetime', dateExpr: dd.expr, time: t.time }, consumed: at + 1 + t.consumed - i };
561
+ }
562
+ const t = readClock(tokens, at);
563
+ if (t)
564
+ return { op: { kind: 'datetime', dateExpr: dd.expr, time: t.time }, consumed: at + t.consumed - i };
565
+ return { op: { kind: 'date', expr: dd.expr, grain: 'day' }, consumed: dd.consumed };
566
+ }
567
+ // Date literal, optionally with a time ("1月15日4時", "2015年1月1日の10時30分").
568
+ const d = readDateCore(tokens, i);
569
+ if (d) {
570
+ const date = { ...d.date };
571
+ // Bare day after a month-bearing left side inherits its month/year.
572
+ if (prev?.kind === 'date' && prev.date?.month !== undefined && date.month === undefined && date.day !== undefined) {
573
+ date.month = prev.date.month;
574
+ if (prev.date.year !== undefined)
575
+ date.year = prev.date.year;
576
+ }
577
+ const expr = { op: 'literal', date };
578
+ if (d.grain === 'day') {
579
+ let at = i + d.consumed;
580
+ let sep = 0;
581
+ if (w(tokens, at) === 'の')
582
+ sep = 1;
583
+ const t = readClock(tokens, at + sep);
584
+ if (t)
585
+ return { op: { kind: 'datetime', dateExpr: expr, time: t.time }, consumed: at + sep + t.consumed - i };
586
+ }
587
+ return { op: { kind: 'date', expr, grain: d.grain, date }, consumed: d.consumed };
588
+ }
589
+ // Clock time.
590
+ const t = readClock(tokens, i);
591
+ if (t)
592
+ return { op: { kind: 'time', time: t.time }, consumed: t.consumed };
593
+ return undefined;
594
+ }
595
+ function timeLit(time) {
596
+ return { op: 'literal', time };
597
+ }
598
+ function operandExpr(o) {
599
+ if (o.kind === 'date')
600
+ return o.expr;
601
+ if (o.kind === 'time')
602
+ return timeLit(o.time);
603
+ return { op: 'intersect', parts: [o.dateExpr, timeLit(o.time)] };
604
+ }
605
+ // === rules =================================================================
606
+ /** AからB(まで|の間) ranges over dates, times, and datetimes. */
607
+ const ruleRange = (tokens, i) => {
608
+ const a = readOperand(tokens, i);
609
+ if (!a)
610
+ return undefined;
611
+ let at = i + a.consumed;
612
+ const kara = seq(tokens, at, 'から');
613
+ if (kara === undefined)
614
+ return undefined;
615
+ at += kara;
616
+ const b = readOperand(tokens, at, a.op);
617
+ if (!b)
618
+ return undefined;
619
+ at += b.consumed;
620
+ for (const suffix of ['までの間', 'まで', 'の間']) {
621
+ const c = seq(tokens, at, suffix);
622
+ if (c !== undefined) {
623
+ at += c;
624
+ break;
625
+ }
626
+ }
627
+ const A = a.op;
628
+ const B = b.op;
629
+ let expr;
630
+ let role = 'date';
631
+ const shareMeridiem = (ta, tb) => {
632
+ // "午前10時から12時" — hour 12 stays noon-ish, never 12am.
633
+ if (ta.meridiem !== undefined && ta.meridiem !== 'unknown' && tb.meridiem === 'unknown' && tb.hour !== 12) {
634
+ return { ...tb, meridiem: ta.meridiem };
635
+ }
636
+ return tb;
637
+ };
638
+ // In dated から-ranges an ambiguous hour reads as the small-hours/24h clock
639
+ // ("1月15日4時から2月3日9時" is 04:00 → 09:00).
640
+ const dawnify = (o) => o.kind === 'datetime' && o.time.meridiem === 'unknown'
641
+ ? { ...o, time: { ...o.time, meridiem: 'am' } }
642
+ : o;
643
+ if (A.kind === 'time' && B.kind === 'time') {
644
+ expr = { op: 'between', start: timeLit(A.time), end: timeLit(shareMeridiem(A.time, B.time)) };
645
+ role = 'time';
646
+ }
647
+ else if (A.kind === 'datetime' && B.kind === 'time') {
648
+ expr = {
649
+ op: 'intersect',
650
+ parts: [A.dateExpr, { op: 'between', start: timeLit(A.time), end: timeLit(shareMeridiem(A.time, B.time)) }],
651
+ };
652
+ }
653
+ else if (A.kind === 'datetime' && B.kind === 'datetime') {
654
+ expr = { op: 'between', start: operandExpr(dawnify(A)), end: operandExpr(dawnify(B)) };
655
+ }
656
+ else if (A.kind === 'datetime' || B.kind === 'datetime') {
657
+ expr = { op: 'between', start: operandExpr(A), end: operandExpr(B) };
658
+ }
659
+ else if (A.kind === 'date' && B.kind === 'date') {
660
+ // Scoped bare-day pair with unknown month ("来月の4日から23日"): the day
661
+ // distance is known even though the month is contextual.
662
+ if (B.date !== undefined && B.date.month === undefined && B.date.day !== undefined &&
663
+ A.date === undefined && A.grain === 'day') {
664
+ return undefined;
665
+ }
666
+ if (A.expr.op === 'intersect' && A.date?.day !== undefined &&
667
+ B.date?.month === undefined && B.date?.day !== undefined && B.date.day > A.date.day) {
668
+ expr = { op: 'span', anchor: A.expr, amount: { days: B.date.day - A.date.day } };
669
+ }
670
+ else {
671
+ // Year back-propagation: "11月から2017年2月まで" starts in 2016.
672
+ let start = A.expr;
673
+ if (A.expr.op === 'literal' && A.date?.month !== undefined && A.date.year === undefined &&
674
+ B.date?.month !== undefined && B.date.year !== undefined) {
675
+ const year = A.date.month > B.date.month ? B.date.year - 1 : B.date.year;
676
+ start = { op: 'literal', date: { ...A.date, year } };
677
+ }
678
+ // `between` treats non-point ends as exclusive at their start, which is
679
+ // exactly the まで convention ("22日まで" runs to the 22nd's 00:00).
680
+ expr = { op: 'between', start, end: B.expr };
681
+ }
682
+ }
683
+ else if (A.kind === 'date' && B.kind === 'time') {
684
+ expr = { op: 'between', start: A.expr, end: timeLit(B.time) };
685
+ }
686
+ else if (A.kind === 'time' && B.kind === 'date') {
687
+ return undefined;
688
+ }
689
+ if (!expr)
690
+ return undefined;
691
+ return { expr, consumed: at - i, confidence: 1, role };
692
+ };
693
+ /** Scoped weeks & nth weekdays as standalone matches. */
694
+ const ruleWeekOf = (tokens, i) => {
695
+ const wo = readWeekOf(tokens, i);
696
+ if (!wo)
697
+ return undefined;
698
+ return { expr: wo.expr, consumed: wo.consumed, confidence: 1, role: 'date' };
699
+ };
700
+ /** "<deictic day>[から|の] N units 前/後/以内" — offsets anchored on that day. */
701
+ const ruleRelDayOffset = (tokens, i) => {
702
+ const d = readDeicticDay(tokens, i);
703
+ if (!d)
704
+ return undefined;
705
+ let at = i + d.consumed;
706
+ const kara = seq(tokens, at, 'から');
707
+ if (kara !== undefined)
708
+ at += kara;
709
+ else if (w(tokens, at) === 'の')
710
+ at += 1;
711
+ else
712
+ return undefined;
713
+ const amt = readAmount(tokens, at);
714
+ if (!amt)
715
+ return undefined;
716
+ at += amt.consumed;
717
+ // "明日から3週間" with no 前/後 marker: the amount measures forward from
718
+ // the named day.
719
+ if (kara !== undefined && w(tokens, at) !== '前' && w(tokens, at) !== '後' && seq(tokens, at, '以内') === undefined) {
720
+ return {
721
+ expr: offsetExpr(d.expr, amt.amount, 1, amt.smallest),
722
+ consumed: at - i,
723
+ confidence: 0.9,
724
+ role: 'date',
725
+ };
726
+ }
727
+ const within = seq(tokens, at, '以内');
728
+ if (within !== undefined) {
729
+ return {
730
+ expr: { op: 'span', anchor: d.expr, amount: amt.amount, ...(amt.business ? { business: true } : {}) },
731
+ consumed: at + within - i,
732
+ confidence: 1,
733
+ role: 'date',
734
+ };
735
+ }
736
+ const suffix = w(tokens, at);
737
+ if (suffix !== '前' && suffix !== '後')
738
+ return undefined;
739
+ return {
740
+ expr: offsetExpr(d.expr, amt.amount, suffix === '後' ? 1 : -1, amt.smallest),
741
+ consumed: at + 1 - i,
742
+ confidence: 1,
743
+ role: 'date',
744
+ };
745
+ };
746
+ /** Calendar dates: 2019年1月4日 · 4月22日 · 12日 · 平成13年 · 1990年代 · 15世紀. */
747
+ const ruleDate = (tokens, i) => {
748
+ const n = readInt(tokens, i);
749
+ if (n) {
750
+ const century = seq(tokens, i + n.consumed, '世紀');
751
+ if (century !== undefined && n.value >= 1 && n.value <= 21) {
752
+ return {
753
+ expr: {
754
+ op: 'span',
755
+ anchor: { op: 'literal', date: { year: (n.value - 1) * 100 } },
756
+ amount: { years: 100 },
757
+ },
758
+ consumed: n.consumed + century,
759
+ confidence: 1,
760
+ role: 'date',
761
+ };
762
+ }
763
+ const decade = seq(tokens, i + n.consumed, '年代');
764
+ if (decade !== undefined && n.value >= 1000 && n.value <= 2999 && n.value % 10 === 0) {
765
+ return {
766
+ expr: { op: 'span', anchor: { op: 'literal', date: { year: n.value } }, amount: { years: 10 } },
767
+ consumed: n.consumed + decade,
768
+ confidence: 1,
769
+ role: 'date',
770
+ };
771
+ }
772
+ }
773
+ const d = readDateCore(tokens, i);
774
+ if (!d)
775
+ return undefined;
776
+ return { expr: { op: 'literal', date: d.date }, consumed: d.consumed, confidence: 1, role: 'date' };
777
+ };
778
+ /** N-unit amounts: durations, ago/in offsets, and windows (今後/過去/ここ…). */
779
+ const ruleNUnit = (tokens, i) => {
780
+ let at = i;
781
+ let mode;
782
+ let afterMode = false;
783
+ let forceDuration = false;
784
+ const prefixes = [
785
+ ['今後', () => { mode = 1; }],
786
+ ['過去の', () => { mode = -1; }],
787
+ ['過去', () => { mode = -1; }],
788
+ ['これから', () => { mode = 1; }],
789
+ ['ここ', () => { mode = -1; }],
790
+ ['この', () => { mode = -1; }],
791
+ ['向こう', () => { mode = 1; }],
792
+ ['次の', () => { mode = 1; }],
793
+ ['前の', () => { mode = -1; }],
794
+ ['まる', () => { forceDuration = true; }],
795
+ ['後', () => { afterMode = true; }],
796
+ ];
797
+ for (const [prefix, apply] of prefixes) {
798
+ const c = seq(tokens, at, prefix);
799
+ if (c !== undefined) {
800
+ apply();
801
+ at += c;
802
+ break;
803
+ }
804
+ }
805
+ const amt = readAmount(tokens, at);
806
+ if (!amt)
807
+ return undefined;
808
+ at += amt.consumed;
809
+ const spanExpr = (sign) => ({
810
+ op: 'span',
811
+ anchor: NOW,
812
+ amount: signedAmount(amt.amount, sign),
813
+ ...(amt.business ? { business: true } : {}),
814
+ });
815
+ const role = isSubDay(amt.smallest) ? 'datetime' : 'date';
816
+ if (afterMode) {
817
+ // 後N分で = in N minutes (point); 後N分間 = the coming N minutes (window).
818
+ if (w(tokens, at) === 'で') {
819
+ return { expr: offsetExpr(NOW, amt.amount, 1, amt.smallest), consumed: at + 1 - i, confidence: 1, role };
820
+ }
821
+ return { expr: spanExpr(1), consumed: at - i, confidence: 1, role };
822
+ }
823
+ if (mode !== undefined) {
824
+ return { expr: spanExpr(mode), consumed: at - i, confidence: 1, role };
825
+ }
826
+ const within = seq(tokens, at, '以内');
827
+ if (within !== undefined) {
828
+ return { expr: spanExpr(1), consumed: at + within - i, confidence: 1, role };
829
+ }
830
+ const orMore = seq(tokens, at, '以上');
831
+ const suffix = w(tokens, at);
832
+ if (orMore === undefined && (suffix === '前' || suffix === '後' || suffix === 'で')) {
833
+ const sign = suffix === '前' ? -1 : 1;
834
+ return {
835
+ expr: offsetExpr(NOW, amt.amount, sign, amt.smallest),
836
+ consumed: at + 1 - i,
837
+ confidence: 1,
838
+ role: 'datetime',
839
+ };
840
+ }
841
+ // Plain duration. Bare "N日" reads as a calendar day-of-month, except the
842
+ // idiomatic 一日 / まる1日 ("a whole day").
843
+ if (amt.bareDay && !forceDuration && !amt.kanjiOneDay)
844
+ return undefined;
845
+ return {
846
+ expr: { op: 'amount', amount: amt.amount },
847
+ consumed: (orMore !== undefined ? at + orMore : at) - i,
848
+ confidence: 0.95,
849
+ role: 'duration',
850
+ };
851
+ };
852
+ /** Halves & edges: 年末 · 今月初め · 2018年の前半 · 今週の終わり · 11月半ば. */
853
+ const rulePartOf = (tokens, i) => {
854
+ // 年初来 — year-to-date.
855
+ const ytd = seq(tokens, i, '年初来');
856
+ if (ytd !== undefined) {
857
+ return {
858
+ expr: {
859
+ op: 'between',
860
+ start: { op: 'snap', base: NOW, unit: 'year', edge: 'start' },
861
+ end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
862
+ },
863
+ consumed: ytd,
864
+ confidence: 1,
865
+ role: 'date',
866
+ };
867
+ }
868
+ let base;
869
+ const du = readDeicticUnit(tokens, i);
870
+ if (du)
871
+ base = du;
872
+ if (!base) {
873
+ const one = seq(tokens, i, '一日');
874
+ if (one !== undefined)
875
+ base = { expr: snapNow('day'), unit: 'day', consumed: one };
876
+ }
877
+ if (!base) {
878
+ const d = readDateCore(tokens, i);
879
+ if (d && d.grain !== 'day') {
880
+ base = { expr: { op: 'literal', date: d.date }, unit: d.grain, consumed: d.consumed };
881
+ }
882
+ }
883
+ if (!base) {
884
+ const wd = readWeekdayName(tokens, i);
885
+ if (wd) {
886
+ base = {
887
+ expr: { op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: wd.weekday } },
888
+ unit: 'day',
889
+ consumed: wd.consumed,
890
+ };
891
+ }
892
+ }
893
+ if (!base) {
894
+ const bare = w(tokens, i);
895
+ if (bare === '年')
896
+ base = { expr: snapNow('year'), unit: 'year', consumed: 1 };
897
+ else if (bare === '月')
898
+ base = { expr: snapNow('month'), unit: 'month', consumed: 1 };
899
+ else if (bare === '週')
900
+ base = { expr: snapNow('week'), unit: 'week', consumed: 1 };
901
+ }
902
+ if (!base)
903
+ return undefined;
904
+ let at = i + base.consumed;
905
+ if (w(tokens, at) === 'の')
906
+ at += 1;
907
+ const startPoint = { op: 'snap', base: base.expr, unit: base.unit, edge: 'start' };
908
+ const endPoint = { op: 'snap', base: base.expr, unit: base.unit, edge: 'end' };
909
+ const midPoint = base.unit === 'year'
910
+ ? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
911
+ : base.unit === 'month'
912
+ ? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
913
+ : base.unit === 'week'
914
+ ? { op: 'offset', base: startPoint, amount: 3, unit: 'day' }
915
+ : { op: 'offset', base: startPoint, amount: 12, unit: 'hour' };
916
+ const firstHalf = { op: 'between', start: startPoint, end: midPoint };
917
+ const secondHalf = { op: 'between', start: midPoint, end: endPoint };
918
+ const suffixes = [
919
+ ['早い時間', () => (base.unit === 'day' ? firstHalf : undefined)],
920
+ ['早く', () => firstHalf],
921
+ ['終わりごろ', () => (base.unit === 'day' ? { op: 'snap', base: base.expr, unit: 'day', edge: 'end' } : secondHalf)],
922
+ ['終わり', () => (base.unit === 'day' ? { op: 'snap', base: base.expr, unit: 'day', edge: 'end' } : secondHalf)],
923
+ ['初めごろ', () => firstHalf],
924
+ ['初め', () => firstHalf],
925
+ ['始め', () => firstHalf],
926
+ ['前半', () => firstHalf],
927
+ ['後半', () => secondHalf],
928
+ ['半ば', () => ({ ...base.expr, mod: 'mid' })],
929
+ ['末', () => (base.unit === 'week' || base.unit === 'day' ? undefined : secondHalf)],
930
+ ];
931
+ for (const [suffix, build] of suffixes) {
932
+ const c = seq(tokens, at, suffix);
933
+ if (c === undefined)
934
+ continue;
935
+ const expr = build();
936
+ if (!expr)
937
+ return undefined;
938
+ return { expr, consumed: at + c - i, confidence: 1, role: 'date' };
939
+ }
940
+ return undefined;
941
+ };
942
+ /** Deictic days/weeks/months/years and day-period compounds (今晩, 昨夜…). */
943
+ const ruleDeictic = (tokens, i) => {
944
+ const specials = [
945
+ ['明日中', { op: 'snap', base: snapOffset(1, 'day'), unit: 'day', edge: 'end' }, 'date'],
946
+ ['今日中', { op: 'snap', base: snapNow('day'), unit: 'day', edge: 'end' }, 'date'],
947
+ ['今朝', dayPeriodOn(0, 'morning'), 'datetime'],
948
+ ['今晩', dayPeriodOn(0, 'evening'), 'datetime'],
949
+ ['今夜', dayPeriodOn(0, 'night'), 'datetime'],
950
+ ['昨晩', dayPeriodOn(-1, 'evening'), 'datetime'],
951
+ ['昨夜', dayPeriodOn(-1, 'night'), 'datetime'],
952
+ ['明晩', dayPeriodOn(1, 'evening'), 'datetime'],
953
+ ['ひと月', snapNow('month'), 'date'],
954
+ ['最近', snapNow('day'), 'date'],
955
+ ['現在', NOW, 'datetime'],
956
+ ];
957
+ for (const [s, expr, role] of specials) {
958
+ const c = seq(tokens, i, s);
959
+ if (c !== undefined)
960
+ return { expr, consumed: c, confidence: 1, role };
961
+ }
962
+ const dd = readDeicticDay(tokens, i);
963
+ if (dd)
964
+ return { expr: dd.expr, consumed: dd.consumed, confidence: 1, role: 'date' };
965
+ const du = readDeicticUnit(tokens, i);
966
+ if (du)
967
+ return { expr: du.expr, consumed: du.consumed, confidence: 1, role: 'date' };
968
+ // 年間/月間/週間 as "this year/month/week" nouns — but never after a number
969
+ // ("5年間" is a duration).
970
+ const prev = tokens[i - 1];
971
+ const prevNumeric = prev?.type === 'number' ||
972
+ (prev?.type === 'word' &&
973
+ (KDIGIT[prev.value] !== undefined || KMULT[prev.value] !== undefined || prev.value === '数' || prev.value === '半'));
974
+ if (!prevNumeric) {
975
+ for (const [s, unit] of [['年間', 'year'], ['月間', 'month'], ['週間', 'week']]) {
976
+ const c = seq(tokens, i, s);
977
+ if (c !== undefined)
978
+ return { expr: snapNow(unit), consumed: c, confidence: 0.8, role: 'date' };
979
+ }
980
+ }
981
+ if (w(tokens, i) === '今')
982
+ return { expr: NOW, consumed: 1, confidence: 0.9, role: 'datetime' };
983
+ return undefined;
984
+ };
985
+ /** 週末 (weekend), optionally prefixed: 今週末 / 来週末 / 先週末 / その週末. */
986
+ const ruleWeekend = (tokens, i) => {
987
+ let at = i;
988
+ let dir = 'nearest';
989
+ // 今週末 = 今 + 週末 (single-char prefix), unlike 今週の月曜日.
990
+ for (const [prefix, d] of [['来', 'next'], ['先', 'prev'], ['今', 'nearest'], ['この', 'nearest'], ['その', 'nearest']]) {
991
+ const c = seq(tokens, at, prefix);
992
+ if (c !== undefined) {
993
+ dir = d;
994
+ at += c;
995
+ break;
996
+ }
997
+ }
998
+ const wk = seq(tokens, at, '週末');
999
+ if (wk === undefined)
1000
+ return undefined;
1001
+ return {
1002
+ expr: {
1003
+ op: 'span',
1004
+ anchor: { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: 'sat' } },
1005
+ amount: { days: 2 },
1006
+ },
1007
+ consumed: at + wk - i,
1008
+ confidence: 1,
1009
+ role: 'date',
1010
+ };
1011
+ };
1012
+ /** Weekdays: 月曜日 · 来週の月曜日 · 次の金曜日 · 日曜日の終わり. */
1013
+ const ruleWeekday = (tokens, i) => {
1014
+ let at = i;
1015
+ let base;
1016
+ let dir = 'nearest';
1017
+ const weekPrefixes = [
1018
+ ['再来週', snapOffset(2, 'week')],
1019
+ ['来週', snapOffset(1, 'week')],
1020
+ ['今週', snapNow('week')],
1021
+ ['先週', snapOffset(-1, 'week')],
1022
+ ['翌週', snapOffset(1, 'week')],
1023
+ ];
1024
+ for (const [prefix, expr] of weekPrefixes) {
1025
+ const c = seq(tokens, at, prefix);
1026
+ if (c !== undefined) {
1027
+ base = expr;
1028
+ at += c;
1029
+ break;
1030
+ }
1031
+ }
1032
+ if (!base) {
1033
+ const next = seq(tokens, at, '次の');
1034
+ if (next !== undefined) {
1035
+ dir = 'next';
1036
+ at += next;
1037
+ }
1038
+ else {
1039
+ const prevSeq = seq(tokens, at, '前の');
1040
+ if (prevSeq !== undefined) {
1041
+ dir = 'prev';
1042
+ at += prevSeq;
1043
+ }
1044
+ else {
1045
+ const sono = seq(tokens, at, 'その');
1046
+ if (sono !== undefined)
1047
+ at += sono;
1048
+ }
1049
+ }
1050
+ }
1051
+ if (base && w(tokens, at) === 'の')
1052
+ at += 1;
1053
+ const wd = readWeekdayName(tokens, at);
1054
+ if (!wd)
1055
+ return undefined;
1056
+ at += wd.consumed;
1057
+ let expr = base
1058
+ ? { op: 'seek', base, dir: 'next', target: { kind: 'weekday', weekday: wd.weekday }, n: 1 }
1059
+ : { op: 'seek', base: NOW, dir, target: { kind: 'weekday', weekday: wd.weekday } };
1060
+ for (const suffix of ['の終わりごろ', 'の終わり']) {
1061
+ const c = seq(tokens, at, suffix);
1062
+ if (c !== undefined) {
1063
+ expr = { op: 'snap', base: expr, unit: 'day', edge: 'end' };
1064
+ at += c;
1065
+ break;
1066
+ }
1067
+ }
1068
+ return { expr, consumed: at - i, confidence: 1, role: 'date' };
1069
+ };
1070
+ /** Clock times: 午後3時 · 7時半 · 17時49分19秒 · 正午 · 真夜中 · 零時. */
1071
+ const ruleClock = (tokens, i) => {
1072
+ const t = readClock(tokens, i);
1073
+ if (!t)
1074
+ return undefined;
1075
+ return { expr: timeLit(t.time), consumed: t.consumed, confidence: 1, role: 'time' };
1076
+ };
1077
+ /** Day periods: 午前中 · 午前 · 午後 · 朝 · 夕方 · 夜 · 晩. */
1078
+ const rulePeriod = (tokens, i) => {
1079
+ const periods = [
1080
+ ['午前中', 'morning'], ['午前', 'morning'], ['午後', 'afternoon'],
1081
+ ['朝', 'morning'], ['夕方', 'evening'], ['深夜', 'night'],
1082
+ ['夜', 'night'], ['晩', 'evening'],
1083
+ ];
1084
+ for (const [s, period] of periods) {
1085
+ const c = seq(tokens, i, s);
1086
+ if (c !== undefined) {
1087
+ return { expr: { op: 'literal', dayPeriod: period }, consumed: c, confidence: 0.7, role: 'time' };
1088
+ }
1089
+ }
1090
+ return undefined;
1091
+ };
1092
+ /** Fixed dates & other one-offs. */
1093
+ const ruleMisc = (tokens, i) => {
1094
+ const entries = [
1095
+ ['独立記念日', { op: 'holiday', name: 'independence-day' }, 'date'],
1096
+ ['大晦日', { op: 'literal', date: { month: 12, day: 31 } }, 'date'],
1097
+ ['元旦', { op: 'literal', date: { month: 1, day: 1 } }, 'date'],
1098
+ ['元日', { op: 'literal', date: { month: 1, day: 1 } }, 'date'],
1099
+ ['別の日', { op: 'amount', amount: { days: 1 } }, 'duration'],
1100
+ ];
1101
+ for (const [s, expr, role] of entries) {
1102
+ const c = seq(tokens, i, s);
1103
+ if (c !== undefined)
1104
+ return { expr, consumed: c, confidence: 1, role };
1105
+ }
1106
+ return undefined;
1107
+ };
1108
+ /** Numeric date tokens: 29/2 · 29/2/2020 (day-first). */
1109
+ const ruleNumdate = (tokens, i, ctx) => {
1110
+ const t = tokens[i];
1111
+ if (t?.type !== 'numdate')
1112
+ return undefined;
1113
+ const p = t.parts;
1114
+ let date;
1115
+ if (p.length === 3) {
1116
+ if (p[0] > 31)
1117
+ date = { year: p[0], month: p[1], day: p[2] };
1118
+ else if (p[2] > 31) {
1119
+ date = p[0] > 12 ? { day: p[0], month: p[1], year: p[2] } : ctx.dateOrder === 'MDY'
1120
+ ? { month: p[0], day: p[1], year: p[2] }
1121
+ : { day: p[0], month: p[1], year: p[2] };
1122
+ }
1123
+ }
1124
+ else if (p.length === 2) {
1125
+ date = p[0] > 12 ? { day: p[0], month: p[1] } : { month: p[0], day: p[1] };
1126
+ }
1127
+ if (!date || (date.month !== undefined && (date.month < 1 || date.month > 12)))
1128
+ return undefined;
1129
+ return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.9, role: 'date' };
1130
+ };
1131
+ /** ASCII compounds the tokenizer keeps whole: 17:55:23-18:33:02, 2019/2/28-2019/3/1, 2/28-3/1. */
1132
+ const ruleAsciiRange = (tokens, i) => {
1133
+ const v = w(tokens, i);
1134
+ if (v === undefined)
1135
+ return undefined;
1136
+ let m = v.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?-(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
1137
+ if (m) {
1138
+ const mk = (h, min, s) => {
1139
+ const time = { hour: Number(h), minute: Number(min) };
1140
+ if (s !== undefined)
1141
+ time.second = Number(s);
1142
+ if (time.hour >= 1 && time.hour <= 12)
1143
+ time.meridiem = 'unknown';
1144
+ return timeLit(time);
1145
+ };
1146
+ return {
1147
+ expr: { op: 'between', start: mk(m[1], m[2], m[3]), end: mk(m[4], m[5], m[6]) },
1148
+ consumed: 1,
1149
+ confidence: 1,
1150
+ role: 'time',
1151
+ };
1152
+ }
1153
+ m = v.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})-(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
1154
+ if (m) {
1155
+ return {
1156
+ expr: {
1157
+ op: 'between',
1158
+ start: { op: 'literal', date: { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) } },
1159
+ end: { op: 'literal', date: { year: Number(m[4]), month: Number(m[5]), day: Number(m[6]) } },
1160
+ },
1161
+ consumed: 1,
1162
+ confidence: 1,
1163
+ role: 'date',
1164
+ };
1165
+ }
1166
+ m = v.match(/^(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})$/);
1167
+ if (m) {
1168
+ const mk = (a, b) => (a > 12 ? { day: a, month: b } : { month: a, day: b });
1169
+ return {
1170
+ expr: {
1171
+ op: 'between',
1172
+ start: { op: 'literal', date: mk(Number(m[1]), Number(m[2])) },
1173
+ end: { op: 'literal', date: mk(Number(m[3]), Number(m[4])) },
1174
+ },
1175
+ consumed: 1,
1176
+ confidence: 1,
1177
+ role: 'date',
1178
+ };
1179
+ }
1180
+ return undefined;
1181
+ };
1182
+ /** A lone 4-digit year ("2019"). */
1183
+ const ruleBareYear = (tokens, i) => {
1184
+ const t = tokens[i];
1185
+ if (t?.type !== 'number' || t.ordinal || t.value < 1500 || t.value > 2199)
1186
+ return undefined;
1187
+ return { expr: { op: 'literal', date: { year: t.value } }, consumed: 1, confidence: 0.7, role: 'date' };
1188
+ };
1189
+ export const JA_RULE_ENTRIES = [
1190
+ { name: 'ja-range', rule: ruleRange },
1191
+ { name: 'ja-week-of', rule: ruleWeekOf },
1192
+ { name: 'ja-rel-day-offset', rule: ruleRelDayOffset },
1193
+ { name: 'ja-date', rule: ruleDate },
1194
+ { name: 'ja-n-unit', rule: ruleNUnit },
1195
+ { name: 'ja-part-of', rule: rulePartOf },
1196
+ { name: 'ja-deictic', rule: ruleDeictic },
1197
+ { name: 'ja-weekend', rule: ruleWeekend },
1198
+ { name: 'ja-weekday', rule: ruleWeekday },
1199
+ { name: 'ja-clock', rule: ruleClock },
1200
+ { name: 'ja-period', rule: rulePeriod },
1201
+ { name: 'ja-misc', rule: ruleMisc },
1202
+ { name: 'ja-numdate', rule: ruleNumdate },
1203
+ { name: 'ja-ascii-range', rule: ruleAsciiRange },
1204
+ { name: 'ja-bare-year', rule: ruleBareYear },
1205
+ ];
1206
+ /** Particles transparent between merged date & time parts ("明日の朝8時"). */
1207
+ export const JA_CONNECTORS = ['の'];