nl2time 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/clock/index.d.ts +40 -0
- package/dist/clock/index.js +99 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +75 -0
- package/dist/corpus/runner.d.ts +81 -0
- package/dist/corpus/runner.js +220 -0
- package/dist/data/dayPeriods.d.ts +18 -0
- package/dist/data/dayPeriods.js +24 -0
- package/dist/data/weekData.d.ts +11 -0
- package/dist/data/weekData.js +25 -0
- package/dist/describe/index.d.ts +25 -0
- package/dist/describe/index.js +289 -0
- package/dist/engine/resolve.d.ts +31 -0
- package/dist/engine/resolve.js +684 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/ir/types.d.ts +165 -0
- package/dist/ir/types.js +31 -0
- package/dist/ir/validate.d.ts +11 -0
- package/dist/ir/validate.js +205 -0
- package/dist/llm/index.d.ts +26 -0
- package/dist/llm/index.js +89 -0
- package/dist/parse/en.d.ts +18 -0
- package/dist/parse/en.js +2708 -0
- package/dist/parse/index.d.ts +20 -0
- package/dist/parse/index.js +230 -0
- package/dist/parse/tokenizer.d.ts +33 -0
- package/dist/parse/tokenizer.js +118 -0
- package/package.json +56 -0
- package/schema/timeexpr.schema.json +239 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
|
|
2
|
+
import { dayPeriodRules } from '../data/dayPeriods.js';
|
|
3
|
+
export class NotResolvableError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'NotResolvableError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class AmbiguityError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'AmbiguityError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const GRAIN_ORDER = [
|
|
16
|
+
'instant',
|
|
17
|
+
'second',
|
|
18
|
+
'minute',
|
|
19
|
+
'hour',
|
|
20
|
+
'day',
|
|
21
|
+
'week',
|
|
22
|
+
'month',
|
|
23
|
+
'quarter',
|
|
24
|
+
'year',
|
|
25
|
+
];
|
|
26
|
+
function finerGrain(a, b) {
|
|
27
|
+
return GRAIN_ORDER.indexOf(a) <= GRAIN_ORDER.indexOf(b) ? a : b;
|
|
28
|
+
}
|
|
29
|
+
const MAX_CANDIDATES = 8;
|
|
30
|
+
export function resolve(expr, ctx) {
|
|
31
|
+
const cands = evalExpr(expr, ctx, undefined).slice(0, MAX_CANDIDATES);
|
|
32
|
+
return { candidates: cands.map((c) => toValue(c)) };
|
|
33
|
+
}
|
|
34
|
+
/** First candidate under policy, or throw. */
|
|
35
|
+
export function resolveOne(expr, ctx) {
|
|
36
|
+
const { candidates } = resolve(expr, ctx);
|
|
37
|
+
const first = candidates[0];
|
|
38
|
+
if (!first)
|
|
39
|
+
throw new AmbiguityError('expression produced no candidates');
|
|
40
|
+
return first;
|
|
41
|
+
}
|
|
42
|
+
function toValue(c) {
|
|
43
|
+
switch (c.type) {
|
|
44
|
+
case 'interval':
|
|
45
|
+
return {
|
|
46
|
+
kind: 'interval',
|
|
47
|
+
start: c.zi.start.toInstant(),
|
|
48
|
+
end: c.zi.end.toInstant(),
|
|
49
|
+
grain: c.zi.grain,
|
|
50
|
+
};
|
|
51
|
+
case 'duration':
|
|
52
|
+
return { kind: 'duration', iso: c.iso };
|
|
53
|
+
case 'amount':
|
|
54
|
+
return { kind: 'amount', amount: c.amount };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Evaluate an expression to ordered candidates. `anchor` carries the date
|
|
59
|
+
* context inside an intersect ("tomorrow at 4" evaluates the time literal
|
|
60
|
+
* against tomorrow, not today).
|
|
61
|
+
*/
|
|
62
|
+
function evalExpr(expr, ctx, anchor) {
|
|
63
|
+
const cands = evalExprInner(expr, ctx, anchor);
|
|
64
|
+
// mod 'start'/'end' narrow an interval to its first/second half ("early
|
|
65
|
+
// July", "later this week"). Mid-points floor to the day for week+ grains
|
|
66
|
+
// (month/year to coarser boundaries), and when the reference day falls
|
|
67
|
+
// inside the interval it tightens the bound ("earlier this year" ends
|
|
68
|
+
// today, not at midyear).
|
|
69
|
+
if (expr.mod === 'start' || expr.mod === 'end') {
|
|
70
|
+
return intervalMap(cands, (zi) => {
|
|
71
|
+
if (zi.grain === 'instant')
|
|
72
|
+
return zi;
|
|
73
|
+
const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
|
|
74
|
+
let mid = zi.start.add({ milliseconds: Math.floor(totalMs / 2) });
|
|
75
|
+
const gi = GRAIN_ORDER.indexOf(zi.grain);
|
|
76
|
+
if (zi.grain === 'year')
|
|
77
|
+
mid = floorTo(mid, 'month', ctx.weekStart);
|
|
78
|
+
else if (gi > GRAIN_ORDER.indexOf('day'))
|
|
79
|
+
mid = floorTo(mid, 'day', ctx.weekStart);
|
|
80
|
+
let bound = mid;
|
|
81
|
+
if (gi > GRAIN_ORDER.indexOf('day')) {
|
|
82
|
+
const refDay = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
|
|
83
|
+
const inRange = Temporal_compare(refDay, zi.start) > 0 && Temporal_compare(refDay, zi.end) < 0;
|
|
84
|
+
if (inRange) {
|
|
85
|
+
bound =
|
|
86
|
+
expr.mod === 'start'
|
|
87
|
+
? Temporal_compare(refDay, mid) < 0
|
|
88
|
+
? refDay
|
|
89
|
+
: mid
|
|
90
|
+
: Temporal_compare(refDay, mid) > 0
|
|
91
|
+
? refDay
|
|
92
|
+
: mid;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const narrowed = expr.mod === 'start'
|
|
96
|
+
? { start: zi.start, end: bound, grain: zi.grain }
|
|
97
|
+
: { start: bound, end: zi.end, grain: zi.grain };
|
|
98
|
+
// A degenerate clamp (bound at an edge) falls back to the plain half.
|
|
99
|
+
if (Temporal_compare(narrowed.start, narrowed.end) >= 0) {
|
|
100
|
+
return expr.mod === 'start'
|
|
101
|
+
? { start: zi.start, end: mid, grain: zi.grain }
|
|
102
|
+
: { start: mid, end: zi.end, grain: zi.grain };
|
|
103
|
+
}
|
|
104
|
+
return narrowed;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// mod 'mid': the middle stretch. Conventions follow common usage: mid-month
|
|
108
|
+
// = the 10th through the 20th; mid-day = 10:00–14:00; otherwise the middle
|
|
109
|
+
// half of the interval.
|
|
110
|
+
if (expr.mod === 'mid') {
|
|
111
|
+
return intervalMap(cands, (zi) => {
|
|
112
|
+
if (zi.grain === 'month') {
|
|
113
|
+
return { start: zi.start.add({ days: 9 }), end: zi.start.add({ days: 20 }), grain: 'day' };
|
|
114
|
+
}
|
|
115
|
+
if (zi.grain === 'day') {
|
|
116
|
+
return { start: zi.start.add({ hours: 10 }), end: zi.start.add({ hours: 14 }), grain: 'hour' };
|
|
117
|
+
}
|
|
118
|
+
const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
|
|
119
|
+
return {
|
|
120
|
+
start: zi.start.add({ milliseconds: Math.floor(totalMs / 4) }),
|
|
121
|
+
end: zi.start.add({ milliseconds: Math.floor((3 * totalMs) / 4) }),
|
|
122
|
+
grain: zi.grain,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return cands;
|
|
127
|
+
}
|
|
128
|
+
function evalExprInner(expr, ctx, anchor) {
|
|
129
|
+
switch (expr.op) {
|
|
130
|
+
case 'now':
|
|
131
|
+
return [{ type: 'interval', zi: pointInterval(ctx.zonedNow) }];
|
|
132
|
+
case 'literal':
|
|
133
|
+
return evalLiteral(expr, ctx, anchor);
|
|
134
|
+
case 'offset':
|
|
135
|
+
return intervalMap(evalExpr(expr.base, ctx, anchor), (zi) => ({
|
|
136
|
+
start: addUnits(zi.start, expr.unit, expr.amount),
|
|
137
|
+
end: addUnits(zi.end, expr.unit, expr.amount),
|
|
138
|
+
grain: zi.grain,
|
|
139
|
+
}));
|
|
140
|
+
case 'snap':
|
|
141
|
+
return intervalMap(evalExpr(expr.base, ctx, anchor), (zi) => {
|
|
142
|
+
const unit = containingUnit(zi.start, expr.unit, ctx.weekStart);
|
|
143
|
+
if (expr.edge === 'start')
|
|
144
|
+
return pointInterval(unit.start);
|
|
145
|
+
if (expr.edge === 'end')
|
|
146
|
+
return pointInterval(unit.end);
|
|
147
|
+
return unit;
|
|
148
|
+
});
|
|
149
|
+
case 'span':
|
|
150
|
+
return intervalMap(evalExpr(expr.anchor, ctx, anchor), (zi) => expr.business ? evalBusinessSpan(zi, expr.amount, ctx) : evalSpan(zi, expr.amount, ctx));
|
|
151
|
+
case 'between': {
|
|
152
|
+
const starts = evalExpr(expr.start, ctx, anchor);
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const s of starts) {
|
|
155
|
+
if (s.type !== 'interval')
|
|
156
|
+
continue;
|
|
157
|
+
// The end operand resolves anchored to the start (shared context:
|
|
158
|
+
// "between 3 and 12 of Sept" — the 3 anchors to September).
|
|
159
|
+
for (const e of evalExpr(expr.end, ctx, s.zi)) {
|
|
160
|
+
if (e.type !== 'interval')
|
|
161
|
+
continue;
|
|
162
|
+
// Non-point ends are exclusive at their *start* ("May 2 to May 7"
|
|
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;
|
|
165
|
+
const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day');
|
|
166
|
+
if (Temporal_compare(s.zi.start, endPoint) >= 0) {
|
|
167
|
+
// A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
|
|
168
|
+
// the end into the next day; date ranges just drop.
|
|
169
|
+
if (!timeGrained)
|
|
170
|
+
continue;
|
|
171
|
+
endPoint = endPoint.add({ days: 1 });
|
|
172
|
+
if (Temporal_compare(s.zi.start, endPoint) >= 0)
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
out.push({
|
|
176
|
+
type: 'interval',
|
|
177
|
+
zi: {
|
|
178
|
+
start: s.zi.start,
|
|
179
|
+
end: endPoint,
|
|
180
|
+
grain: finerGrain(s.zi.grain, e.zi.grain),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
case 'seek':
|
|
188
|
+
return evalSeek(expr, ctx, anchor);
|
|
189
|
+
case 'intersect': {
|
|
190
|
+
// Left-to-right: each part is evaluated with the accumulated interval as
|
|
191
|
+
// its anchor. Time-of-day parts (a clock time or day period with no date
|
|
192
|
+
// of their own) COMPOSE onto the anchor day — "last night" is 21:00 of
|
|
193
|
+
// yesterday wrapping to 06:00 today, which must not be clipped at the
|
|
194
|
+
// day boundary. Parts that carry their own date CONSTRAIN by interval
|
|
195
|
+
// intersection.
|
|
196
|
+
let acc = evalExpr(expr.parts[0], ctx, anchor);
|
|
197
|
+
for (const part of expr.parts.slice(1)) {
|
|
198
|
+
const partIsTimeOfDay = isTimeOfDayPart(part);
|
|
199
|
+
const next = [];
|
|
200
|
+
for (const a of acc) {
|
|
201
|
+
if (a.type !== 'interval')
|
|
202
|
+
continue;
|
|
203
|
+
// Clock times always compose onto the anchor's day ("tonight at
|
|
204
|
+
// 1am" escapes the night window). Day-period parts compose only
|
|
205
|
+
// onto day-or-coarser anchors; onto an already time-refined
|
|
206
|
+
// interval ("10am to 12pm Monday morning") they constrain.
|
|
207
|
+
const composes = partIsTimeOfDay &&
|
|
208
|
+
(isClockPart(part) || GRAIN_ORDER.indexOf(a.zi.grain) >= GRAIN_ORDER.indexOf('day'));
|
|
209
|
+
for (const b of evalExpr(part, ctx, a.zi)) {
|
|
210
|
+
if (b.type !== 'interval')
|
|
211
|
+
continue;
|
|
212
|
+
if (composes) {
|
|
213
|
+
next.push(b);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const zi = intersectIntervals(a.zi, b.zi);
|
|
217
|
+
if (zi)
|
|
218
|
+
next.push({ type: 'interval', zi });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
acc = next;
|
|
222
|
+
}
|
|
223
|
+
return acc;
|
|
224
|
+
}
|
|
225
|
+
case 'duration':
|
|
226
|
+
return [{ type: 'duration', iso: expr.iso }];
|
|
227
|
+
case 'amount':
|
|
228
|
+
return [{ type: 'amount', amount: expr.amount }];
|
|
229
|
+
case 'holiday':
|
|
230
|
+
return evalHoliday(expr, ctx);
|
|
231
|
+
case 'recur':
|
|
232
|
+
throw new NotResolvableError('recurrence expressions are representable in v1 but not yet resolvable (planned for v2)');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A literal clock reading (no date of its own). */
|
|
236
|
+
function isClockPart(expr) {
|
|
237
|
+
return expr.op === 'literal' && expr.date === undefined && expr.time !== undefined;
|
|
238
|
+
}
|
|
239
|
+
/** A part that names a time within a day rather than a day: composes onto its anchor. */
|
|
240
|
+
function isTimeOfDayPart(expr) {
|
|
241
|
+
if (expr.op === 'literal')
|
|
242
|
+
return expr.date === undefined && (expr.time !== undefined || expr.dayPeriod !== undefined);
|
|
243
|
+
if (expr.op === 'seek')
|
|
244
|
+
return expr.target.kind === 'dayPeriod';
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
function intervalMap(cands, f) {
|
|
248
|
+
return cands.map((c) => (c.type === 'interval' ? { type: 'interval', zi: f(c.zi) } : c));
|
|
249
|
+
}
|
|
250
|
+
function Temporal_compare(a, b) {
|
|
251
|
+
const x = a.epochNanoseconds;
|
|
252
|
+
const y = b.epochNanoseconds;
|
|
253
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
254
|
+
}
|
|
255
|
+
function intersectIntervals(a, b) {
|
|
256
|
+
// Point intervals (grain 'instant') act as points-in-time; containment check.
|
|
257
|
+
const start = Temporal_compare(a.start, b.start) >= 0 ? a.start : b.start;
|
|
258
|
+
const end = Temporal_compare(a.end, b.end) <= 0 ? a.end : b.end;
|
|
259
|
+
if (Temporal_compare(start, end) > 0)
|
|
260
|
+
return undefined;
|
|
261
|
+
if (Temporal_compare(start, end) === 0 && a.grain !== 'instant' && b.grain !== 'instant') {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return { start, end, grain: finerGrain(a.grain, b.grain) };
|
|
265
|
+
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// span
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
function smallestUnitOf(amount) {
|
|
270
|
+
if (amount.seconds)
|
|
271
|
+
return 'second';
|
|
272
|
+
if (amount.minutes)
|
|
273
|
+
return 'minute';
|
|
274
|
+
if (amount.hours)
|
|
275
|
+
return 'hour';
|
|
276
|
+
if (amount.days)
|
|
277
|
+
return 'day';
|
|
278
|
+
if (amount.weeks)
|
|
279
|
+
return 'week';
|
|
280
|
+
if (amount.months)
|
|
281
|
+
return 'month';
|
|
282
|
+
return 'year';
|
|
283
|
+
}
|
|
284
|
+
function addAmount(z, amount, factor) {
|
|
285
|
+
const scaled = {};
|
|
286
|
+
for (const [k, v] of Object.entries(amount)) {
|
|
287
|
+
if (typeof v === 'number' && v !== 0)
|
|
288
|
+
scaled[k] = v * factor;
|
|
289
|
+
}
|
|
290
|
+
return z.add(scaled);
|
|
291
|
+
}
|
|
292
|
+
function evalSpan(anchorZi, amount, ctx) {
|
|
293
|
+
const negative = Object.values(amount).some((v) => typeof v === 'number' && v < 0);
|
|
294
|
+
const unit = smallestUnitOf(amount);
|
|
295
|
+
const coarse = GRAIN_ORDER.indexOf(unit) >= GRAIN_ORDER.indexOf('day');
|
|
296
|
+
let anchorPoint = negative ? anchorZi.end : anchorZi.start;
|
|
297
|
+
// Complete-periods policy: "the last 3 days" ends at today's start; "the
|
|
298
|
+
// next 3 days" begins at tomorrow's start. Applies only to spans anchored
|
|
299
|
+
// at the reference instant itself — an explicit anchor point ("the week
|
|
300
|
+
// starting Monday") is exact.
|
|
301
|
+
if (ctx.partialPeriod === 'exclude' &&
|
|
302
|
+
anchorZi.grain === 'instant' &&
|
|
303
|
+
Temporal_compare(anchorPoint, ctx.zonedNow) === 0 &&
|
|
304
|
+
coarse) {
|
|
305
|
+
anchorPoint = floorTo(anchorPoint, 'day', ctx.weekStart);
|
|
306
|
+
if (!negative)
|
|
307
|
+
anchorPoint = anchorPoint.add({ days: 1 });
|
|
308
|
+
}
|
|
309
|
+
const other = addAmount(anchorPoint, amount, 1);
|
|
310
|
+
const [start, end] = Temporal_compare(anchorPoint, other) <= 0 ? [anchorPoint, other] : [other, anchorPoint];
|
|
311
|
+
return { start, end, grain: unit };
|
|
312
|
+
}
|
|
313
|
+
/** Business-day span: counts weekdays only, from the day after/before the anchor. */
|
|
314
|
+
function evalBusinessSpan(anchorZi, amount, ctx) {
|
|
315
|
+
const n = amount.days ?? 0;
|
|
316
|
+
const negative = n < 0;
|
|
317
|
+
let day = floorTo(negative ? anchorZi.end : anchorZi.start, 'day', ctx.weekStart);
|
|
318
|
+
const step = negative ? -1 : 1;
|
|
319
|
+
let first;
|
|
320
|
+
let last;
|
|
321
|
+
let remaining = Math.abs(n);
|
|
322
|
+
while (remaining > 0) {
|
|
323
|
+
day = day.add({ days: step });
|
|
324
|
+
if (day.dayOfWeek >= 6)
|
|
325
|
+
continue;
|
|
326
|
+
if (!first)
|
|
327
|
+
first = day;
|
|
328
|
+
last = day;
|
|
329
|
+
remaining -= 1;
|
|
330
|
+
}
|
|
331
|
+
const [start, end] = negative
|
|
332
|
+
? [last, first.add({ days: 1 })]
|
|
333
|
+
: [first, last.add({ days: 1 })];
|
|
334
|
+
return { start, end, grain: 'day' };
|
|
335
|
+
}
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// seek
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
function evalSeek(expr, ctx, anchor) {
|
|
340
|
+
const bases = evalExpr(expr.base, ctx, anchor);
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const b of bases) {
|
|
343
|
+
if (b.type !== 'interval')
|
|
344
|
+
continue;
|
|
345
|
+
// A bare weekday ("Friday", dir nearest, from a point) is genuinely
|
|
346
|
+
// ambiguous: produce both directions, bias-ordered.
|
|
347
|
+
if (expr.dir === 'nearest' &&
|
|
348
|
+
expr.target.kind === 'weekday' &&
|
|
349
|
+
(b.zi.grain === 'instant' || b.zi.grain === 'day')) {
|
|
350
|
+
// Strict adjacent occurrences (n: 1) — the dialect policy is for
|
|
351
|
+
// explicit "next X"/"last X", not for a bare weekday.
|
|
352
|
+
const fwd = seekFrom(b.zi, { ...expr, dir: 'next', n: 1 }, ctx);
|
|
353
|
+
const back = seekFrom(b.zi, { ...expr, dir: 'prev', n: 1 }, ctx);
|
|
354
|
+
const ordered = ctx.bias === 'past' ? [back, fwd] : [fwd, back];
|
|
355
|
+
for (const zi of ordered)
|
|
356
|
+
if (zi)
|
|
357
|
+
out.push({ type: 'interval', zi });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const zi = seekFrom(b.zi, expr, ctx);
|
|
361
|
+
if (zi)
|
|
362
|
+
out.push({ type: 'interval', zi });
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
function seekFrom(base, expr, ctx) {
|
|
367
|
+
const t = expr.target;
|
|
368
|
+
switch (t.kind) {
|
|
369
|
+
case 'weekday': {
|
|
370
|
+
const target = weekdayNumber(t.weekday);
|
|
371
|
+
const n = expr.n ?? 1;
|
|
372
|
+
if (base.grain !== 'instant' && GRAIN_ORDER.indexOf(base.grain) > GRAIN_ORDER.indexOf('day')) {
|
|
373
|
+
// "the 2nd Monday of March": nth occurrence inside the base interval.
|
|
374
|
+
let day = floorTo(base.start, 'day', ctx.weekStart);
|
|
375
|
+
const delta = (target - day.dayOfWeek + 7) % 7;
|
|
376
|
+
day = day.add({ days: delta + 7 * (n - 1) });
|
|
377
|
+
if (Temporal_compare(day, base.end) >= 0)
|
|
378
|
+
return undefined;
|
|
379
|
+
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
380
|
+
}
|
|
381
|
+
// Deictic navigation from a point/day ("next Tuesday").
|
|
382
|
+
const today = floorTo(base.start, 'day', ctx.weekStart);
|
|
383
|
+
const dow = today.dayOfWeek;
|
|
384
|
+
let day;
|
|
385
|
+
// The nextWeekday dialect policy applies only to bare "next/last
|
|
386
|
+
// <weekday>"; an explicit n means strict occurrence counting.
|
|
387
|
+
const usePolicy = ctx.nextWeekday === 'week-after' && expr.n === undefined;
|
|
388
|
+
if (expr.dir === 'next') {
|
|
389
|
+
if (usePolicy) {
|
|
390
|
+
const weekStartDay = floorTo(today.add({ days: 7 }), 'week', ctx.weekStart);
|
|
391
|
+
const delta = (target - weekStartDay.dayOfWeek + 7) % 7;
|
|
392
|
+
day = weekStartDay.add({ days: delta });
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const delta = ((target - dow + 6) % 7) + 1; // strictly after today
|
|
396
|
+
day = today.add({ days: delta + 7 * (n - 1) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (expr.dir === 'prev') {
|
|
400
|
+
if (usePolicy) {
|
|
401
|
+
// Symmetric dialect: "last Tuesday" = Tuesday of the previous week.
|
|
402
|
+
const weekStartDay = floorTo(today.subtract({ days: 7 }), 'week', ctx.weekStart);
|
|
403
|
+
const delta = (target - weekStartDay.dayOfWeek + 7) % 7;
|
|
404
|
+
day = weekStartDay.add({ days: delta });
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const delta = ((dow - target + 6) % 7) + 1; // strictly before today
|
|
408
|
+
day = today.subtract({ days: delta + 7 * (n - 1) });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
// 'nearest': bias decides; 'none' behaves like future ("Friday" usually
|
|
413
|
+
// means the upcoming one in conversation).
|
|
414
|
+
const forwardDelta = ((target - dow + 6) % 7) + 1;
|
|
415
|
+
const backDelta = ((dow - target + 6) % 7) + 1;
|
|
416
|
+
day =
|
|
417
|
+
ctx.bias === 'past'
|
|
418
|
+
? today.subtract({ days: backDelta })
|
|
419
|
+
: today.add({ days: forwardDelta });
|
|
420
|
+
}
|
|
421
|
+
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
422
|
+
}
|
|
423
|
+
case 'month': {
|
|
424
|
+
const refYearStart = floorTo(base.start, 'year', ctx.weekStart);
|
|
425
|
+
let m = refYearStart.with({ month: t.month });
|
|
426
|
+
const cmp = Temporal_compare(m, base.start);
|
|
427
|
+
if (expr.dir === 'next' && cmp <= 0)
|
|
428
|
+
m = m.add({ years: 1 });
|
|
429
|
+
if (expr.dir === 'prev' && cmp >= 0)
|
|
430
|
+
m = m.subtract({ years: 1 });
|
|
431
|
+
const start = floorTo(m, 'month', ctx.weekStart);
|
|
432
|
+
return { start, end: addUnits(start, 'month', 1), grain: 'month' };
|
|
433
|
+
}
|
|
434
|
+
case 'dayPeriod': {
|
|
435
|
+
return dayPeriodInterval(base, t.period, ctx);
|
|
436
|
+
}
|
|
437
|
+
case 'unit': {
|
|
438
|
+
const cur = containingUnit(base.start, t.unit, ctx.weekStart);
|
|
439
|
+
const n = expr.n ?? 1;
|
|
440
|
+
const shift = expr.dir === 'next' ? n : expr.dir === 'prev' ? -n : 0;
|
|
441
|
+
return {
|
|
442
|
+
start: addUnits(cur.start, t.unit, shift),
|
|
443
|
+
end: addUnits(cur.end, t.unit, shift),
|
|
444
|
+
grain: t.unit,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** The day-period interval within (anchored to) the day of `base`. */
|
|
450
|
+
export function dayPeriodInterval(base, period, ctx) {
|
|
451
|
+
const rules = ctx.dayPeriods ?? dayPeriodRules(ctx.language);
|
|
452
|
+
const rule = rules.find((r) => r.period === period);
|
|
453
|
+
if (!rule)
|
|
454
|
+
return undefined;
|
|
455
|
+
const day = floorTo(base.start, 'day', ctx.weekStart);
|
|
456
|
+
const start = day.add({ hours: rule.from });
|
|
457
|
+
const end = rule.before > rule.from
|
|
458
|
+
? day.add({ hours: rule.before })
|
|
459
|
+
: day.add({ days: 1 }).add({ hours: rule.before });
|
|
460
|
+
return { start, end, grain: 'hour' };
|
|
461
|
+
}
|
|
462
|
+
const HOLIDAYS = {
|
|
463
|
+
'earth-day': { kind: 'fixed', month: 4, day: 22 },
|
|
464
|
+
'st-patricks': { kind: 'fixed', month: 3, day: 17 },
|
|
465
|
+
'workers-day': { kind: 'fixed', month: 5, day: 1 },
|
|
466
|
+
'new-year': { kind: 'fixed', month: 1, day: 1 },
|
|
467
|
+
'new-year-eve': { kind: 'fixed', month: 12, day: 31 },
|
|
468
|
+
valentines: { kind: 'fixed', month: 2, day: 14 },
|
|
469
|
+
halloween: { kind: 'fixed', month: 10, day: 31 },
|
|
470
|
+
christmas: { kind: 'fixed', month: 12, day: 25 },
|
|
471
|
+
'christmas-eve': { kind: 'fixed', month: 12, day: 24 },
|
|
472
|
+
'independence-day': { kind: 'fixed', month: 7, day: 4 },
|
|
473
|
+
thanksgiving: { kind: 'nth-weekday', month: 11, weekday: 4, n: 4 },
|
|
474
|
+
'labor-day': { kind: 'nth-weekday', month: 9, weekday: 1, n: 1 },
|
|
475
|
+
'mothers-day': { kind: 'nth-weekday', month: 5, weekday: 7, n: 2 },
|
|
476
|
+
'fathers-day': { kind: 'nth-weekday', month: 6, weekday: 7, n: 3 },
|
|
477
|
+
'memorial-day': { kind: 'last-weekday', month: 5, weekday: 1 },
|
|
478
|
+
easter: { kind: 'easter' },
|
|
479
|
+
};
|
|
480
|
+
/** Anonymous Gregorian computus. */
|
|
481
|
+
function easterDate(year) {
|
|
482
|
+
const a = year % 19;
|
|
483
|
+
const b = Math.floor(year / 100);
|
|
484
|
+
const c = year % 100;
|
|
485
|
+
const d = Math.floor(b / 4);
|
|
486
|
+
const e = b % 4;
|
|
487
|
+
const f = Math.floor((b + 8) / 25);
|
|
488
|
+
const g = Math.floor((b - f + 1) / 3);
|
|
489
|
+
const h = (19 * a + b - d - g + 15) % 30;
|
|
490
|
+
const i = Math.floor(c / 4);
|
|
491
|
+
const k = c % 4;
|
|
492
|
+
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
493
|
+
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
494
|
+
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
495
|
+
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
496
|
+
return { month, day };
|
|
497
|
+
}
|
|
498
|
+
function holidayDay(name, year, ctx) {
|
|
499
|
+
if (name === 'black-friday') {
|
|
500
|
+
return holidayDay('thanksgiving', year, ctx).add({ days: 1 });
|
|
501
|
+
}
|
|
502
|
+
const def = HOLIDAYS[name];
|
|
503
|
+
const base = ctx.zonedNow.with({ year, month: 1, day: 1 }).startOfDay();
|
|
504
|
+
if (def.kind === 'fixed') {
|
|
505
|
+
return base.with({ month: def.month, day: def.day });
|
|
506
|
+
}
|
|
507
|
+
if (def.kind === 'easter') {
|
|
508
|
+
const { month, day } = easterDate(year);
|
|
509
|
+
return base.with({ month, day });
|
|
510
|
+
}
|
|
511
|
+
const monthStart = base.with({ month: def.month, day: 1 });
|
|
512
|
+
if (def.kind === 'nth-weekday') {
|
|
513
|
+
const delta = (def.weekday - monthStart.dayOfWeek + 7) % 7;
|
|
514
|
+
return monthStart.add({ days: delta + 7 * (def.n - 1) });
|
|
515
|
+
}
|
|
516
|
+
// last-weekday
|
|
517
|
+
const monthEnd = monthStart.add({ months: 1 }).subtract({ days: 1 });
|
|
518
|
+
const back = (monthEnd.dayOfWeek - def.weekday + 7) % 7;
|
|
519
|
+
return monthEnd.subtract({ days: back });
|
|
520
|
+
}
|
|
521
|
+
function evalHoliday(expr, ctx) {
|
|
522
|
+
const mk = (year) => {
|
|
523
|
+
const day = holidayDay(expr.name, year, ctx);
|
|
524
|
+
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
525
|
+
};
|
|
526
|
+
if (expr.year !== undefined) {
|
|
527
|
+
return [{ type: 'interval', zi: mk(expr.year) }];
|
|
528
|
+
}
|
|
529
|
+
const refYear = ctx.zonedNow.year;
|
|
530
|
+
const now = ctx.zonedNow;
|
|
531
|
+
if (expr.dir === 'prev') {
|
|
532
|
+
const cur = mk(refYear);
|
|
533
|
+
return [{ type: 'interval', zi: Temporal_compare(cur.end, now) <= 0 ? cur : mk(refYear - 1) }];
|
|
534
|
+
}
|
|
535
|
+
if (expr.dir === 'next') {
|
|
536
|
+
const cur = mk(refYear);
|
|
537
|
+
return [{ type: 'interval', zi: Temporal_compare(cur.start, now) > 0 ? cur : mk(refYear + 1) }];
|
|
538
|
+
}
|
|
539
|
+
const ordered = orderByBias(mk(0 + refYear), (d) => mk(refYear + d), ctx, 'years');
|
|
540
|
+
return ordered.map((zi) => ({ type: 'interval', zi }));
|
|
541
|
+
}
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// literal
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
function evalLiteral(expr, ctx, anchor) {
|
|
546
|
+
const refDay = floorTo(anchor?.start ?? ctx.zonedNow, 'day', ctx.weekStart);
|
|
547
|
+
const dateCands = expr.date
|
|
548
|
+
? resolveDate(expr.date, ctx, anchor)
|
|
549
|
+
: anchor
|
|
550
|
+
? [anchor]
|
|
551
|
+
: [{ start: refDay, end: refDay.add({ days: 1 }), grain: 'day' }];
|
|
552
|
+
if (!expr.time && !expr.dayPeriod)
|
|
553
|
+
return dateCands.map((zi) => ({ type: 'interval', zi }));
|
|
554
|
+
const out = [];
|
|
555
|
+
for (const d of dateCands) {
|
|
556
|
+
if (expr.dayPeriod) {
|
|
557
|
+
const zi = dayPeriodInterval(d, expr.dayPeriod, ctx);
|
|
558
|
+
if (zi)
|
|
559
|
+
out.push({ type: 'interval', zi });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
for (const timeCand of resolveTime(expr.time, d)) {
|
|
563
|
+
out.push({ type: 'interval', zi: timeCand });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
function resolveDate(date, ctx, anchor) {
|
|
569
|
+
const ref = anchor?.start ?? ctx.zonedNow;
|
|
570
|
+
if (date.year !== undefined && date.month !== undefined && date.day !== undefined) {
|
|
571
|
+
const day = ref.with({ year: date.year, month: date.month, day: date.day }).startOfDay();
|
|
572
|
+
return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
|
|
573
|
+
}
|
|
574
|
+
if (date.year !== undefined && date.month !== undefined) {
|
|
575
|
+
const start = ref.with({ year: date.year, month: date.month, day: 1 }).startOfDay();
|
|
576
|
+
return [{ start, end: start.add({ months: 1 }), grain: 'month' }];
|
|
577
|
+
}
|
|
578
|
+
if (date.year !== undefined) {
|
|
579
|
+
const start = ref.with({ year: date.year, month: 1, day: 1 }).startOfDay();
|
|
580
|
+
return [{ start, end: start.add({ years: 1 }), grain: 'year' }];
|
|
581
|
+
}
|
|
582
|
+
if (date.month !== undefined && date.day !== undefined) {
|
|
583
|
+
// "May 29" with no year → the most recent occurrence starting on-or-
|
|
584
|
+
// before today, then the following one ("past + future" pairing).
|
|
585
|
+
const mk = (yearDelta) => {
|
|
586
|
+
const day = ref
|
|
587
|
+
.add({ years: yearDelta })
|
|
588
|
+
.with({ month: date.month, day: date.day })
|
|
589
|
+
.startOfDay();
|
|
590
|
+
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
591
|
+
};
|
|
592
|
+
if (ctx.bias === 'none') {
|
|
593
|
+
const today = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
|
|
594
|
+
const base = Temporal_compare(mk(0).start, today) <= 0 ? 0 : -1;
|
|
595
|
+
return [mk(base), mk(base + 1)];
|
|
596
|
+
}
|
|
597
|
+
return orderByBias(mk(0), mk, ctx, 'years');
|
|
598
|
+
}
|
|
599
|
+
if (date.month !== undefined) {
|
|
600
|
+
const mk = (yearDelta) => {
|
|
601
|
+
const start = ref.add({ years: yearDelta }).with({ month: date.month, day: 1 }).startOfDay();
|
|
602
|
+
return { start, end: start.add({ months: 1 }), grain: 'month' };
|
|
603
|
+
};
|
|
604
|
+
return orderByBias(mk(0), mk, ctx, 'years');
|
|
605
|
+
}
|
|
606
|
+
if (date.day !== undefined) {
|
|
607
|
+
// "the 3rd" → most recent occurrence on-or-before today, then the next.
|
|
608
|
+
const mk = (monthDelta) => {
|
|
609
|
+
const day = ref.add({ months: monthDelta }).with({ day: date.day }).startOfDay();
|
|
610
|
+
return { start: day, end: day.add({ days: 1 }), grain: 'day' };
|
|
611
|
+
};
|
|
612
|
+
if (ctx.bias === 'none') {
|
|
613
|
+
const today = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
|
|
614
|
+
const base = Temporal_compare(mk(0).start, today) <= 0 ? 0 : -1;
|
|
615
|
+
return [mk(base), mk(base + 1)];
|
|
616
|
+
}
|
|
617
|
+
return orderByBias(mk(0), mk, ctx, 'months');
|
|
618
|
+
}
|
|
619
|
+
const day = floorTo(ref, 'day', ctx.weekStart);
|
|
620
|
+
return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Order the this-period occurrence and its neighbor under the context bias:
|
|
624
|
+
* 'future' → soonest occurrence not in the past first; 'past' → most recent
|
|
625
|
+
* past occurrence first; 'none' → nearest by calendar distance first.
|
|
626
|
+
*/
|
|
627
|
+
function orderByBias(current, mk, ctx, _unit) {
|
|
628
|
+
const now = ctx.zonedNow;
|
|
629
|
+
const isPast = Temporal_compare(current.end, now) <= 0;
|
|
630
|
+
const isFuture = Temporal_compare(current.start, now) > 0;
|
|
631
|
+
if (ctx.bias === 'future') {
|
|
632
|
+
return isPast ? [mk(1), current] : [current, mk(1)];
|
|
633
|
+
}
|
|
634
|
+
if (ctx.bias === 'past') {
|
|
635
|
+
return isFuture ? [mk(-1), current] : [current, mk(-1)];
|
|
636
|
+
}
|
|
637
|
+
// none → nearest first
|
|
638
|
+
if (isPast) {
|
|
639
|
+
const next = mk(1);
|
|
640
|
+
const dPast = Math.abs(calendarDaysBetween(current.start, now));
|
|
641
|
+
const dNext = Math.abs(calendarDaysBetween(now, next.start));
|
|
642
|
+
return dPast <= dNext ? [current, next] : [next, current];
|
|
643
|
+
}
|
|
644
|
+
if (isFuture) {
|
|
645
|
+
const prev = mk(-1);
|
|
646
|
+
const dFut = Math.abs(calendarDaysBetween(now, current.start));
|
|
647
|
+
const dPrev = Math.abs(calendarDaysBetween(prev.start, now));
|
|
648
|
+
return dFut <= dPrev ? [current, prev] : [prev, current];
|
|
649
|
+
}
|
|
650
|
+
// now falls inside the current occurrence: pair it with the previous one
|
|
651
|
+
// ("from Sep to Nov" said mid-September offers last year's too). Bias
|
|
652
|
+
// 'past'/'future' were handled above, so this is the 'none' path.
|
|
653
|
+
return [current, mk(-1)];
|
|
654
|
+
}
|
|
655
|
+
function resolveTime(time, day) {
|
|
656
|
+
const grain = time.second !== undefined ? 'second' : time.minute !== undefined ? 'minute' : 'hour';
|
|
657
|
+
const dayStart = day.start.startOfDay();
|
|
658
|
+
const mk = (hour24) => {
|
|
659
|
+
const start = dayStart.add({
|
|
660
|
+
hours: hour24,
|
|
661
|
+
minutes: time.minute ?? 0,
|
|
662
|
+
seconds: time.second ?? 0,
|
|
663
|
+
});
|
|
664
|
+
const end = grain === 'second'
|
|
665
|
+
? start.add({ seconds: 1 })
|
|
666
|
+
: grain === 'minute'
|
|
667
|
+
? start.add({ minutes: 1 })
|
|
668
|
+
: start.add({ hours: 1 });
|
|
669
|
+
return { start, end, grain };
|
|
670
|
+
};
|
|
671
|
+
const h = time.hour ?? 0;
|
|
672
|
+
if (time.meridiem === 'am')
|
|
673
|
+
return [mk(h === 12 ? 0 : h)];
|
|
674
|
+
if (time.meridiem === 'pm')
|
|
675
|
+
return [mk(h === 12 ? 12 : h + 12)];
|
|
676
|
+
if (time.meridiem === 'unknown' && h >= 1 && h <= 12) {
|
|
677
|
+
// Ambiguous clock reading: both readings, plausibility-ordered ("at 4"
|
|
678
|
+
// usually means 4pm; "at 9" usually means 9am).
|
|
679
|
+
const am = mk(h === 12 ? 0 : h);
|
|
680
|
+
const pm = mk(h === 12 ? 12 : h + 12);
|
|
681
|
+
return h >= 7 && h <= 11 ? [am, pm] : [pm, am];
|
|
682
|
+
}
|
|
683
|
+
return [mk(h)];
|
|
684
|
+
}
|