nl2time 0.3.0 → 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.
- package/README.md +8 -2
- package/dist/engine/resolve.js +50 -14
- package/dist/parse/en.js +203 -5
- package/dist/parse/index.js +21 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,11 +114,17 @@ Every imported conformance case carries machine-readable provenance (upstream pr
|
|
|
114
114
|
|
|
115
115
|
## Python
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
[](https://pypi.org/project/nl2time/)
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
pip install nl2time
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The Python engine port lives in [`python/`](python/) (`whenever`-based time model): IR validation, `TimeContext`, and the full deterministic resolver at **100% bit-exact parity** with the JS reference across all 2,760 engine-parity fixtures (`corpus/ir/`), enforced in CI on every push. Parsers and `describe()` are JS-only so far — [docs/porting.md](docs/porting.md) has the strategy and the divergence gates.
|
|
118
124
|
|
|
119
125
|
## Release history
|
|
120
126
|
|
|
121
|
-
See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.0 (multilingual parsing).
|
|
127
|
+
See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.0 (JS, multilingual parsing) · py-v0.1.0 (Python engine). Releases publish to npm/PyPI automatically via OIDC trusted publishing — see [docs/RELEASING.md](docs/RELEASING.md).
|
|
122
128
|
|
|
123
129
|
## Development
|
|
124
130
|
|
package/dist/engine/resolve.js
CHANGED
|
@@ -67,9 +67,13 @@ function evalExpr(expr, ctx, anchor) {
|
|
|
67
67
|
// inside the interval it tightens the bound ("earlier this year" ends
|
|
68
68
|
// today, not at midyear).
|
|
69
69
|
if (expr.mod === 'start' || expr.mod === 'end') {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const c of cands) {
|
|
72
|
+
if (c.type !== 'interval' || c.zi.grain === 'instant') {
|
|
73
|
+
out.push(c);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const zi = c.zi;
|
|
73
77
|
const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
|
|
74
78
|
let mid = zi.start.add({ milliseconds: Math.floor(totalMs / 2) });
|
|
75
79
|
const gi = GRAIN_ORDER.indexOf(zi.grain);
|
|
@@ -78,18 +82,25 @@ function evalExpr(expr, ctx, anchor) {
|
|
|
78
82
|
else if (gi > GRAIN_ORDER.indexOf('day'))
|
|
79
83
|
mid = floorTo(mid, 'day', ctx.weekStart);
|
|
80
84
|
let bound = mid;
|
|
85
|
+
let altHalf = false;
|
|
81
86
|
if (gi > GRAIN_ORDER.indexOf('day')) {
|
|
82
87
|
const refDay = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
|
|
83
88
|
const inRange = Temporal_compare(refDay, zi.start) > 0 && Temporal_compare(refDay, zi.end) < 0;
|
|
84
89
|
if (inRange) {
|
|
90
|
+
// 'start' ("earlier this week/year") ends at the start of the
|
|
91
|
+
// reference day: on a Thursday, "earlier this week" reaches
|
|
92
|
+
// through Wednesday, not just past the midpoint (#20). When the
|
|
93
|
+
// reference sits beyond the midpoint the plain first half stays
|
|
94
|
+
// as an alternative (the Recognizers corpus pins it for "earlier
|
|
95
|
+
// this month"). 'end' ("later this week") starts at the
|
|
96
|
+
// reference day only once the midpoint has passed.
|
|
85
97
|
bound =
|
|
86
98
|
expr.mod === 'start'
|
|
87
|
-
?
|
|
88
|
-
? refDay
|
|
89
|
-
: mid
|
|
99
|
+
? refDay
|
|
90
100
|
: Temporal_compare(refDay, mid) > 0
|
|
91
101
|
? refDay
|
|
92
102
|
: mid;
|
|
103
|
+
altHalf = expr.mod === 'start' && Temporal_compare(refDay, mid) > 0;
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
const narrowed = expr.mod === 'start'
|
|
@@ -97,12 +108,20 @@ function evalExpr(expr, ctx, anchor) {
|
|
|
97
108
|
: { start: bound, end: zi.end, grain: zi.grain };
|
|
98
109
|
// A degenerate clamp (bound at an edge) falls back to the plain half.
|
|
99
110
|
if (Temporal_compare(narrowed.start, narrowed.end) >= 0) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:
|
|
111
|
+
out.push({
|
|
112
|
+
type: 'interval',
|
|
113
|
+
zi: expr.mod === 'start'
|
|
114
|
+
? { start: zi.start, end: mid, grain: zi.grain }
|
|
115
|
+
: { start: mid, end: zi.end, grain: zi.grain },
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
103
118
|
}
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
out.push({ type: 'interval', zi: narrowed });
|
|
120
|
+
if (altHalf && Temporal_compare(zi.start, mid) < 0) {
|
|
121
|
+
out.push({ type: 'interval', zi: { start: zi.start, end: mid, grain: zi.grain } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
106
125
|
}
|
|
107
126
|
// mod 'mid': the middle stretch. Conventions follow common usage: mid-month
|
|
108
127
|
// = the 10th through the 20th; mid-day = 10:00–14:00; otherwise the middle
|
|
@@ -159,10 +178,27 @@ function evalExprInner(expr, ctx, anchor) {
|
|
|
159
178
|
for (const e of evalExpr(expr.end, ctx, s.zi)) {
|
|
160
179
|
if (e.type !== 'interval')
|
|
161
180
|
continue;
|
|
162
|
-
|
|
163
|
-
// ends at May 7 00:00); point ends are used directly.
|
|
164
|
-
let endPoint = Temporal_compare(e.zi.start, e.zi.end) === 0 ? e.zi.end : e.zi.start;
|
|
181
|
+
const isPoint = Temporal_compare(e.zi.start, e.zi.end) === 0;
|
|
165
182
|
const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day');
|
|
183
|
+
// Day-grain-or-coarser ends are conversationally inclusive:
|
|
184
|
+
// "between July 4th and July 10th" covers the 10th. Emit the
|
|
185
|
+
// inclusive reading (end operand contributes its END) first, with
|
|
186
|
+
// the strict exclusive-at-start reading as an alternative — the
|
|
187
|
+
// Recognizers-derived corpus pins the exclusive value, so both
|
|
188
|
+
// must remain candidates (#17).
|
|
189
|
+
if (!isPoint && !timeGrained) {
|
|
190
|
+
const grain = finerGrain(s.zi.grain, e.zi.grain);
|
|
191
|
+
if (Temporal_compare(s.zi.start, e.zi.end) < 0) {
|
|
192
|
+
out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.end, grain } });
|
|
193
|
+
}
|
|
194
|
+
if (Temporal_compare(s.zi.start, e.zi.start) < 0) {
|
|
195
|
+
out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.start, grain } });
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Non-point time ends are exclusive at their *start* ("9 to 5pm"
|
|
200
|
+
// ends at 17:00); point ends are used directly.
|
|
201
|
+
let endPoint = isPoint ? e.zi.end : e.zi.start;
|
|
166
202
|
if (Temporal_compare(s.zi.start, endPoint) >= 0) {
|
|
167
203
|
// A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
|
|
168
204
|
// the end into the next day; date ranges just drop.
|
package/dist/parse/en.js
CHANGED
|
@@ -610,6 +610,51 @@ function buildRange(a, b, scope, period, connWord) {
|
|
|
610
610
|
}
|
|
611
611
|
return undefined;
|
|
612
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* Open-range connectors (#18): "since X" → [start(X), now]; "until X" /
|
|
615
|
+
* "till X" / "through X" / "up to X" / "by X" (deadline) → [now, end(X)].
|
|
616
|
+
* These only fire when the connector opens the range — "A until B" and
|
|
617
|
+
* "from A through B" are consumed earlier by the range rule.
|
|
618
|
+
*/
|
|
619
|
+
const ruleOpenRange = (tokens, i, ctx) => {
|
|
620
|
+
const w = word(tokens[i]);
|
|
621
|
+
let mode;
|
|
622
|
+
let at = i + 1;
|
|
623
|
+
if (w === 'since')
|
|
624
|
+
mode = 'since';
|
|
625
|
+
else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by') {
|
|
626
|
+
mode = 'until';
|
|
627
|
+
}
|
|
628
|
+
else if (w === 'up' && word(tokens[i + 1]) === 'to') {
|
|
629
|
+
mode = 'until';
|
|
630
|
+
at = i + 2;
|
|
631
|
+
}
|
|
632
|
+
if (!mode)
|
|
633
|
+
return undefined;
|
|
634
|
+
// "by the 15th": a bare article only before an explicit calendar date —
|
|
635
|
+
// NOT "through the week"-style prose, which stays unmatched.
|
|
636
|
+
if (word(tokens[at]) === 'the' && ruleCalendarDate(tokens, at + 1, ctx))
|
|
637
|
+
at += 1;
|
|
638
|
+
const inner = ruleDeicticDay(tokens, at, ctx) ??
|
|
639
|
+
ruleLastThisNext(tokens, at, ctx) ??
|
|
640
|
+
ruleHoliday(tokens, at, ctx) ??
|
|
641
|
+
ruleEndOf(tokens, at, ctx) ??
|
|
642
|
+
ruleWeekOf(tokens, at, ctx) ??
|
|
643
|
+
ruleBareYear(tokens, at, ctx) ??
|
|
644
|
+
ruleCalendarDate(tokens, at, ctx) ??
|
|
645
|
+
ruleClockTime(tokens, at, ctx) ??
|
|
646
|
+
ruleWeekdayAlone(tokens, at, ctx);
|
|
647
|
+
if (!inner || inner.role === 'duration')
|
|
648
|
+
return undefined;
|
|
649
|
+
// Boundary points ("by EOD", "by the end of the month") keep their point
|
|
650
|
+
// reading — the deadline instant is the answer there, not a range.
|
|
651
|
+
if (inner.expr.op === 'snap' && inner.expr.edge !== undefined)
|
|
652
|
+
return undefined;
|
|
653
|
+
const expr = mode === 'since'
|
|
654
|
+
? { op: 'between', start: inner.expr, end: NOW }
|
|
655
|
+
: { op: 'between', start: NOW, end: inner.expr };
|
|
656
|
+
return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
|
|
657
|
+
};
|
|
613
658
|
/** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
|
|
614
659
|
const ruleWeekOf = (tokens, i, ctx) => {
|
|
615
660
|
let at = i;
|
|
@@ -915,6 +960,88 @@ const ruleEndOf = (tokens, i, ctx) => {
|
|
|
915
960
|
: { ...inner.expr, mod: isEnd ? 'end' : isMid ? 'mid' : 'start' };
|
|
916
961
|
return { expr, consumed: at + inner.consumed - i, confidence: 0.95, role: 'date' };
|
|
917
962
|
};
|
|
963
|
+
/** The period unit a half-of scope denotes, if determinable. */
|
|
964
|
+
function halfScopeUnit(expr) {
|
|
965
|
+
if (expr.op === 'snap') {
|
|
966
|
+
return expr.edge === undefined &&
|
|
967
|
+
(expr.unit === 'week' || expr.unit === 'month' || expr.unit === 'quarter' || expr.unit === 'year')
|
|
968
|
+
? expr.unit
|
|
969
|
+
: undefined;
|
|
970
|
+
}
|
|
971
|
+
if (expr.op === 'literal' && expr.date !== undefined && expr.time === undefined) {
|
|
972
|
+
if (expr.date.day !== undefined)
|
|
973
|
+
return undefined;
|
|
974
|
+
return expr.date.month !== undefined ? 'month' : 'year';
|
|
975
|
+
}
|
|
976
|
+
if (expr.op === 'seek' && expr.target.kind === 'month')
|
|
977
|
+
return 'month';
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|
|
980
|
+
/** "the first/second half of <period>" → [start, mid) / [mid, end) (#24). */
|
|
981
|
+
const ruleHalfOf = (tokens, i, ctx) => {
|
|
982
|
+
let at = i;
|
|
983
|
+
if (word(tokens[at]) === 'the')
|
|
984
|
+
at += 1;
|
|
985
|
+
const w = word(tokens[at]);
|
|
986
|
+
const t = tokens[at];
|
|
987
|
+
let which;
|
|
988
|
+
if (w === 'first')
|
|
989
|
+
which = 1;
|
|
990
|
+
else if (w === 'second' || w === 'latter' || w === 'back')
|
|
991
|
+
which = 2;
|
|
992
|
+
else if (t?.type === 'number' && t.ordinal && (t.value === 1 || t.value === 2)) {
|
|
993
|
+
which = t.value;
|
|
994
|
+
}
|
|
995
|
+
if (which === undefined)
|
|
996
|
+
return undefined;
|
|
997
|
+
if (word(tokens[at + 1]) !== 'half' || word(tokens[at + 2]) !== 'of')
|
|
998
|
+
return undefined;
|
|
999
|
+
at += 3;
|
|
1000
|
+
if (word(tokens[at]) === 'the')
|
|
1001
|
+
at += 1;
|
|
1002
|
+
// Scope: a bare unit ("the year"), a relative period ("next month"), a
|
|
1003
|
+
// month name, a quarter, or a year.
|
|
1004
|
+
let scope;
|
|
1005
|
+
let unit;
|
|
1006
|
+
let consumed = 0;
|
|
1007
|
+
const uw = word(tokens[at]) !== 'night' ? UNIT_WORDS[word(tokens[at]) ?? ''] : undefined;
|
|
1008
|
+
if (uw === 'week' || uw === 'month' || uw === 'quarter' || uw === 'year') {
|
|
1009
|
+
scope = snapNow(uw);
|
|
1010
|
+
unit = uw;
|
|
1011
|
+
consumed = 1;
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
const inner = ruleLastThisNext(tokens, at, ctx) ??
|
|
1015
|
+
ruleBareYear(tokens, at, ctx) ??
|
|
1016
|
+
ruleCalendarDate(tokens, at, ctx);
|
|
1017
|
+
if (inner && inner.role === 'date') {
|
|
1018
|
+
unit = halfScopeUnit(inner.expr);
|
|
1019
|
+
if (unit) {
|
|
1020
|
+
scope = inner.expr;
|
|
1021
|
+
consumed = inner.consumed;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (!scope || !unit)
|
|
1026
|
+
return undefined;
|
|
1027
|
+
// Fixed split, deliberately not reference-clamped (unlike mod
|
|
1028
|
+
// 'start'/'end'): "the first half of the year" is Jan–Jun regardless of
|
|
1029
|
+
// today. Midpoints: year +6 months, quarter +45 days, month +15 days
|
|
1030
|
+
// (the 16th), week +84 hours.
|
|
1031
|
+
const startPoint = { op: 'snap', base: scope, unit, edge: 'start' };
|
|
1032
|
+
const endPoint = { op: 'snap', base: scope, unit, edge: 'end' };
|
|
1033
|
+
const mid = unit === 'year'
|
|
1034
|
+
? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
|
|
1035
|
+
: unit === 'quarter'
|
|
1036
|
+
? { op: 'offset', base: startPoint, amount: 45, unit: 'day' }
|
|
1037
|
+
: unit === 'month'
|
|
1038
|
+
? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
|
|
1039
|
+
: { op: 'offset', base: startPoint, amount: 84, unit: 'hour' };
|
|
1040
|
+
const expr = which === 1
|
|
1041
|
+
? { op: 'between', start: startPoint, end: mid }
|
|
1042
|
+
: { op: 'between', start: mid, end: endPoint };
|
|
1043
|
+
return { expr, consumed: at + consumed - i, confidence: 1, role: 'date' };
|
|
1044
|
+
};
|
|
918
1045
|
const REL_SYNONYMS = {
|
|
919
1046
|
last: 'last', previous: 'last', prior: 'last',
|
|
920
1047
|
this: 'this', current: 'this', present: 'this', that: 'this',
|
|
@@ -1164,6 +1291,33 @@ const ruleUnitAfterNext = (tokens, i) => {
|
|
|
1164
1291
|
return undefined;
|
|
1165
1292
|
return { expr: snapOffset(2, unit), consumed: 3, confidence: 1, role: 'date' };
|
|
1166
1293
|
};
|
|
1294
|
+
/** "[the] <weekday> before last" → two occurrences back; "the week before last" → two units back (#21). */
|
|
1295
|
+
const ruleBeforeLast = (tokens, i) => {
|
|
1296
|
+
let at = i;
|
|
1297
|
+
if (word(tokens[at]) === 'the')
|
|
1298
|
+
at += 1;
|
|
1299
|
+
const w = word(tokens[at]);
|
|
1300
|
+
if (w === undefined)
|
|
1301
|
+
return undefined;
|
|
1302
|
+
if (word(tokens[at + 1]) !== 'before' || word(tokens[at + 2]) !== 'last')
|
|
1303
|
+
return undefined;
|
|
1304
|
+
const consumed = at + 3 - i;
|
|
1305
|
+
const weekday = WEEKDAY_WORDS[w];
|
|
1306
|
+
if (weekday) {
|
|
1307
|
+
// Strict occurrence counting: the second one strictly in the past.
|
|
1308
|
+
return {
|
|
1309
|
+
expr: { op: 'seek', base: NOW, dir: 'prev', target: { kind: 'weekday', weekday }, n: 2 },
|
|
1310
|
+
consumed,
|
|
1311
|
+
confidence: 1,
|
|
1312
|
+
role: 'date',
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const unit = w !== 'night' ? UNIT_WORDS[w] : undefined;
|
|
1316
|
+
if (unit && unit !== 'second' && unit !== 'minute' && unit !== 'hour') {
|
|
1317
|
+
return { expr: snapOffset(-2, unit), consumed, confidence: 1, role: 'date' };
|
|
1318
|
+
}
|
|
1319
|
+
return undefined;
|
|
1320
|
+
};
|
|
1167
1321
|
/** "last/past/previous N units" → span backwards; "the past week" → trailing 7 days. */
|
|
1168
1322
|
const rulePastN = (tokens, i) => {
|
|
1169
1323
|
const w = word(tokens[i]);
|
|
@@ -1440,16 +1594,57 @@ const ruleWeekendOf = (tokens, i, ctx) => {
|
|
|
1440
1594
|
if (word(tokens[at]) !== 'weekend' || word(tokens[at + 1]) !== 'of')
|
|
1441
1595
|
return undefined;
|
|
1442
1596
|
at += 2;
|
|
1443
|
-
|
|
1597
|
+
// Holidays keep the long-weekend reading: Saturday before through the end
|
|
1598
|
+
// of the holiday ("Memorial Day weekend" = Sat–Mon).
|
|
1599
|
+
const holiday = ruleHoliday(tokens, at, ctx);
|
|
1600
|
+
if (holiday && holiday.role === 'date') {
|
|
1601
|
+
return {
|
|
1602
|
+
expr: {
|
|
1603
|
+
op: 'between',
|
|
1604
|
+
start: { op: 'seek', base: holiday.expr, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
|
|
1605
|
+
end: { op: 'snap', base: holiday.expr, unit: 'day', edge: 'end' },
|
|
1606
|
+
},
|
|
1607
|
+
consumed: at + holiday.consumed - i,
|
|
1608
|
+
confidence: 0.95,
|
|
1609
|
+
role: 'date',
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
if (word(tokens[at]) === 'the')
|
|
1613
|
+
at += 1; // "the weekend of the 18th"
|
|
1614
|
+
const inner = ruleCalendarDate(tokens, at, ctx);
|
|
1444
1615
|
if (!inner || inner.role !== 'date')
|
|
1445
1616
|
return undefined;
|
|
1617
|
+
let consumed = at + inner.consumed - i;
|
|
1618
|
+
// "the weekend of July 18th and 19th": the conjunct names the weekend's
|
|
1619
|
+
// other day — absorb it (#22).
|
|
1620
|
+
const andAt = at + inner.consumed;
|
|
1621
|
+
if (word(tokens[andAt]) === 'and') {
|
|
1622
|
+
const t = tokens[andAt + 1];
|
|
1623
|
+
if (t?.type === 'number' && t.ordinal && t.value >= 1 && t.value <= 31) {
|
|
1624
|
+
consumed = andAt + 2 - i;
|
|
1625
|
+
}
|
|
1626
|
+
else {
|
|
1627
|
+
const ord = readOrdinalDayWord(tokens, andAt + 1);
|
|
1628
|
+
if (ord)
|
|
1629
|
+
consumed = andAt + 1 + ord.consumed - i;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// The Sat–Sun weekend containing the date, or the following one for a
|
|
1633
|
+
// midweek date: the first Saturday on-or-after (date − 1 day), so a
|
|
1634
|
+
// Sunday still maps back to its own weekend (#22).
|
|
1446
1635
|
return {
|
|
1447
1636
|
expr: {
|
|
1448
|
-
op: '
|
|
1449
|
-
|
|
1450
|
-
|
|
1637
|
+
op: 'span',
|
|
1638
|
+
anchor: {
|
|
1639
|
+
op: 'seek',
|
|
1640
|
+
base: { op: 'offset', base: inner.expr, amount: -2, unit: 'day' },
|
|
1641
|
+
dir: 'next',
|
|
1642
|
+
target: { kind: 'weekday', weekday: 'sat' },
|
|
1643
|
+
n: 1,
|
|
1644
|
+
},
|
|
1645
|
+
amount: { days: 2 },
|
|
1451
1646
|
},
|
|
1452
|
-
consumed
|
|
1647
|
+
consumed,
|
|
1453
1648
|
confidence: 0.95,
|
|
1454
1649
|
role: 'date',
|
|
1455
1650
|
};
|
|
@@ -2722,17 +2917,20 @@ const ruleAllUnit = (tokens, i) => {
|
|
|
2722
2917
|
*/
|
|
2723
2918
|
export const EN_RULE_ENTRIES = [
|
|
2724
2919
|
{ name: 'range', rule: ruleRange },
|
|
2920
|
+
{ name: 'open-range', rule: ruleOpenRange },
|
|
2725
2921
|
{ name: 'week-of', rule: ruleWeekOf },
|
|
2726
2922
|
{ name: 'nth-weekday-of', rule: ruleNthWeekdayOf },
|
|
2727
2923
|
{ name: 'day-of-scope', rule: ruleDayOfScope },
|
|
2728
2924
|
{ name: 'weekend-of', rule: ruleWeekendOf },
|
|
2729
2925
|
{ name: 'holiday', rule: ruleHoliday },
|
|
2730
2926
|
{ name: 'early-late', rule: ruleEarlyLate },
|
|
2927
|
+
{ name: 'half-of', rule: ruleHalfOf },
|
|
2731
2928
|
{ name: 'end-of', rule: ruleEndOf },
|
|
2732
2929
|
{ name: 'deictic-day', rule: ruleDeicticDay },
|
|
2733
2930
|
{ name: 'within', rule: ruleWithin },
|
|
2734
2931
|
{ name: 'n-after-date', rule: ruleNAfterDate },
|
|
2735
2932
|
{ name: 'unit-after-next', rule: ruleUnitAfterNext },
|
|
2933
|
+
{ name: 'before-last', rule: ruleBeforeLast },
|
|
2736
2934
|
{ name: 'past-n', rule: rulePastN },
|
|
2737
2935
|
{ name: 'next-n', rule: ruleNextN },
|
|
2738
2936
|
{ name: 'the-n-past-next', rule: ruleTheNPastNext },
|
package/dist/parse/index.js
CHANGED
|
@@ -137,7 +137,10 @@ function mergeAdjacent(matches, tokens, connectors) {
|
|
|
137
137
|
let timeExpr = inferMeridiem(dateExpr, timePart.expr);
|
|
138
138
|
// A day-period on the date side pushes its meridiem into an explicit
|
|
139
139
|
// time range and then steps aside: "Monday morning 6-8" is Monday
|
|
140
|
-
// 06:00–08:00, not clipped to the morning boundaries.
|
|
140
|
+
// 06:00–08:00, not clipped to the morning boundaries. A cross-midnight
|
|
141
|
+
// range ("last night between 11pm and 1am") keeps the period part:
|
|
142
|
+
// its window extends past midnight, so the range can roll into the
|
|
143
|
+
// next day instead of being clipped at the day anchor's end (#19).
|
|
141
144
|
if (timeExpr.op === 'between') {
|
|
142
145
|
const period = findDayPeriod(dateExpr);
|
|
143
146
|
if (period) {
|
|
@@ -146,7 +149,11 @@ function mergeAdjacent(matches, tokens, connectors) {
|
|
|
146
149
|
start: forceMeridiem(timeExpr.start, period),
|
|
147
150
|
end: forceMeridiem(timeExpr.end, period),
|
|
148
151
|
};
|
|
149
|
-
|
|
152
|
+
const sm = literalMinuteOfDay(timeExpr.start);
|
|
153
|
+
const em = literalMinuteOfDay(timeExpr.end);
|
|
154
|
+
const wraps = sm !== undefined && em !== undefined && em <= sm;
|
|
155
|
+
if (!wraps)
|
|
156
|
+
dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
out.push({
|
|
@@ -211,6 +218,18 @@ function inferMeridiem(dateExpr, timeExpr) {
|
|
|
211
218
|
time: { ...timeExpr.time, meridiem },
|
|
212
219
|
};
|
|
213
220
|
}
|
|
221
|
+
/** Minutes-since-midnight of a literal time with a known meridiem, if any. */
|
|
222
|
+
function literalMinuteOfDay(expr) {
|
|
223
|
+
if (expr.op !== 'literal' || expr.time?.hour === undefined)
|
|
224
|
+
return undefined;
|
|
225
|
+
const h = expr.time.hour % 12;
|
|
226
|
+
const min = expr.time.minute ?? 0;
|
|
227
|
+
if (expr.time.meridiem === 'am')
|
|
228
|
+
return h * 60 + min;
|
|
229
|
+
if (expr.time.meridiem === 'pm')
|
|
230
|
+
return (h + 12) * 60 + min;
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
214
233
|
function forceMeridiem(expr, period) {
|
|
215
234
|
if (expr.op !== 'literal' || !expr.time || (expr.time.meridiem && expr.time.meridiem !== 'unknown')) {
|
|
216
235
|
return expr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nl2time",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Bidirectional natural language \u21c4 date/time: parse expressions like 'last week' into concrete intervals, and describe instants as '9pm last night' \u2014 deterministic core, optional LLM assist.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|