nl2time 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,4 +15,13 @@ export interface RuleMatch {
15
15
  }
16
16
  export type Rule = (tokens: Token[], i: number, ctx: TimeContext) => RuleMatch | undefined;
17
17
  /** Rules in priority order (first match at a position wins ties by length then order). */
18
+ /**
19
+ * Built-in English rules with stable public names (docs/extending.md).
20
+ * Order matters: the scanner prefers longer matches, then higher confidence,
21
+ * then earlier list position — so names are also the disable/override handle.
22
+ */
23
+ export declare const EN_RULE_ENTRIES: readonly {
24
+ name: string;
25
+ rule: Rule;
26
+ }[];
18
27
  export declare const EN_RULES: readonly Rule[];
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
- const inner = ruleHoliday(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
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: 'between',
1449
- start: { op: 'seek', base: inner.expr, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
1450
- end: { op: 'snap', base: inner.expr, unit: 'day', edge: 'end' },
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: at + inner.consumed - i,
1647
+ consumed,
1453
1648
  confidence: 0.95,
1454
1649
  role: 'date',
1455
1650
  };
@@ -2404,6 +2599,25 @@ const ruleClockTime = (tokens, i, ctx) => {
2404
2599
  role: 'time',
2405
2600
  };
2406
2601
  }
2602
+ // Day-shifting period phrases: "10 last night", "8 yesterday morning",
2603
+ // "5 tomorrow afternoon" (#10). Emit the bare hour with meridiem
2604
+ // 'unknown' and stop — the following day-part matches separately and the
2605
+ // refiner merges them, with the period supplying the meridiem and the
2606
+ // deictic supplying the day shift. Same noun-guard as "this <period>":
2607
+ // "the count was 4 last night" keeps the period's range reading.
2608
+ const relNext = word(tokens[i + hn.consumed]);
2609
+ const isDayShifter = relNext !== undefined &&
2610
+ (REL_SYNONYMS[relNext] !== undefined ||
2611
+ relNext === 'yesterday' || relNext === 'tomorrow' || relNext === 'today');
2612
+ const relPeriodWord = isDayShifter ? word(tokens[i + hn.consumed + 1]) : undefined;
2613
+ if (allowThis && relPeriodWord !== undefined && PERIOD_WORDS[relPeriodWord]) {
2614
+ return {
2615
+ expr: { op: 'literal', time: { hour: hn.value, meridiem: 'unknown' } },
2616
+ consumed: hn.consumed,
2617
+ confidence: 0.95,
2618
+ role: 'time',
2619
+ };
2620
+ }
2407
2621
  if (['at', 'from', 'around', 'about'].includes(word(tokens[i - 1]) ?? '')) {
2408
2622
  return {
2409
2623
  expr: { op: 'literal', time: { hour: hn.value, meridiem: 'unknown' } },
@@ -2696,34 +2910,43 @@ const ruleAllUnit = (tokens, i) => {
2696
2910
  return { expr: { op: 'amount', amount: amountFor(unit, 1) }, consumed: 2, confidence: 0.9, role: 'duration' };
2697
2911
  };
2698
2912
  /** Rules in priority order (first match at a position wins ties by length then order). */
2699
- export const EN_RULES = [
2700
- ruleRange,
2701
- ruleWeekOf,
2702
- ruleNthWeekdayOf,
2703
- ruleDayOfScope,
2704
- ruleWeekendOf,
2705
- ruleHoliday,
2706
- ruleEarlyLate,
2707
- ruleEndOf,
2708
- ruleDeicticDay,
2709
- ruleWithin,
2710
- ruleNAfterDate,
2711
- ruleUnitAfterNext,
2712
- rulePastN,
2713
- ruleNextN,
2714
- ruleTheNPastNext,
2715
- ruleTheUnit,
2716
- ruleLastThisNext,
2717
- ruleAgoIn,
2718
- ruleDuration,
2719
- ruleFractionDuration,
2720
- ruleAllUnit,
2721
- ruleDecadeCentury,
2722
- ruleBareYear,
2723
- ruleCalendarDate,
2724
- ruleClockTime,
2725
- ruleWeekdayDay,
2726
- ruleWeekdayAlone,
2727
- rulePeriodAlone,
2728
- ruleNow,
2913
+ /**
2914
+ * Built-in English rules with stable public names (docs/extending.md).
2915
+ * Order matters: the scanner prefers longer matches, then higher confidence,
2916
+ * then earlier list position — so names are also the disable/override handle.
2917
+ */
2918
+ export const EN_RULE_ENTRIES = [
2919
+ { name: 'range', rule: ruleRange },
2920
+ { name: 'open-range', rule: ruleOpenRange },
2921
+ { name: 'week-of', rule: ruleWeekOf },
2922
+ { name: 'nth-weekday-of', rule: ruleNthWeekdayOf },
2923
+ { name: 'day-of-scope', rule: ruleDayOfScope },
2924
+ { name: 'weekend-of', rule: ruleWeekendOf },
2925
+ { name: 'holiday', rule: ruleHoliday },
2926
+ { name: 'early-late', rule: ruleEarlyLate },
2927
+ { name: 'half-of', rule: ruleHalfOf },
2928
+ { name: 'end-of', rule: ruleEndOf },
2929
+ { name: 'deictic-day', rule: ruleDeicticDay },
2930
+ { name: 'within', rule: ruleWithin },
2931
+ { name: 'n-after-date', rule: ruleNAfterDate },
2932
+ { name: 'unit-after-next', rule: ruleUnitAfterNext },
2933
+ { name: 'before-last', rule: ruleBeforeLast },
2934
+ { name: 'past-n', rule: rulePastN },
2935
+ { name: 'next-n', rule: ruleNextN },
2936
+ { name: 'the-n-past-next', rule: ruleTheNPastNext },
2937
+ { name: 'the-unit', rule: ruleTheUnit },
2938
+ { name: 'last-this-next', rule: ruleLastThisNext },
2939
+ { name: 'ago-in', rule: ruleAgoIn },
2940
+ { name: 'duration', rule: ruleDuration },
2941
+ { name: 'fraction-duration', rule: ruleFractionDuration },
2942
+ { name: 'all-unit', rule: ruleAllUnit },
2943
+ { name: 'decade-century', rule: ruleDecadeCentury },
2944
+ { name: 'bare-year', rule: ruleBareYear },
2945
+ { name: 'calendar-date', rule: ruleCalendarDate },
2946
+ { name: 'clock-time', rule: ruleClockTime },
2947
+ { name: 'weekday-day', rule: ruleWeekdayDay },
2948
+ { name: 'weekday-alone', rule: ruleWeekdayAlone },
2949
+ { name: 'period-alone', rule: rulePeriodAlone },
2950
+ { name: 'now', rule: ruleNow },
2729
2951
  ];
2952
+ export const EN_RULES = EN_RULE_ENTRIES.map((e) => e.rule);
@@ -0,0 +1,9 @@
1
+ import type { Rule } from './en.js';
2
+ import { type LatinLexicon } from './latin.js';
3
+ export declare const ES_LEXICON: LatinLexicon;
4
+ export declare const ES_RULE_ENTRIES: {
5
+ name: string;
6
+ rule: Rule;
7
+ }[];
8
+ /** Connector words allowed between merged date & time parts. */
9
+ export declare const ES_CONNECTORS: string[];