nl2time 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/corpus/runner.d.ts +2 -0
- package/dist/corpus/runner.js +10 -1
- package/dist/describe/index.js +5 -0
- package/dist/engine/resolve.d.ts +4 -2
- package/dist/engine/resolve.js +173 -54
- package/dist/ir/types.d.ts +12 -1
- package/dist/ir/validate.js +9 -2
- package/dist/parse/en.js +314 -52
- package/dist/parse/index.js +40 -2
- package/package.json +6 -6
- package/schema/timeexpr.schema.json +14 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ nl2time puts a small symbolic IR in the middle of both directions:
|
|
|
45
45
|
- **`TimeExpr`** — a JSON operator tree (`now`, `literal`, `offset`, `snap`, `span`, `seek`, `between`, `intersect`, `recur`) over timeline intervals. [Spec](docs/ir-spec.md), [JSON Schema](schema/timeexpr.schema.json).
|
|
46
46
|
- **`resolve(expr, ctx)`** — deterministic evaluation against a `TimeContext` (reference instant, IANA timezone, locale, week start, ambiguity policies). Ambiguity is data: candidates come back ordered, never silently guessed.
|
|
47
47
|
- **`describe(value, ctx)`** — chooses framing (calendar / elapsed / absolute), builds the IR expression that describes the value, renders it via `Intl`. Round-trip invariant: `resolve(describe(v).expr, ctx)` contains `v`.
|
|
48
|
-
- **Everything language refers to is an interval with a grain.** "Last week" is seven days; "9pm" is an hour; "March" is a month.
|
|
48
|
+
- **Everything language refers to is an interval with a grain.** "Last week" is seven days; "9pm" is an hour; "March" is a month. Intervals are half-open `[start, end)`; a range with no lower bound ("orders placed before March 2022") has `start: null`.
|
|
49
49
|
|
|
50
50
|
## The LLM boundary (optional)
|
|
51
51
|
|
|
@@ -120,11 +120,11 @@ Every imported conformance case carries machine-readable provenance (upstream pr
|
|
|
120
120
|
pip install nl2time
|
|
121
121
|
```
|
|
122
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,
|
|
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,827 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.
|
|
124
124
|
|
|
125
125
|
## Release history
|
|
126
126
|
|
|
127
|
-
See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.
|
|
127
|
+
See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.3 (JS) · py-v0.1.2 (Python engine). Releases publish to npm/PyPI automatically via OIDC trusted publishing — see [docs/RELEASING.md](docs/RELEASING.md).
|
|
128
128
|
|
|
129
129
|
## Development
|
|
130
130
|
|
package/dist/corpus/runner.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface IntervalExpect {
|
|
|
17
17
|
/** Civil expectations, compared in the case's timeZone ("YYYY-MM-DDTHH:MM:SS"). */
|
|
18
18
|
startLocal?: string;
|
|
19
19
|
endLocal?: string;
|
|
20
|
+
/** Unbounded start ("before March 2022"): the candidate's start must be null (#32). */
|
|
21
|
+
openStart?: boolean;
|
|
20
22
|
/** Civil point: candidate interval start must equal it. */
|
|
21
23
|
pointLocal?: string;
|
|
22
24
|
/** Omit or null to skip the grain check (imported cases). */
|
package/dist/corpus/runner.js
CHANGED
|
@@ -80,6 +80,15 @@ function matchesInterval(v, spec, timeZone) {
|
|
|
80
80
|
return false;
|
|
81
81
|
if (spec.grain !== undefined && spec.grain !== null && v.grain !== spec.grain)
|
|
82
82
|
return false;
|
|
83
|
+
if ((spec.openStart === true) !== (v.start === null))
|
|
84
|
+
return false;
|
|
85
|
+
if (v.start === null) {
|
|
86
|
+
if (spec.end !== undefined && v.end.toString() !== spec.end)
|
|
87
|
+
return false;
|
|
88
|
+
if (spec.endLocal !== undefined && !localEq(localString(v.end, timeZone), normLocal(spec.endLocal)))
|
|
89
|
+
return false;
|
|
90
|
+
return spec.start === undefined && spec.startLocal === undefined && spec.pointLocal === undefined;
|
|
91
|
+
}
|
|
83
92
|
if (spec.start !== undefined && v.start.toString() !== spec.start)
|
|
84
93
|
return false;
|
|
85
94
|
if (spec.end !== undefined && v.end.toString() !== spec.end)
|
|
@@ -177,7 +186,7 @@ function describeMismatch(candidates, tz) {
|
|
|
177
186
|
const shown = candidates
|
|
178
187
|
.slice(0, 3)
|
|
179
188
|
.map((v) => v.kind === 'interval'
|
|
180
|
-
? `[${localString(v.start, tz)}, ${localString(v.end, tz)}) ${v.grain}`
|
|
189
|
+
? `[${v.start === null ? '-inf' : localString(v.start, tz)}, ${localString(v.end, tz)}) ${v.grain}`
|
|
181
190
|
: JSON.stringify(v))
|
|
182
191
|
.join(' | ');
|
|
183
192
|
return `got: ${shown || '(none)'}`;
|
package/dist/describe/index.js
CHANGED
|
@@ -17,6 +17,11 @@ export function describe(value, ctx, opts = {}) {
|
|
|
17
17
|
if (value.kind === 'duration' || value.kind === 'amount') {
|
|
18
18
|
return describeAmount(value, ctx);
|
|
19
19
|
}
|
|
20
|
+
if (value.start === null) {
|
|
21
|
+
// Open start ("before March 2022", #32): describe the boundary.
|
|
22
|
+
const bound = describeInterval(toZoned(value.end, ctx.timeZone), value.grain, ctx, 'absolute', style);
|
|
23
|
+
return { ...bound, text: `before ${bound.text}`, expr: { op: 'until', end: bound.expr, exclusive: true } };
|
|
24
|
+
}
|
|
20
25
|
const start = toZoned(value.start, ctx.timeZone);
|
|
21
26
|
return describeInterval(start, value.grain, ctx, framing, style);
|
|
22
27
|
}
|
package/dist/engine/resolve.d.ts
CHANGED
|
@@ -8,9 +8,11 @@ export declare class AmbiguityError extends Error {
|
|
|
8
8
|
constructor(message: string);
|
|
9
9
|
}
|
|
10
10
|
/** A resolved value. Intervals are half-open [start, end). */
|
|
11
|
-
export type TimeValue =
|
|
11
|
+
export type TimeValue =
|
|
12
|
+
/** `start` is null for an unbounded start ("before March 2022", #32). */
|
|
13
|
+
{
|
|
12
14
|
kind: 'interval';
|
|
13
|
-
start: Instant;
|
|
15
|
+
start: Instant | null;
|
|
14
16
|
end: Instant;
|
|
15
17
|
grain: Grain;
|
|
16
18
|
} | {
|
package/dist/engine/resolve.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
|
|
1
|
+
import { Temporal, addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
|
|
2
2
|
import { dayPeriodRules } from '../data/dayPeriods.js';
|
|
3
3
|
export class NotResolvableError extends Error {
|
|
4
4
|
constructor(message) {
|
|
@@ -27,6 +27,106 @@ function finerGrain(a, b) {
|
|
|
27
27
|
return GRAIN_ORDER.indexOf(a) <= GRAIN_ORDER.indexOf(b) ? a : b;
|
|
28
28
|
}
|
|
29
29
|
const MAX_CANDIDATES = 8;
|
|
30
|
+
const isPointZi = (zi) => Temporal_compare(zi.start, zi.end) === 0;
|
|
31
|
+
/**
|
|
32
|
+
* Grain of a `between` result (#37). A range that starts on a calendar period
|
|
33
|
+
* and ends on a point ("since 2020" = 2020 → now) keeps the start's grain;
|
|
34
|
+
* a range between two points takes its grain from how the edges align ("the
|
|
35
|
+
* second half of 2024" = Jul 1 → Jan 1 is month-grained, while a deadline
|
|
36
|
+
* "by end of month" = now → Sep 1 stays 'instant'). Otherwise the finer
|
|
37
|
+
* operand grain, as before.
|
|
38
|
+
*/
|
|
39
|
+
function rangeGrain(s, e, start, end, touchesNow) {
|
|
40
|
+
const sp = isPointZi(s);
|
|
41
|
+
const ep = isPointZi(e);
|
|
42
|
+
// `now` is an arbitrary instant even when a fixture pins it to midnight.
|
|
43
|
+
if (sp && ep && !touchesNow)
|
|
44
|
+
return alignedGrain(start, end);
|
|
45
|
+
if (!sp && ep)
|
|
46
|
+
return s.grain;
|
|
47
|
+
return finerGrain(s.grain, e.grain);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Clock-time semantics need a fine grain AND a sub-day extent: a multi-day
|
|
51
|
+
* interval whose edges are snap points ("end of year" = Jul 1 → Jan 1) is a
|
|
52
|
+
* calendar period, not a time of day.
|
|
53
|
+
*/
|
|
54
|
+
function isTimeGrained(e) {
|
|
55
|
+
return (GRAIN_ORDER.indexOf(e.grain) < GRAIN_ORDER.indexOf('day') &&
|
|
56
|
+
(isPointZi(e) || Temporal_compare(e.start.add({ days: 1 }), e.end) > 0));
|
|
57
|
+
}
|
|
58
|
+
/** Candidates for one (start, end) operand pair of `between`. */
|
|
59
|
+
function betweenPair(s, e, touchesNow) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const isPoint = isPointZi(e);
|
|
62
|
+
const timeGrained = isTimeGrained(e);
|
|
63
|
+
// Day-grain-or-coarser ends are conversationally inclusive: "between July
|
|
64
|
+
// 4th and July 10th" covers the 10th. Emit the inclusive reading (end
|
|
65
|
+
// operand contributes its END) first, with the strict exclusive-at-start
|
|
66
|
+
// reading as an alternative — the Recognizers-derived corpus pins the
|
|
67
|
+
// exclusive value, so both must remain candidates (#17).
|
|
68
|
+
if (!isPoint && !timeGrained) {
|
|
69
|
+
if (Temporal_compare(s.start, e.end) < 0) {
|
|
70
|
+
const grain = rangeGrain(s, e, s.start, e.end, touchesNow);
|
|
71
|
+
out.push({ type: 'interval', zi: { start: s.start, end: e.end, grain } });
|
|
72
|
+
}
|
|
73
|
+
if (Temporal_compare(s.start, e.start) < 0) {
|
|
74
|
+
const grain = rangeGrain(s, e, s.start, e.start, touchesNow);
|
|
75
|
+
out.push({ type: 'interval', zi: { start: s.start, end: e.start, grain } });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// Non-point time ends are exclusive at their *start* ("9 to 5pm" ends at
|
|
80
|
+
// 17:00); point ends are used directly.
|
|
81
|
+
let endPoint = isPoint ? e.end : e.start;
|
|
82
|
+
if (Temporal_compare(s.start, endPoint) >= 0) {
|
|
83
|
+
// A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls the
|
|
84
|
+
// end into the next day; date ranges just drop.
|
|
85
|
+
if (!timeGrained)
|
|
86
|
+
return out;
|
|
87
|
+
endPoint = endPoint.add({ days: 1 });
|
|
88
|
+
if (Temporal_compare(s.start, endPoint) >= 0)
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
out.push({
|
|
92
|
+
type: 'interval',
|
|
93
|
+
zi: { start: s.start, end: endPoint, grain: rangeGrain(s, e, s.start, endPoint, touchesNow) },
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Internal stand-in for an unbounded start; `toValue` turns it into
|
|
99
|
+
* `start: null`. Far enough back that no calendar arithmetic reaches it.
|
|
100
|
+
*/
|
|
101
|
+
const OPEN_START = Temporal.Instant.from('0100-01-01T00:00:00Z');
|
|
102
|
+
/** (−∞, end) readings for an `until` whose end is not after now (#32). */
|
|
103
|
+
function openStartPair(e, ctx) {
|
|
104
|
+
const open = OPEN_START.toZonedDateTimeISO(ctx.timeZone);
|
|
105
|
+
if (isPointZi(e))
|
|
106
|
+
return [{ type: 'interval', zi: { start: open, end: e.end, grain: edgeGrain(e.end) } }];
|
|
107
|
+
return [
|
|
108
|
+
{ type: 'interval', zi: { start: open, end: e.end, grain: e.grain } },
|
|
109
|
+
{ type: 'interval', zi: { start: open, end: e.start, grain: e.grain } },
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
/** Coarsest calendar boundary a single point sits on ("end of 2024" → year). */
|
|
113
|
+
function edgeGrain(z) {
|
|
114
|
+
if (Temporal_compare(z, z.startOfDay()) !== 0)
|
|
115
|
+
return 'instant';
|
|
116
|
+
if (z.day !== 1)
|
|
117
|
+
return 'day';
|
|
118
|
+
return z.month === 1 ? 'year' : 'month';
|
|
119
|
+
}
|
|
120
|
+
function alignedGrain(start, end) {
|
|
121
|
+
if (Temporal_compare(start, end) === 0)
|
|
122
|
+
return 'instant';
|
|
123
|
+
const atDayStart = (z) => Temporal_compare(z, z.startOfDay()) === 0;
|
|
124
|
+
if (!atDayStart(start) || !atDayStart(end))
|
|
125
|
+
return 'instant';
|
|
126
|
+
if (start.day !== 1 || end.day !== 1)
|
|
127
|
+
return 'day';
|
|
128
|
+
return start.month === 1 && end.month === 1 ? 'year' : 'month';
|
|
129
|
+
}
|
|
30
130
|
export function resolve(expr, ctx) {
|
|
31
131
|
const cands = evalExpr(expr, ctx, undefined).slice(0, MAX_CANDIDATES);
|
|
32
132
|
return { candidates: cands.map((c) => toValue(c)) };
|
|
@@ -44,7 +144,7 @@ function toValue(c) {
|
|
|
44
144
|
case 'interval':
|
|
45
145
|
return {
|
|
46
146
|
kind: 'interval',
|
|
47
|
-
start: c.zi.start.toInstant(),
|
|
147
|
+
start: c.zi.start.epochMilliseconds === OPEN_START.epochMilliseconds ? null : c.zi.start.toInstant(),
|
|
48
148
|
end: c.zi.end.toInstant(),
|
|
49
149
|
grain: c.zi.grain,
|
|
50
150
|
};
|
|
@@ -168,63 +268,38 @@ function evalExprInner(expr, ctx, anchor) {
|
|
|
168
268
|
case 'span':
|
|
169
269
|
return intervalMap(evalExpr(expr.anchor, ctx, anchor), (zi) => expr.business ? evalBusinessSpan(zi, expr.amount, ctx) : evalSpan(zi, expr.amount, ctx));
|
|
170
270
|
case 'between': {
|
|
171
|
-
const
|
|
271
|
+
const touchesNow = expr.start.op === 'now' || expr.end.op === 'now';
|
|
172
272
|
const out = [];
|
|
173
|
-
for (const
|
|
174
|
-
if (
|
|
273
|
+
for (const st of evalExpr(expr.start, ctx, anchor)) {
|
|
274
|
+
if (st.type !== 'interval')
|
|
175
275
|
continue;
|
|
176
276
|
// The end operand resolves anchored to the start (shared context:
|
|
177
277
|
// "between 3 and 12 of Sept" — the 3 anchors to September).
|
|
178
|
-
for (const e of evalExpr(expr.end, ctx,
|
|
278
|
+
for (const e of evalExpr(expr.end, ctx, st.zi)) {
|
|
179
279
|
if (e.type !== 'interval')
|
|
180
280
|
continue;
|
|
181
|
-
|
|
182
|
-
// Clock-time semantics need a fine grain AND a sub-day extent: a
|
|
183
|
-
// multi-day interval whose edges are snap points ("end of year" =
|
|
184
|
-
// Jul 1 → Jan 1) carries grain 'instant' but is a calendar period,
|
|
185
|
-
// not a time of day.
|
|
186
|
-
const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day') &&
|
|
187
|
-
(isPoint || Temporal_compare(e.zi.start.add({ days: 1 }), e.zi.end) > 0);
|
|
188
|
-
// Day-grain-or-coarser ends are conversationally inclusive:
|
|
189
|
-
// "between July 4th and July 10th" covers the 10th. Emit the
|
|
190
|
-
// inclusive reading (end operand contributes its END) first, with
|
|
191
|
-
// the strict exclusive-at-start reading as an alternative — the
|
|
192
|
-
// Recognizers-derived corpus pins the exclusive value, so both
|
|
193
|
-
// must remain candidates (#17).
|
|
194
|
-
if (!isPoint && !timeGrained) {
|
|
195
|
-
const grain = finerGrain(s.zi.grain, e.zi.grain);
|
|
196
|
-
if (Temporal_compare(s.zi.start, e.zi.end) < 0) {
|
|
197
|
-
out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.end, grain } });
|
|
198
|
-
}
|
|
199
|
-
if (Temporal_compare(s.zi.start, e.zi.start) < 0) {
|
|
200
|
-
out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.start, grain } });
|
|
201
|
-
}
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
// Non-point time ends are exclusive at their *start* ("9 to 5pm"
|
|
205
|
-
// ends at 17:00); point ends are used directly.
|
|
206
|
-
let endPoint = isPoint ? e.zi.end : e.zi.start;
|
|
207
|
-
if (Temporal_compare(s.zi.start, endPoint) >= 0) {
|
|
208
|
-
// A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
|
|
209
|
-
// the end into the next day; date ranges just drop.
|
|
210
|
-
if (!timeGrained)
|
|
211
|
-
continue;
|
|
212
|
-
endPoint = endPoint.add({ days: 1 });
|
|
213
|
-
if (Temporal_compare(s.zi.start, endPoint) >= 0)
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
out.push({
|
|
217
|
-
type: 'interval',
|
|
218
|
-
zi: {
|
|
219
|
-
start: s.zi.start,
|
|
220
|
-
end: endPoint,
|
|
221
|
-
grain: finerGrain(s.zi.grain, e.zi.grain),
|
|
222
|
-
},
|
|
223
|
-
});
|
|
281
|
+
out.push(...betweenPair(st.zi, e.zi, touchesNow));
|
|
224
282
|
}
|
|
225
283
|
}
|
|
226
284
|
return out;
|
|
227
285
|
}
|
|
286
|
+
case 'until': {
|
|
287
|
+
// "by Friday", "until 2025-06-30", "before March 2022" (#32): a
|
|
288
|
+
// deadline [now, end) for the end candidates still ahead of now. Only
|
|
289
|
+
// when none is ahead does it read as the stretch of history up to the
|
|
290
|
+
// end, with an unbounded start. Clock-time ends always keep the
|
|
291
|
+
// deadline reading (they roll to the next day like any time range).
|
|
292
|
+
const now = pointInterval(ctx.zonedNow);
|
|
293
|
+
const ends = evalExpr(expr.end, ctx, anchor).flatMap((e) => (e.type === 'interval' ? [e.zi] : []));
|
|
294
|
+
const ahead = ends.filter((e) => Temporal_compare(e.end, ctx.zonedNow) > 0 || (!isPointZi(e) && isTimeGrained(e)));
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const e of ahead.length > 0 ? ahead : ends) {
|
|
297
|
+
const pair = ahead.length > 0 ? betweenPair(now, e, true) : openStartPair(e, ctx);
|
|
298
|
+
// "before X" excludes X: its exclusive reading leads.
|
|
299
|
+
out.push(...(expr.exclusive && pair.length === 2 ? [pair[1], pair[0]] : pair));
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
228
303
|
case 'seek':
|
|
229
304
|
return evalSeek(expr, ctx, anchor);
|
|
230
305
|
case 'intersect': {
|
|
@@ -398,7 +473,25 @@ function evalSeek(expr, ctx, anchor) {
|
|
|
398
473
|
out.push({ type: 'interval', zi });
|
|
399
474
|
continue;
|
|
400
475
|
}
|
|
401
|
-
|
|
476
|
+
// Deictic "this Friday" / "this weekend" (#23): the occurrence on or
|
|
477
|
+
// after the base day leads, whatever the bias; the previous one follows.
|
|
478
|
+
// It leads instead when it is the weekend still in progress (Sunday)
|
|
479
|
+
// or, under bias 'past', when it was at most 3 days ago ("this weekend"
|
|
480
|
+
// said on Monday or Tuesday).
|
|
481
|
+
if (expr.dir === 'this' && expr.target.kind === 'weekday' && (b.zi.grain === 'instant' || b.zi.grain === 'day')) {
|
|
482
|
+
const today = floorTo(b.zi.start, 'day', ctx.weekStart);
|
|
483
|
+
const target = weekdayNumber(expr.target.weekday);
|
|
484
|
+
const upDelta = (target - today.dayOfWeek + 7) % 7;
|
|
485
|
+
const backDelta = ((today.dayOfWeek - target + 6) % 7) + 1;
|
|
486
|
+
const up = today.add({ days: upDelta });
|
|
487
|
+
const prev = today.subtract({ days: backDelta });
|
|
488
|
+
const prevLeads = (target === 6 && backDelta === 1) || (ctx.bias === 'past' && backDelta <= 3);
|
|
489
|
+
for (const d of prevLeads ? [prev, up] : [up, prev]) {
|
|
490
|
+
out.push({ type: 'interval', zi: { start: d, end: d.add({ days: 1 }), grain: 'day' } });
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const zi = seekFrom(b.zi, expr.dir === 'this' ? { ...expr, dir: 'nearest' } : expr, ctx);
|
|
402
495
|
if (zi)
|
|
403
496
|
out.push({ type: 'interval', zi });
|
|
404
497
|
}
|
|
@@ -623,9 +716,12 @@ function resolveDate(date, ctx, anchor) {
|
|
|
623
716
|
if (date.month !== undefined && date.day !== undefined) {
|
|
624
717
|
// "May 29" with no year → the most recent occurrence starting on-or-
|
|
625
718
|
// before today, then the following one ("past + future" pairing).
|
|
626
|
-
|
|
719
|
+
// Occurrence k walks over years where the date exists, so "Feb 29"
|
|
720
|
+
// lands on leap years instead of clamping to Feb 28 (#14).
|
|
721
|
+
const valid = (d) => ref.add({ years: d }).with({ month: date.month, day: 1 }).daysInMonth >= date.day;
|
|
722
|
+
const mk = (k) => {
|
|
627
723
|
const day = ref
|
|
628
|
-
.add({ years:
|
|
724
|
+
.add({ years: nthValidDelta(k, valid) })
|
|
629
725
|
.with({ month: date.month, day: date.day })
|
|
630
726
|
.startOfDay();
|
|
631
727
|
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
@@ -646,8 +742,13 @@ function resolveDate(date, ctx, anchor) {
|
|
|
646
742
|
}
|
|
647
743
|
if (date.day !== undefined) {
|
|
648
744
|
// "the 3rd" → most recent occurrence on-or-before today, then the next.
|
|
649
|
-
|
|
650
|
-
|
|
745
|
+
// Skip months too short for the day ("the 31st" never means the 30th).
|
|
746
|
+
const valid = (d) => ref.add({ months: d }).with({ day: 1 }).daysInMonth >= date.day;
|
|
747
|
+
const mk = (k) => {
|
|
748
|
+
const day = ref
|
|
749
|
+
.add({ months: nthValidDelta(k, valid) })
|
|
750
|
+
.with({ day: date.day })
|
|
751
|
+
.startOfDay();
|
|
651
752
|
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
652
753
|
};
|
|
653
754
|
if (ctx.bias === 'none') {
|
|
@@ -660,6 +761,24 @@ function resolveDate(date, ctx, anchor) {
|
|
|
660
761
|
const day = floorTo(ref, 'day', ctx.weekStart);
|
|
661
762
|
return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
|
|
662
763
|
}
|
|
764
|
+
/**
|
|
765
|
+
* Map occurrence index k to a calendar delta, counting only deltas where
|
|
766
|
+
* `valid` holds: index 0 is the first valid delta >= 0, k > 0 steps forward
|
|
767
|
+
* and k < 0 steps back from it. With every delta valid this is the identity.
|
|
768
|
+
*/
|
|
769
|
+
function nthValidDelta(k, valid) {
|
|
770
|
+
const LIMIT = 16; // leap years skip at most 8 years; month lengths at most 2
|
|
771
|
+
let d = 0;
|
|
772
|
+
for (let g = 0; !valid(d) && g < LIMIT; g++)
|
|
773
|
+
d++;
|
|
774
|
+
const step = k > 0 ? 1 : -1;
|
|
775
|
+
for (let i = 0; i < Math.abs(k); i++) {
|
|
776
|
+
d += step;
|
|
777
|
+
for (let g = 0; !valid(d) && g < LIMIT; g++)
|
|
778
|
+
d += step;
|
|
779
|
+
}
|
|
780
|
+
return d;
|
|
781
|
+
}
|
|
663
782
|
/**
|
|
664
783
|
* Order the this-period occurrence and its neighbor under the context bias:
|
|
665
784
|
* 'future' → soonest occurrence not in the past first; 'past' → most recent
|
package/dist/ir/types.d.ts
CHANGED
|
@@ -108,6 +108,17 @@ export type TimeExprBody =
|
|
|
108
108
|
start: TimeExpr;
|
|
109
109
|
end: TimeExpr;
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The stretch up to `end` ("by Friday", "until 2025-06-30", "before March
|
|
113
|
+
* 2022"): [now, end) while `end` is ahead of now, else (−∞, end) with an
|
|
114
|
+
* unbounded start. `exclusive` ("before") ranks the reading that stops at
|
|
115
|
+
* the start of `end` first.
|
|
116
|
+
*/
|
|
117
|
+
| {
|
|
118
|
+
op: 'until';
|
|
119
|
+
end: TimeExpr;
|
|
120
|
+
exclusive?: boolean;
|
|
121
|
+
}
|
|
111
122
|
/**
|
|
112
123
|
* Directed calendar navigation: seek(now, 'next', weekday tue) is "next
|
|
113
124
|
* Tuesday" (semantics of 'next' controlled by context nextWeekday policy);
|
|
@@ -116,7 +127,7 @@ export type TimeExprBody =
|
|
|
116
127
|
| {
|
|
117
128
|
op: 'seek';
|
|
118
129
|
base: TimeExpr;
|
|
119
|
-
dir: 'next' | 'prev' | 'nearest';
|
|
130
|
+
dir: 'next' | 'prev' | 'nearest' | 'this';
|
|
120
131
|
target: Target;
|
|
121
132
|
n?: number;
|
|
122
133
|
}
|
package/dist/ir/validate.js
CHANGED
|
@@ -8,7 +8,7 @@ export class IRValidationError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
const MODS = ['approx', 'start', 'mid', 'end'];
|
|
11
|
-
const DIRS = ['next', 'prev', 'nearest'];
|
|
11
|
+
const DIRS = ['next', 'prev', 'nearest', 'this'];
|
|
12
12
|
/**
|
|
13
13
|
* Structural validation of untrusted input (LLM output, stored IR, network)
|
|
14
14
|
* into a typed TimeExpr. Mirrors schema/timeexpr.schema.json; the JSON Schema
|
|
@@ -66,10 +66,17 @@ export function validateExpr(input, path = '$') {
|
|
|
66
66
|
validateExpr(obj.end, `${path}.end`);
|
|
67
67
|
return obj;
|
|
68
68
|
}
|
|
69
|
+
case 'until': {
|
|
70
|
+
validateExpr(obj.end, `${path}.end`);
|
|
71
|
+
if (obj.exclusive !== undefined && typeof obj.exclusive !== 'boolean') {
|
|
72
|
+
throw new IRValidationError('exclusive must be boolean', `${path}.exclusive`);
|
|
73
|
+
}
|
|
74
|
+
return obj;
|
|
75
|
+
}
|
|
69
76
|
case 'seek': {
|
|
70
77
|
validateExpr(obj.base, `${path}.base`);
|
|
71
78
|
if (!DIRS.includes(obj.dir)) {
|
|
72
|
-
throw new IRValidationError('dir must be next|prev|nearest', `${path}.dir`);
|
|
79
|
+
throw new IRValidationError('dir must be next|prev|nearest|this', `${path}.dir`);
|
|
73
80
|
}
|
|
74
81
|
validateTarget(obj.target, `${path}.target`);
|
|
75
82
|
if (obj.n !== undefined)
|
package/dist/parse/en.js
CHANGED
|
@@ -156,8 +156,23 @@ const TIME_PREPOSITIONS = new Set([
|
|
|
156
156
|
]);
|
|
157
157
|
// --- rules -----------------------------------------------------------------
|
|
158
158
|
/** today / yesterday / tomorrow / tonight / day before yesterday / day after tomorrow */
|
|
159
|
-
const ruleDeicticDay = (tokens, i) => {
|
|
159
|
+
const ruleDeicticDay = (tokens, i, ctx) => {
|
|
160
160
|
const w = word(tokens[i]);
|
|
161
|
+
// "overnight" (#23): the night just ended, or tonight once it is afternoon
|
|
162
|
+
// and the context looks forward (bias 'future', or 'none' after noon). A
|
|
163
|
+
// night still in progress is "last night" (the night period wraps midnight).
|
|
164
|
+
if (w === 'overnight') {
|
|
165
|
+
const tonight = ctx.bias === 'future' || (ctx.bias === 'none' && ctx.zonedNow.hour >= 12);
|
|
166
|
+
return {
|
|
167
|
+
expr: {
|
|
168
|
+
op: 'intersect',
|
|
169
|
+
parts: [tonight ? snapNow('day') : snapOffset(-1, 'day'), { op: 'literal', dayPeriod: 'night' }],
|
|
170
|
+
},
|
|
171
|
+
consumed: 1,
|
|
172
|
+
confidence: 0.85,
|
|
173
|
+
role: 'datetime',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
161
176
|
if (w === 'today' || w === 'tdy')
|
|
162
177
|
return { expr: snapNow('day'), consumed: 1, confidence: 1, role: 'date' };
|
|
163
178
|
if (w === 'yesterday')
|
|
@@ -416,30 +431,33 @@ const ruleRange = (tokens, i, ctx) => {
|
|
|
416
431
|
}
|
|
417
432
|
return { expr, consumed: end - i, confidence: 0.95, role: rangeRole(a, b) };
|
|
418
433
|
};
|
|
434
|
+
/** "end of 2024" / "beginning of the year 2024" → the year's boundary point. */
|
|
435
|
+
function readYearEdge(tokens, i) {
|
|
436
|
+
const w0 = word(tokens[i]);
|
|
437
|
+
if ((w0 !== 'end' && w0 !== 'beginning' && w0 !== 'start') || word(tokens[i + 1]) !== 'of')
|
|
438
|
+
return undefined;
|
|
439
|
+
let yAt = i + 2;
|
|
440
|
+
if (word(tokens[yAt]) === 'the')
|
|
441
|
+
yAt += 1;
|
|
442
|
+
const year = yearAt(tokens, yAt);
|
|
443
|
+
if (year === undefined)
|
|
444
|
+
return undefined;
|
|
445
|
+
return {
|
|
446
|
+
expr: { op: 'snap', base: { op: 'literal', date: { year } }, unit: 'year', edge: w0 === 'end' ? 'end' : 'start' },
|
|
447
|
+
consumed: yAt + 1 - i,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
419
450
|
function readOperand(tokens, i, ctx) {
|
|
420
451
|
// "between the end of 2007 and the end of 2008" — inside a range, "end of
|
|
421
452
|
// YEAR" means the year's end boundary (a point), not its final third.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
operand: {
|
|
431
|
-
kind: 'date',
|
|
432
|
-
expr: {
|
|
433
|
-
op: 'snap',
|
|
434
|
-
base: { op: 'literal', date: { year } },
|
|
435
|
-
unit: 'year',
|
|
436
|
-
edge: w0 === 'end' ? 'end' : 'start',
|
|
437
|
-
},
|
|
438
|
-
},
|
|
439
|
-
consumed: yAt + 1 - i,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
}
|
|
453
|
+
// "through the end of 2024" (#32): an article before the boundary word.
|
|
454
|
+
const lead = word(tokens[i]) === 'the' ? 1 : 0;
|
|
455
|
+
const edge = readYearEdge(tokens, i + lead);
|
|
456
|
+
if (edge)
|
|
457
|
+
return { operand: { kind: 'date', expr: edge.expr }, consumed: lead + edge.consumed };
|
|
458
|
+
const wdd = readWeekdayDate(tokens, i, ctx);
|
|
459
|
+
if (wdd)
|
|
460
|
+
return { operand: { kind: 'date', expr: wdd.expr }, consumed: wdd.consumed };
|
|
443
461
|
const rel = ruleLastThisNext(tokens, i, ctx);
|
|
444
462
|
if (rel && rel.role === 'date') {
|
|
445
463
|
// "this friday 5/12": an explicit date after the weekday supersedes it.
|
|
@@ -612,9 +630,9 @@ function buildRange(a, b, scope, period, connWord) {
|
|
|
612
630
|
}
|
|
613
631
|
/**
|
|
614
632
|
* Open-range connectors (#18): "since X" → [start(X), now]; "until X" /
|
|
615
|
-
* "till X" / "through X" / "up to X" / "by X" (deadline) →
|
|
616
|
-
* These only fire when the connector opens the range —
|
|
617
|
-
* "from A through B" are consumed earlier by the range rule.
|
|
633
|
+
* "till X" / "through X" / "up to X" / "by X" / "before X" (deadline) →
|
|
634
|
+
* [now, end(X)]. These only fire when the connector opens the range —
|
|
635
|
+
* "A until B" and "from A through B" are consumed earlier by the range rule.
|
|
618
636
|
*/
|
|
619
637
|
const ruleOpenRange = (tokens, i, ctx) => {
|
|
620
638
|
const w = word(tokens[i]);
|
|
@@ -622,7 +640,7 @@ const ruleOpenRange = (tokens, i, ctx) => {
|
|
|
622
640
|
let at = i + 1;
|
|
623
641
|
if (w === 'since')
|
|
624
642
|
mode = 'since';
|
|
625
|
-
else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by') {
|
|
643
|
+
else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by' || w === 'before') {
|
|
626
644
|
mode = 'until';
|
|
627
645
|
}
|
|
628
646
|
else if (w === 'up' && word(tokens[i + 1]) === 'to') {
|
|
@@ -631,10 +649,25 @@ const ruleOpenRange = (tokens, i, ctx) => {
|
|
|
631
649
|
}
|
|
632
650
|
if (!mode)
|
|
633
651
|
return undefined;
|
|
634
|
-
// "by the 15th": a bare article only before an
|
|
635
|
-
//
|
|
636
|
-
|
|
652
|
+
// "by the 15th" / "by the end of the month": a bare article only before an
|
|
653
|
+
// explicit calendar date or an edge phrase (#29) — NOT "through the
|
|
654
|
+
// week"-style prose, which stays unmatched.
|
|
655
|
+
if (word(tokens[at]) === 'the' &&
|
|
656
|
+
(ruleCalendarDate(tokens, at + 1, ctx) || ruleEndOf(tokens, at + 1, ctx))) {
|
|
637
657
|
at += 1;
|
|
658
|
+
}
|
|
659
|
+
// "through the end of 2024" (#32): a year's end is its boundary point here,
|
|
660
|
+
// as in range operands, not the year's late part.
|
|
661
|
+
const yearEdge = readYearEdge(tokens, word(tokens[at]) === 'the' ? at + 1 : at);
|
|
662
|
+
if (yearEdge && mode === 'until') {
|
|
663
|
+
const consumed = (word(tokens[at]) === 'the' ? 1 : 0) + yearEdge.consumed;
|
|
664
|
+
return {
|
|
665
|
+
expr: { op: 'until', end: yearEdge.expr, ...(w === 'before' ? { exclusive: true } : {}) },
|
|
666
|
+
consumed: at + consumed - i,
|
|
667
|
+
confidence: 0.9,
|
|
668
|
+
role: 'datetime',
|
|
669
|
+
};
|
|
670
|
+
}
|
|
638
671
|
const inner = ruleDeicticDay(tokens, at, ctx) ??
|
|
639
672
|
ruleLastThisNext(tokens, at, ctx) ??
|
|
640
673
|
ruleHoliday(tokens, at, ctx) ??
|
|
@@ -646,23 +679,117 @@ const ruleOpenRange = (tokens, i, ctx) => {
|
|
|
646
679
|
ruleWeekdayAlone(tokens, at, ctx);
|
|
647
680
|
if (!inner || inner.role === 'duration')
|
|
648
681
|
return undefined;
|
|
649
|
-
// Boundary points ("by EOD"
|
|
650
|
-
//
|
|
682
|
+
// Boundary points ("by EOD") keep their point reading — the deadline
|
|
683
|
+
// instant is the answer there, not a range.
|
|
651
684
|
if (inner.expr.op === 'snap' && inner.expr.edge !== undefined)
|
|
652
685
|
return undefined;
|
|
686
|
+
// Late-part edge phrases ("end of month/year/quarter/week") parse as the
|
|
687
|
+
// period's late part — between(offset(start), end). As a deadline the
|
|
688
|
+
// answer is the period's END point, not the whole late part: using the
|
|
689
|
+
// interval would make the engine emit the inclusive/exclusive pair (#17)
|
|
690
|
+
// as a spurious second candidate whenever `now` precedes the late-part
|
|
691
|
+
// start (#29). Symmetrically, "since the beginning of the month" opens at
|
|
692
|
+
// the period's START point.
|
|
693
|
+
let target = inner.expr;
|
|
694
|
+
if (target.op === 'between') {
|
|
695
|
+
if (mode === 'until' && target.end.op === 'snap' && target.end.edge === 'end') {
|
|
696
|
+
target = target.end;
|
|
697
|
+
}
|
|
698
|
+
else if (mode === 'since' && target.start.op === 'snap' && target.start.edge === 'start') {
|
|
699
|
+
target = target.start;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Deadlines become `until`: [now, X) while X is ahead, else the history
|
|
703
|
+
// up to X with an unbounded start ("orders before March 2022", #32).
|
|
653
704
|
const expr = mode === 'since'
|
|
654
|
-
? { op: 'between', start:
|
|
655
|
-
: { op: '
|
|
705
|
+
? { op: 'between', start: target, end: NOW }
|
|
706
|
+
: { op: 'until', end: target, ...(w === 'before' ? { exclusive: true } : {}) };
|
|
656
707
|
return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
|
|
657
708
|
};
|
|
658
709
|
/** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
|
|
710
|
+
/**
|
|
711
|
+
* A weekday written next to an explicit month-day ("Thursday 2026-08-20",
|
|
712
|
+
* "Monday, March 3, 2025") checks the date rather than naming a second,
|
|
713
|
+
* relative weekday: the date wins (#34). A weekday that contradicts a full
|
|
714
|
+
* date keeps the date at lowered confidence.
|
|
715
|
+
*/
|
|
716
|
+
function readWeekdayDate(tokens, i, ctx) {
|
|
717
|
+
const w = word(tokens[i]);
|
|
718
|
+
const weekday = w !== undefined ? WEEKDAY_WORDS[w] : undefined;
|
|
719
|
+
if (!weekday)
|
|
720
|
+
return undefined;
|
|
721
|
+
const d = ruleCalendarDate(tokens, i + 1, ctx);
|
|
722
|
+
if (!d || d.role !== 'date' || d.expr.op !== 'literal')
|
|
723
|
+
return undefined;
|
|
724
|
+
const date = d.expr.date;
|
|
725
|
+
if (!date || date.month === undefined || date.day === undefined)
|
|
726
|
+
return undefined;
|
|
727
|
+
let confidence = Math.max(d.confidence, 0.9);
|
|
728
|
+
if (date.year !== undefined && isoWeekday(date.year, date.month, date.day) !== weekday)
|
|
729
|
+
confidence = 0.6;
|
|
730
|
+
return { expr: d.expr, consumed: 1 + d.consumed, confidence };
|
|
731
|
+
}
|
|
732
|
+
const WEEKDAY_BY_ISO = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
733
|
+
/** Weekday of a proleptic Gregorian date (Sakamoto), without Temporal. */
|
|
734
|
+
function isoWeekday(year, month, day) {
|
|
735
|
+
const t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
|
|
736
|
+
const y = month < 3 ? year - 1 : year;
|
|
737
|
+
const sun0 = (y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) + t[month - 1] + day) % 7;
|
|
738
|
+
return WEEKDAY_BY_ISO[(sun0 + 6) % 7];
|
|
739
|
+
}
|
|
740
|
+
/** "Thursday 2026-08-20" → the date (#34). */
|
|
741
|
+
const ruleWeekdayDate = (tokens, i, ctx) => {
|
|
742
|
+
const r = readWeekdayDate(tokens, i, ctx);
|
|
743
|
+
return r ? { expr: r.expr, consumed: r.consumed, confidence: r.confidence, role: 'date' } : undefined;
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* "twelve months ended June", "the 12 months ending June 30, 2026", "the
|
|
747
|
+
* three years ended 2025" (#36): the N units up to and including the date's
|
|
748
|
+
* period — span(date, -N units), i.e. [end(date) − N units, end(date)).
|
|
749
|
+
*/
|
|
750
|
+
const ruleNUnitsEnded = (tokens, i, ctx) => {
|
|
751
|
+
let at = i;
|
|
752
|
+
if (word(tokens[at]) === 'the')
|
|
753
|
+
at += 1;
|
|
754
|
+
const n = readNumber(tokens, at);
|
|
755
|
+
if (!n || !Number.isInteger(n.value) || n.value < 1)
|
|
756
|
+
return undefined;
|
|
757
|
+
at += n.consumed;
|
|
758
|
+
const unit = UNIT_WORDS[word(tokens[at]) ?? ''];
|
|
759
|
+
if (unit !== 'day' && unit !== 'week' && unit !== 'month' && unit !== 'quarter' && unit !== 'year')
|
|
760
|
+
return undefined;
|
|
761
|
+
at += 1;
|
|
762
|
+
const conn = word(tokens[at]);
|
|
763
|
+
if (conn !== 'ended' && conn !== 'ending')
|
|
764
|
+
return undefined;
|
|
765
|
+
at += 1;
|
|
766
|
+
if (word(tokens[at]) === 'on' || word(tokens[at]) === 'in')
|
|
767
|
+
at += 1;
|
|
768
|
+
if (word(tokens[at]) === 'the')
|
|
769
|
+
at += 1;
|
|
770
|
+
const year = yearAt(tokens, at);
|
|
771
|
+
const inner = ruleCalendarDate(tokens, at, ctx) ??
|
|
772
|
+
ruleLastThisNext(tokens, at, ctx) ??
|
|
773
|
+
(year !== undefined
|
|
774
|
+
? { expr: { op: 'literal', date: { year } }, consumed: 1, confidence: 0.85, role: 'date' }
|
|
775
|
+
: undefined);
|
|
776
|
+
if (!inner || inner.role !== 'date')
|
|
777
|
+
return undefined;
|
|
778
|
+
const amount = unit === 'quarter' ? { months: -3 * n.value } : { [`${unit}s`]: -n.value };
|
|
779
|
+
return {
|
|
780
|
+
expr: { op: 'span', anchor: inner.expr, amount },
|
|
781
|
+
consumed: at + inner.consumed - i,
|
|
782
|
+
confidence: 0.95,
|
|
783
|
+
role: 'date',
|
|
784
|
+
};
|
|
785
|
+
};
|
|
659
786
|
const ruleWeekOf = (tokens, i, ctx) => {
|
|
660
787
|
let at = i;
|
|
661
788
|
if (word(tokens[at]) === 'the')
|
|
662
789
|
at += 1;
|
|
663
790
|
// "w/c Feb 4" (week commencing)
|
|
664
791
|
if (word(tokens[at]) === 'w/c' || word(tokens[at]) === 'wc') {
|
|
665
|
-
const date = ruleCalendarDate(tokens, at + 1, ctx);
|
|
792
|
+
const date = ruleWeekdayDate(tokens, at + 1, ctx) ?? ruleCalendarDate(tokens, at + 1, ctx);
|
|
666
793
|
if (date && date.role === 'date') {
|
|
667
794
|
return {
|
|
668
795
|
expr: { op: 'snap', base: date.expr, unit: 'week' },
|
|
@@ -677,7 +804,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
|
|
|
677
804
|
let dAt = at + 2;
|
|
678
805
|
if (word(tokens[dAt]) === 'on' || word(tokens[dAt]) === 'from')
|
|
679
806
|
dAt += 1;
|
|
680
|
-
const date = ruleCalendarDate(tokens, dAt, ctx) ?? ruleDeicticDay(tokens, dAt, ctx);
|
|
807
|
+
const date = ruleWeekdayDate(tokens, dAt, ctx) ?? ruleCalendarDate(tokens, dAt, ctx) ?? ruleDeicticDay(tokens, dAt, ctx);
|
|
681
808
|
if (date && date.role === 'date') {
|
|
682
809
|
return {
|
|
683
810
|
expr: { op: 'snap', base: date.expr, unit: 'week' },
|
|
@@ -720,7 +847,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
|
|
|
720
847
|
let scope;
|
|
721
848
|
let consumed = 0;
|
|
722
849
|
const year = yearAt(tokens, at);
|
|
723
|
-
const date = ruleCalendarDate(tokens, at, ctx);
|
|
850
|
+
const date = ruleWeekdayDate(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
|
|
724
851
|
if (date) {
|
|
725
852
|
scope = date.expr;
|
|
726
853
|
consumed = date.consumed;
|
|
@@ -994,11 +1121,20 @@ const ruleHalfOf = (tokens, i, ctx) => {
|
|
|
994
1121
|
}
|
|
995
1122
|
if (which === undefined)
|
|
996
1123
|
return undefined;
|
|
997
|
-
if (word(tokens[at + 1]) !== 'half'
|
|
1124
|
+
if (word(tokens[at + 1]) !== 'half')
|
|
998
1125
|
return undefined;
|
|
999
|
-
at
|
|
1000
|
-
|
|
1001
|
-
at
|
|
1126
|
+
if (word(tokens[at + 2]) === 'of') {
|
|
1127
|
+
at += 3;
|
|
1128
|
+
if (word(tokens[at]) === 'the')
|
|
1129
|
+
at += 1;
|
|
1130
|
+
}
|
|
1131
|
+
else if (yearAt(tokens, at + 2) !== undefined) {
|
|
1132
|
+
// "first half 2025" (#36): no "of" before a bare year.
|
|
1133
|
+
at += 2;
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1002
1138
|
// Scope: a bare unit ("the year"), a relative period ("next month"), a
|
|
1003
1139
|
// month name, a quarter, or a year.
|
|
1004
1140
|
let scope;
|
|
@@ -1011,9 +1147,13 @@ const ruleHalfOf = (tokens, i, ctx) => {
|
|
|
1011
1147
|
consumed = 1;
|
|
1012
1148
|
}
|
|
1013
1149
|
else {
|
|
1150
|
+
const bareYear = yearAt(tokens, at);
|
|
1014
1151
|
const inner = ruleLastThisNext(tokens, at, ctx) ??
|
|
1015
1152
|
ruleBareYear(tokens, at, ctx) ??
|
|
1016
|
-
ruleCalendarDate(tokens, at, ctx)
|
|
1153
|
+
ruleCalendarDate(tokens, at, ctx) ??
|
|
1154
|
+
(bareYear !== undefined
|
|
1155
|
+
? { expr: { op: 'literal', date: { year: bareYear } }, consumed: 1, confidence: 0.85, role: 'date' }
|
|
1156
|
+
: undefined);
|
|
1017
1157
|
if (inner && inner.role === 'date') {
|
|
1018
1158
|
unit = halfScopeUnit(inner.expr);
|
|
1019
1159
|
if (unit) {
|
|
@@ -1044,7 +1184,8 @@ const ruleHalfOf = (tokens, i, ctx) => {
|
|
|
1044
1184
|
};
|
|
1045
1185
|
const REL_SYNONYMS = {
|
|
1046
1186
|
last: 'last', previous: 'last', prior: 'last',
|
|
1047
|
-
|
|
1187
|
+
// Not "that": "that year" points back into the conversation, not at now (#35).
|
|
1188
|
+
this: 'this', current: 'this', present: 'this',
|
|
1048
1189
|
next: 'next', coming: 'next', upcoming: 'next', following: 'next',
|
|
1049
1190
|
};
|
|
1050
1191
|
function weekendExpr(dir) {
|
|
@@ -1084,7 +1225,8 @@ const ruleLastThisNext = (tokens, i, ctx) => {
|
|
|
1084
1225
|
return undefined;
|
|
1085
1226
|
}
|
|
1086
1227
|
if (nextWord === 'weekend') {
|
|
1087
|
-
|
|
1228
|
+
// "this weekend" is deictic: the current/upcoming one leads (#23).
|
|
1229
|
+
const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'this';
|
|
1088
1230
|
return { expr: weekendExpr(dir), consumed: 2, confidence: 1, role: 'date' };
|
|
1089
1231
|
}
|
|
1090
1232
|
if (nextWord === 'workweek' || ((nextWord === 'work' || nextWord === 'working') && word(tokens[i + 2]) === 'week')) {
|
|
@@ -1121,11 +1263,12 @@ const ruleLastThisNext = (tokens, i, ctx) => {
|
|
|
1121
1263
|
}
|
|
1122
1264
|
const weekday = WEEKDAY_WORDS[nextWord];
|
|
1123
1265
|
if (weekday) {
|
|
1124
|
-
|
|
1266
|
+
// "this Friday" is deictic: today-or-next leads, whatever the bias (#23).
|
|
1267
|
+
const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'this';
|
|
1125
1268
|
// Only the literal words "next"/"last" carry the dialect ambiguity;
|
|
1126
1269
|
// "upcoming Friday" is strictly the adjacent occurrence (n: 1).
|
|
1127
1270
|
const literalWord = word(tokens[i]);
|
|
1128
|
-
const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== '
|
|
1271
|
+
const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'this';
|
|
1129
1272
|
return {
|
|
1130
1273
|
expr: {
|
|
1131
1274
|
op: 'seek',
|
|
@@ -1392,6 +1535,101 @@ const ruleNextN = (tokens, i) => {
|
|
|
1392
1535
|
};
|
|
1393
1536
|
};
|
|
1394
1537
|
/** "the year [2008]", "the week [31]", "the month", "the day" — with ISO week numbers. */
|
|
1538
|
+
/** Words that open a relative clause after "the <unit>" ("the year we …"). */
|
|
1539
|
+
const RELATIVE_CLAUSE_STARTS = new Set([
|
|
1540
|
+
'the', 'a', 'an', 'we', 'i', 'you', 'they', 'he', 'she', 'it', 'that', 'which', 'when', 'where', 'our',
|
|
1541
|
+
'my', 'your', 'their', 'his', 'her', 'its', 'this', 'these', 'those', 'in', 'during', 'after', 'before',
|
|
1542
|
+
]);
|
|
1543
|
+
/**
|
|
1544
|
+
* "year to date", "YTD", "so far this month", "this quarter so far" (#36):
|
|
1545
|
+
* from the start of the current period up to now — the period itself as the
|
|
1546
|
+
* start operand keeps the period's grain. Ends at now, not at the start of
|
|
1547
|
+
* today, whatever `partialPeriod` says (owner decision D3).
|
|
1548
|
+
*/
|
|
1549
|
+
function toDate(unit) {
|
|
1550
|
+
return { op: 'between', start: snapNow(unit), end: NOW };
|
|
1551
|
+
}
|
|
1552
|
+
const TO_DATE_ABBREV = { ytd: 'year', qtd: 'quarter', mtd: 'month', wtd: 'week' };
|
|
1553
|
+
const ruleToDate = (tokens, i) => {
|
|
1554
|
+
const w = word(tokens[i]);
|
|
1555
|
+
if (w !== undefined && TO_DATE_ABBREV[w]) {
|
|
1556
|
+
return { expr: toDate(TO_DATE_ABBREV[w]), consumed: 1, confidence: 0.95, role: 'date' };
|
|
1557
|
+
}
|
|
1558
|
+
const periodUnit = (u) => {
|
|
1559
|
+
const unit = UNIT_WORDS[u ?? ''];
|
|
1560
|
+
return unit === 'year' || unit === 'quarter' || unit === 'month' || unit === 'week' || unit === 'day'
|
|
1561
|
+
? unit
|
|
1562
|
+
: undefined;
|
|
1563
|
+
};
|
|
1564
|
+
// "year to date" / "year-to-date" (the article form is in ruleTheUnit).
|
|
1565
|
+
const u0 = periodUnit(w);
|
|
1566
|
+
if (u0) {
|
|
1567
|
+
let at = i + 1;
|
|
1568
|
+
if (word(tokens[at]) === '-')
|
|
1569
|
+
at += 1;
|
|
1570
|
+
if (word(tokens[at]) === 'to') {
|
|
1571
|
+
at += 1;
|
|
1572
|
+
if (word(tokens[at]) === '-')
|
|
1573
|
+
at += 1;
|
|
1574
|
+
if (word(tokens[at]) === 'date')
|
|
1575
|
+
return { expr: toDate(u0), consumed: at + 1 - i, confidence: 0.95, role: 'date' };
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// "so far this year" / "so far in the month"
|
|
1579
|
+
if (w === 'so' && word(tokens[i + 1]) === 'far') {
|
|
1580
|
+
let at = i + 2;
|
|
1581
|
+
if (word(tokens[at]) === 'in' || word(tokens[at]) === 'during')
|
|
1582
|
+
at += 1;
|
|
1583
|
+
if (word(tokens[at]) === 'this' || word(tokens[at]) === 'the') {
|
|
1584
|
+
const u = periodUnit(word(tokens[at + 1]));
|
|
1585
|
+
if (u)
|
|
1586
|
+
return { expr: toDate(u), consumed: at + 2 - i, confidence: 0.95, role: 'date' };
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// "this year so far" / "this month to date"
|
|
1590
|
+
if (w === 'this') {
|
|
1591
|
+
const u = periodUnit(word(tokens[i + 1]));
|
|
1592
|
+
const tail = word(tokens[i + 2]);
|
|
1593
|
+
if (u && ((tail === 'so' && word(tokens[i + 3]) === 'far') || (tail === 'to' && word(tokens[i + 3]) === 'date'))) {
|
|
1594
|
+
return { expr: toDate(u), consumed: 4, confidence: 0.95, role: 'date' };
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return undefined;
|
|
1598
|
+
};
|
|
1599
|
+
/**
|
|
1600
|
+
* "trailing twelve months", "TTM", "LTM", "the trailing 4 quarters" (#36):
|
|
1601
|
+
* the N most recent COMPLETE units before the current one — [start of the
|
|
1602
|
+
* current unit − N, start of the current unit). The finance/BI convention
|
|
1603
|
+
* (complete periods compare like with like); "the past 12 months" stays the
|
|
1604
|
+
* rolling window.
|
|
1605
|
+
*/
|
|
1606
|
+
const ruleTrailing = (tokens, i) => {
|
|
1607
|
+
const w = word(tokens[i]);
|
|
1608
|
+
const trailing = (unit, n, consumed) => ({
|
|
1609
|
+
expr: {
|
|
1610
|
+
op: 'span',
|
|
1611
|
+
anchor: { op: 'snap', base: NOW, unit, edge: 'start' },
|
|
1612
|
+
amount: unit === 'quarter' ? { months: -3 * n } : { [`${unit}s`]: -n },
|
|
1613
|
+
},
|
|
1614
|
+
consumed,
|
|
1615
|
+
confidence: 0.95,
|
|
1616
|
+
role: 'date',
|
|
1617
|
+
});
|
|
1618
|
+
if (w === 'ttm' || w === 'ltm')
|
|
1619
|
+
return trailing('month', 12, 1);
|
|
1620
|
+
let at = i;
|
|
1621
|
+
if (w === 'the')
|
|
1622
|
+
at += 1;
|
|
1623
|
+
if (word(tokens[at]) !== 'trailing')
|
|
1624
|
+
return undefined;
|
|
1625
|
+
const n = readNumber(tokens, at + 1);
|
|
1626
|
+
if (!n || !Number.isInteger(n.value) || n.value < 1)
|
|
1627
|
+
return undefined;
|
|
1628
|
+
const unit = UNIT_WORDS[word(tokens[at + 1 + n.consumed]) ?? ''];
|
|
1629
|
+
if (unit !== 'day' && unit !== 'week' && unit !== 'month' && unit !== 'quarter' && unit !== 'year')
|
|
1630
|
+
return undefined;
|
|
1631
|
+
return trailing(unit, n.value, at + 2 + n.consumed - i);
|
|
1632
|
+
};
|
|
1395
1633
|
const ruleTheUnit = (tokens, i) => {
|
|
1396
1634
|
if (word(tokens[i]) !== 'the')
|
|
1397
1635
|
return undefined;
|
|
@@ -1427,14 +1665,17 @@ const ruleTheUnit = (tokens, i) => {
|
|
|
1427
1665
|
const unit = UNIT_WORDS[uw];
|
|
1428
1666
|
if (!unit || unit === 'second' || unit === 'minute' || unit === 'hour')
|
|
1429
1667
|
return undefined;
|
|
1668
|
+
// "over the years" is generic, and "the year the invoice was posted" /
|
|
1669
|
+
// "the month we launched" name a period by a clause, not the current one:
|
|
1670
|
+
// no match rather than a confident "this year" (#35).
|
|
1671
|
+
if (uw.endsWith('s') && UNIT_WORDS[uw.slice(0, -1)] === unit)
|
|
1672
|
+
return undefined;
|
|
1673
|
+
if (RELATIVE_CLAUSE_STARTS.has(word(tokens[i + 2]) ?? ''))
|
|
1674
|
+
return undefined;
|
|
1430
1675
|
// "the year to date", "the month to date"
|
|
1431
1676
|
if (word(tokens[i + 2]) === 'to' && word(tokens[i + 3]) === 'date') {
|
|
1432
1677
|
return {
|
|
1433
|
-
expr:
|
|
1434
|
-
op: 'between',
|
|
1435
|
-
start: { op: 'snap', base: NOW, unit, edge: 'start' },
|
|
1436
|
-
end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
|
|
1437
|
-
},
|
|
1678
|
+
expr: toDate(unit),
|
|
1438
1679
|
consumed: 4,
|
|
1439
1680
|
confidence: 1,
|
|
1440
1681
|
role: 'date',
|
|
@@ -1907,11 +2148,28 @@ const ruleBareYear = (tokens, i) => {
|
|
|
1907
2148
|
if (!y || y.value < 1500 || y.value > 2199)
|
|
1908
2149
|
return undefined;
|
|
1909
2150
|
const prev = word(tokens[i - 1]);
|
|
1910
|
-
|
|
1911
|
-
|
|
2151
|
+
// "2024 or 2025", "in 2024 and 2025" (#35): the second year of a
|
|
2152
|
+
// coordination is its own match instead of being dropped.
|
|
2153
|
+
const coordinated = prev !== undefined && ['or', 'and', '&', 'vs', 'versus'].includes(prev) && yearAt(tokens, i - 2) !== undefined;
|
|
2154
|
+
if (prev !== undefined &&
|
|
2155
|
+
!coordinated &&
|
|
2156
|
+
!['in', 'since', 'by', 'until', 'during', 'year', 'of', 'from', 'for', 'calendar', 'cy', 'throughout'].includes(prev)) {
|
|
2157
|
+
return undefined;
|
|
2158
|
+
}
|
|
2159
|
+
// "for 2024" / "revenue for 2024" (#36) — but not "for 2000 people": after
|
|
2160
|
+
// "for", the year must end the phrase or be followed by a function word.
|
|
2161
|
+
if (prev === 'for') {
|
|
2162
|
+
const next = tokens[i + y.consumed];
|
|
2163
|
+
const nw = word(next);
|
|
2164
|
+
if (next !== undefined && (nw === undefined || !YEAR_FOLLOWERS.has(nw)))
|
|
2165
|
+
return undefined;
|
|
1912
2166
|
}
|
|
1913
2167
|
return { expr: { op: 'literal', date: { year: y.value } }, consumed: y.consumed, confidence: 0.85, role: 'date' };
|
|
1914
2168
|
};
|
|
2169
|
+
const YEAR_FOLLOWERS = new Set([
|
|
2170
|
+
'and', 'or', 'vs', 'versus', 'compared', 'to', 'through', 'thru', 'by', 'in', 'on', 'at', 'from', 'with',
|
|
2171
|
+
'was', 'were', 'is', 'are', 'only', 'alone', 'so', 'than', 'but', '-', '?', '.', ';', ':', ')',
|
|
2172
|
+
]);
|
|
1915
2173
|
/** Decades ("1990s", "the 90s") and centuries ("21st century"). */
|
|
1916
2174
|
const ruleDecadeCentury = (tokens, i) => {
|
|
1917
2175
|
const t = tokens[i];
|
|
@@ -2944,6 +3202,10 @@ export const EN_RULE_ENTRIES = [
|
|
|
2944
3202
|
{ name: 'bare-year', rule: ruleBareYear },
|
|
2945
3203
|
{ name: 'calendar-date', rule: ruleCalendarDate },
|
|
2946
3204
|
{ name: 'clock-time', rule: ruleClockTime },
|
|
3205
|
+
{ name: 'weekday-date', rule: ruleWeekdayDate },
|
|
3206
|
+
{ name: 'n-units-ended', rule: ruleNUnitsEnded },
|
|
3207
|
+
{ name: 'to-date', rule: ruleToDate },
|
|
3208
|
+
{ name: 'trailing', rule: ruleTrailing },
|
|
2947
3209
|
{ name: 'weekday-day', rule: ruleWeekdayDay },
|
|
2948
3210
|
{ name: 'weekday-alone', rule: ruleWeekdayAlone },
|
|
2949
3211
|
{ name: 'period-alone', rule: rulePeriodAlone },
|
package/dist/parse/index.js
CHANGED
|
@@ -71,7 +71,7 @@ function parseWithRules(text, ctx, rules) {
|
|
|
71
71
|
text: text.slice(startTok.start, endTok.end),
|
|
72
72
|
start: startTok.start,
|
|
73
73
|
end: endTok.end,
|
|
74
|
-
expr: m.expr,
|
|
74
|
+
expr: wholeAmounts(m.expr),
|
|
75
75
|
confidence: m.confidence,
|
|
76
76
|
source: 'rules',
|
|
77
77
|
};
|
|
@@ -156,8 +156,14 @@ function mergeAdjacent(matches, tokens, connectors) {
|
|
|
156
156
|
dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
// "end of day yesterday" / "yesterday at start of day": a bare day
|
|
160
|
+
// edge (built against today) moves onto the date it modifies instead
|
|
161
|
+
// of intersecting today's edge with that date, which is empty (#33).
|
|
162
|
+
const dayEdge = todayDayEdge(timeExpr);
|
|
159
163
|
out.push({
|
|
160
|
-
expr:
|
|
164
|
+
expr: dayEdge
|
|
165
|
+
? { op: 'snap', base: dateExpr, unit: 'day', edge: dayEdge }
|
|
166
|
+
: { op: 'intersect', parts: [dateExpr, timeExpr] },
|
|
161
167
|
consumed: next.tokenEnd - cur.tokenStart,
|
|
162
168
|
confidence: Math.max(cur.confidence, next.confidence),
|
|
163
169
|
role: 'datetime',
|
|
@@ -238,6 +244,38 @@ function forceMeridiem(expr, period) {
|
|
|
238
244
|
const meridiem = period === 'morning' ? 'am' : (period === 'night' || period === 'evening') && hour <= 5 ? 'am' : 'pm';
|
|
239
245
|
return { ...expr, time: { ...expr.time, meridiem } };
|
|
240
246
|
}
|
|
247
|
+
const AMOUNT_SECONDS = {
|
|
248
|
+
seconds: 1,
|
|
249
|
+
minutes: 60,
|
|
250
|
+
hours: 3600,
|
|
251
|
+
days: 86400,
|
|
252
|
+
weeks: 604800,
|
|
253
|
+
months: 2592000,
|
|
254
|
+
years: 31536000,
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Calendar amounts are integers in the IR. A fractional one ("3,5 años",
|
|
258
|
+
* "半年", "123,45 secondes") becomes an exact duration, using the same
|
|
259
|
+
* nominal unit lengths as the English duration rules (#16).
|
|
260
|
+
*/
|
|
261
|
+
function wholeAmounts(expr) {
|
|
262
|
+
if (expr.op !== 'amount')
|
|
263
|
+
return expr;
|
|
264
|
+
const entries = Object.entries(expr.amount);
|
|
265
|
+
if (entries.every(([, v]) => Number.isInteger(v)))
|
|
266
|
+
return expr;
|
|
267
|
+
let secs = 0;
|
|
268
|
+
for (const [k, v] of entries)
|
|
269
|
+
secs += v * (AMOUNT_SECONDS[k] ?? 0);
|
|
270
|
+
return { op: 'duration', iso: `PT${Math.round(secs * 100) / 100}S` };
|
|
271
|
+
}
|
|
272
|
+
/** The edge of a bare "start/end of day" point anchored to today, if `expr` is one. */
|
|
273
|
+
function todayDayEdge(expr) {
|
|
274
|
+
if (expr.op !== 'snap' || expr.unit !== 'day' || !expr.edge)
|
|
275
|
+
return undefined;
|
|
276
|
+
const b = expr.base;
|
|
277
|
+
return b.op === 'snap' && b.unit === 'day' && !b.edge && b.base.op === 'now' ? expr.edge : undefined;
|
|
278
|
+
}
|
|
241
279
|
/** Remove day-period literals from an intersect; undefined if nothing remains. */
|
|
242
280
|
function stripDayPeriod(expr) {
|
|
243
281
|
if (expr.op === 'literal' && expr.dayPeriod && !expr.date && !expr.time)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nl2time",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Bidirectional natural language
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Bidirectional natural language ⇄ date/time: parse expressions like 'last week' into concrete intervals, and describe instants as '9pm last night' — deterministic core, optional LLM assist.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"locale"
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"temporal-polyfill": "^0.
|
|
51
|
+
"temporal-polyfill": "^1.0.6"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^26.1.1",
|
|
55
|
-
"typescript": "^
|
|
56
|
-
"vitest": "^
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vitest": "^5.0.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|
|
60
60
|
}
|
|
61
|
-
}
|
|
61
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
{ "$ref": "#/$defs/Snap" },
|
|
14
14
|
{ "$ref": "#/$defs/Span" },
|
|
15
15
|
{ "$ref": "#/$defs/Between" },
|
|
16
|
+
{ "$ref": "#/$defs/Until" },
|
|
16
17
|
{ "$ref": "#/$defs/Seek" },
|
|
17
18
|
{ "$ref": "#/$defs/Intersect" },
|
|
18
19
|
{ "$ref": "#/$defs/Duration" },
|
|
@@ -157,13 +158,25 @@
|
|
|
157
158
|
"required": ["op", "start", "end"],
|
|
158
159
|
"additionalProperties": false
|
|
159
160
|
},
|
|
161
|
+
"Until": {
|
|
162
|
+
"type": "object",
|
|
163
|
+
"description": "The stretch up to end ('by Friday', 'until 2025-06-30', 'before March 2022'): [now, end) while end is ahead of now, otherwise an open-start range ending at end (resolved values carry start: null). exclusive ('before') ranks the reading that stops at the start of end first.",
|
|
164
|
+
"properties": {
|
|
165
|
+
"op": { "const": "until" },
|
|
166
|
+
"end": { "$ref": "#/$defs/TimeExpr" },
|
|
167
|
+
"exclusive": { "type": "boolean" },
|
|
168
|
+
"mod": { "$ref": "#/$defs/Mod" }
|
|
169
|
+
},
|
|
170
|
+
"required": ["op", "end"],
|
|
171
|
+
"additionalProperties": false
|
|
172
|
+
},
|
|
160
173
|
"Seek": {
|
|
161
174
|
"type": "object",
|
|
162
175
|
"description": "Directed calendar navigation: seek(now, next, weekday tue) is 'next Tuesday' (exact semantics owned by the context nextWeekday policy); with n, the nth occurrence within the base interval ('the 2nd Monday of March').",
|
|
163
176
|
"properties": {
|
|
164
177
|
"op": { "const": "seek" },
|
|
165
178
|
"base": { "$ref": "#/$defs/TimeExpr" },
|
|
166
|
-
"dir": { "enum": ["next", "prev", "nearest"] },
|
|
179
|
+
"dir": { "enum": ["next", "prev", "nearest", "this"] },
|
|
167
180
|
"target": { "$ref": "#/$defs/Target" },
|
|
168
181
|
"n": { "type": "integer", "minimum": 1 },
|
|
169
182
|
"mod": { "$ref": "#/$defs/Mod" }
|