chronolizer 0.3.0 → 0.4.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.
@@ -1,10 +1,9 @@
1
- import { isIsoDate } from "../ast/schemas.mjs";
2
1
  import { BaseLanguageContribution } from "../language/model.mjs";
3
2
  import { normalizeNaturalText } from "../natural/text.mjs";
4
3
  import { defineLanguagePlugin, languagePluginsLayer } from "../language/registry.mjs";
5
4
  import { correctWhitespaceSeparatedText } from "../natural/correction.mjs";
6
5
  import { completeNaturalPhrases, fixedCalendarPeriodPhrases, naturalCount, prefixNaturalPhrases } from "../natural/suggestion.mjs";
7
- import { absoluteDatePeriod, calendarPeriodOffset, candidate, currentYearDatePeriods, datedPeriods, datedQuarterPeriods, fixedDatePeriod, fixedMonthPeriod, fixedQuarterPeriod, fixedYearPeriod, fromNowRange, futurePeriod, futureRange, isoDate, joinedNowCandidate, joinedPeriodCandidate, monthOfRelativeYear, namedCurrentYearDatePeriod, namedDatePeriod, openBoundaryCandidate, parseTrailingCount, periodBoundaryCandidate, periodEndDay, periodRange, periodStartDay, periodToDateRange, periodsFromPhrases, quarterOfRelativeYear, relativePeriod, relativeWeekend, remainingPeriodRange, renderPeriodRange, textAt, trailingPeriod, trailingRange, untilNowRange, validYear } from "./shared.mjs";
6
+ import { absoluteDatePeriod, calendarPeriodOffset, candidate, compileCountAliasNormalizer, compoundCountAliases, countAliasVocabulary, currentYearDatePeriods, datedPeriods, datedQuarterPeriods, decimalTens, decomposeShiftedPeriodRange, fixedMonthPeriod, fixedQuarterPeriod, fixedYearPeriod, fromNowRange, futurePeriod, futureRange, joinedNowCandidate, joinedPeriodCandidate, monthOfRelativeYear, namedCurrentYearDatePeriod, namedDatePeriod, openBoundaryCandidate, parseTrailingCount, periodBoundaryCandidate, periodEndDay, periodRange, periodStartDay, periodToDateRange, periodsFromPhrases, quarterOfRelativeYear, relativePeriod, relativeWeekday, relativeWeekend, remainingPeriodRange, renderPeriodRange, sequentialCountAliases, shiftPeriod, textAt, trailingPeriod, trailingRange, untilNowRange, validYear } from "./shared.mjs";
8
7
  import { Effect, Option, String as String$1 } from "effect";
9
8
  //#region src/locales/tr.ts
10
9
  const months = [
@@ -21,6 +20,61 @@ const months = [
21
20
  "kasım",
22
21
  "aralık"
23
22
  ];
23
+ const weekdays = [
24
+ "pazartesi",
25
+ "salı",
26
+ "çarşamba",
27
+ "perşembe",
28
+ "cuma",
29
+ "cumartesi",
30
+ "pazar"
31
+ ];
32
+ const nextWeekdayPhrases = weekdays.map((weekday) => `gelecek ${weekday}`);
33
+ const turkishCountWords = [
34
+ ["iki"],
35
+ ["üç", "uc"],
36
+ ["dört", "dort"],
37
+ ["beş", "bes"],
38
+ ["altı", "alti"],
39
+ ["yedi"],
40
+ ["sekiz"],
41
+ ["dokuz"],
42
+ ["on"],
43
+ ["on bir"],
44
+ ["on iki"],
45
+ ["on üç"],
46
+ ["on dört"],
47
+ ["on beş"],
48
+ ["on altı"],
49
+ ["on yedi"],
50
+ ["on sekiz"],
51
+ ["on dokuz"],
52
+ ["yirmi"]
53
+ ];
54
+ const turkishCountOnes = [
55
+ [1, "bir"],
56
+ [2, "iki"],
57
+ [3, "üç"],
58
+ [4, "dört"],
59
+ [5, "beş"],
60
+ [6, "altı"],
61
+ [7, "yedi"],
62
+ [8, "sekiz"],
63
+ [9, "dokuz"]
64
+ ];
65
+ const turkishCountAliases = [...sequentialCountAliases(turkishCountWords, 2), ...compoundCountAliases(decimalTens([
66
+ "yirmi",
67
+ "otuz",
68
+ "kırk",
69
+ "elli",
70
+ "altmış",
71
+ "yetmiş",
72
+ "seksen",
73
+ "doksan"
74
+ ]), turkishCountOnes, (ten, one) => [`${ten} ${one}`])];
75
+ const normalizeTurkishCounts = compileCountAliasNormalizer(turkishCountAliases);
76
+ const turkishCountVocabulary = new Set(countAliasVocabulary(turkishCountAliases));
77
+ const correctTurkish = (input, vocabulary) => correctWhitespaceSeparatedText(input, vocabulary, turkishCountVocabulary);
24
78
  const monthAbbreviations = [
25
79
  ["oca"],
26
80
  ["şub", "sub"],
@@ -303,15 +357,7 @@ const parseNamedDate = (input) => {
303
357
  const named = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)(?:,)? (\d{4})$/u)(input);
304
358
  if (Option.isSome(named)) return namedDatePeriod(textAt(named.value, 3), textAt(named.value, 2), textAt(named.value, 1), monthNumber, dateLabel);
305
359
  const numeric = String$1.match(/^([0-3]?\d)[./-]([01]?\d)[./-](\d{4})$/u)(input);
306
- if (Option.isSome(numeric)) {
307
- const year = validYear(textAt(numeric.value, 3));
308
- const month = Number(textAt(numeric.value, 2));
309
- const day = Number(textAt(numeric.value, 1));
310
- if (year !== void 0 && month >= 1 && month <= 12) {
311
- const value = isoDate(year, month, day);
312
- if (isIsoDate(value) && value !== "9999-12-31") return Option.some(fixedDatePeriod(value, dateLabel(day, month, year)));
313
- }
314
- }
360
+ if (Option.isSome(numeric)) return namedDatePeriod(textAt(numeric.value, 3), textAt(numeric.value, 2), textAt(numeric.value, 1), Number, dateLabel);
315
361
  const current = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)$/u)(input);
316
362
  return Option.isSome(current) ? namedCurrentYearDatePeriod(textAt(current.value, 2), textAt(current.value, 1), monthNumber, currentDateLabel) : Option.none();
317
363
  };
@@ -340,13 +386,13 @@ const parseQuarter = (input) => {
340
386
  const quarter = quarterNumber(textAt(standalone.value, 1));
341
387
  return quarter === void 0 ? Option.none() : Option.some(quarterOfRelativeYear(quarter, 0, `Ç${quarter}`));
342
388
  };
343
- const parseBasePeriod = (input) => {
344
- const absoluteDate = absoluteDatePeriod(input, "tr");
345
- if (Option.isSome(absoluteDate)) return absoluteDate;
346
- const namedDate = parseNamedDate(input);
347
- if (Option.isSome(namedDate)) return namedDate;
348
- const quarter = parseQuarter(input);
349
- if (Option.isSome(quarter)) return quarter;
389
+ const parseDatedPeriod = (input) => {
390
+ const knownPeriod = Option.firstSomeOf([
391
+ absoluteDatePeriod(input, "tr"),
392
+ parseNamedDate(input),
393
+ parseQuarter(input)
394
+ ]);
395
+ if (Option.isSome(knownPeriod)) return knownPeriod;
350
396
  const yearMatch = String$1.match(/^(?:yıl |yılı )?(\d{4})$/u)(input);
351
397
  if (Option.isSome(yearMatch)) {
352
398
  const year = validYear(textAt(yearMatch.value, 1));
@@ -358,6 +404,20 @@ const parseBasePeriod = (input) => {
358
404
  const year = validYear(textAt(monthYear.value, 2));
359
405
  if (month !== void 0 && year !== void 0) return Option.some(fixedMonthPeriod(year, month, `${title(textAt(months, month - 1))} ${year}`));
360
406
  }
407
+ return Option.none();
408
+ };
409
+ const parseBasePeriod = (input) => {
410
+ const datedPeriod = parseDatedPeriod(input);
411
+ if (Option.isSome(datedPeriod)) return datedPeriod;
412
+ const prefixedRelativeMonth = String$1.match(/^(geçen|bu|gelecek) ([a-zçğıöşü]+\.?)$/u)(input);
413
+ if (Option.isSome(prefixedRelativeMonth)) {
414
+ const month = monthNumber(textAt(prefixedRelativeMonth.value, 2));
415
+ if (month !== void 0) {
416
+ const modifier = textAt(prefixedRelativeMonth.value, 1);
417
+ const direction = relativeYearDirection(modifier);
418
+ return Option.some(monthOfRelativeYear(month, direction, `${modifier} ${title(textAt(months, month - 1))}`));
419
+ }
420
+ }
361
421
  const relativeMonth = String$1.match(/^([a-zçğıöşü]+\.?) (geçen yıl|gelecek yıl|bu yıl)$/u)(input);
362
422
  const relativeMonthYearFirst = String$1.match(/^(geçen yıl|gelecek yıl|bu yıl) ([a-zçğıöşü]+\.?)$/u)(input);
363
423
  const relativeMatch = Option.firstSomeOf([relativeMonth, relativeMonthYearFirst]);
@@ -378,16 +438,37 @@ const parseBasePeriod = (input) => {
378
438
  if (["gelecek hafta sonu", "önümüzdeki hafta sonu"].includes(input)) return Option.some(relativeWeekend(1, "gelecek hafta sonu"));
379
439
  if (input === "geçen hafta sonundan önceki hafta sonu") return Option.some(relativeWeekend(-2, input));
380
440
  if (input === "gelecek hafta sonundan sonraki hafta sonu") return Option.some(relativeWeekend(2, input));
381
- return Option.none();
441
+ const weekday = nextWeekdayPhrases.indexOf(input);
442
+ return weekday === -1 ? Option.none() : Option.some(relativeWeekday(weekday, 1, nextWeekdayPhrases[weekday] ?? input));
382
443
  };
383
- const parsePeriod = (input) => {
444
+ const parsePeriodEdge = (input) => {
384
445
  const edge = String$1.match(/^(.+?)(?:ın|in|un|ün) (başı|başlangıcı|sonu)$/u)(input);
385
- if (Option.isSome(edge)) {
386
- const period = parseBasePeriod(textAt(edge.value, 1));
387
- if (Option.isSome(period)) {
388
- const isEnd = textAt(edge.value, 2) === "sonu";
389
- const canonical = `${period.value.canonical} ${isEnd ? "sonu" : "başı"}`;
390
- return Option.some(isEnd ? periodEndDay(period.value, canonical) : periodStartDay(period.value, canonical));
446
+ if (Option.isNone(edge)) return Option.none();
447
+ const periodText = textAt(edge.value, 1);
448
+ const basePeriod = parseBasePeriod(periodText);
449
+ const implicitUnit = unitAliases.find((entry) => entry[0] === periodText)?.[1];
450
+ const implicit = units.find((entry) => entry.unit === implicitUnit);
451
+ const period = Option.isSome(basePeriod) || implicit === void 0 ? basePeriod : Option.some(relativePeriod(implicit.unit, 0, implicit.current));
452
+ if (Option.isNone(period)) return Option.none();
453
+ const isEnd = textAt(edge.value, 2) === "sonu";
454
+ return Option.some(isEnd ? periodEndDay(period.value, input) : periodStartDay(period.value, input));
455
+ };
456
+ const parsePeriod = (input) => {
457
+ const prefix = String$1.match(/^([1-9]\d*) (gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene) (önce|sonra) (.+)$/u)(input);
458
+ const suffix = String$1.match(/^(.+) ([1-9]\d*) (gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene) (önce|sonra)$/u)(input);
459
+ const shifted = Option.firstSomeOf([prefix, suffix]);
460
+ if (Option.isSome(shifted)) {
461
+ const prefixOrder = Option.isSome(prefix);
462
+ const amount = parseTrailingCount(textAt(shifted.value, prefixOrder ? 1 : 2));
463
+ const alias = unitAliases.find((unit) => unit[0] === textAt(shifted.value, prefixOrder ? 2 : 3));
464
+ const entry = alias === void 0 ? void 0 : units.find((unit) => unit.unit === alias[1]);
465
+ const period = parsePeriod(textAt(shifted.value, prefixOrder ? 4 : 1));
466
+ if (Option.isSome(amount) && entry !== void 0 && Option.isSome(period)) {
467
+ const past = textAt(shifted.value, prefixOrder ? 3 : 4) === "önce";
468
+ const direction = past ? -amount.value : amount.value;
469
+ const noun = amount.value === 1 ? entry.singular : entry.plural;
470
+ const canonical = `${amount.value} ${noun} ${past ? "önce" : "sonra"} ${period.value.canonical}`;
471
+ return Option.some(shiftPeriod(period.value, direction, entry.unit, canonical));
391
472
  }
392
473
  }
393
474
  const wrapper = [
@@ -395,17 +476,19 @@ const parsePeriod = (input) => {
395
476
  "içinde ",
396
477
  "tüm "
397
478
  ].find((prefix) => input.startsWith(prefix));
398
- return parseBasePeriod(wrapper === void 0 ? input : input.slice(wrapper.length));
479
+ return Option.firstSomeOf([
480
+ parsePeriodEdge(input),
481
+ parseBasePeriod(wrapper === void 0 ? input : input.slice(wrapper.length)),
482
+ parseCalendarOffset(input)
483
+ ]);
399
484
  };
400
485
  const countedUnit = (value) => unitAliases.find((entry) => entry[0] === value)?.[1];
401
- const countedUnitPattern = "gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene";
402
- const countedPattern = (source) => new RegExp(source.replace("UNIT", countedUnitPattern), "u");
403
- const calendarPastPattern = countedPattern("^([1-9]\\d*) (UNIT) önce$");
404
- const calendarFuturePatterns = [countedPattern("^([1-9]\\d*) (UNIT) sonra$"), countedPattern("^([1-9]\\d*) (UNIT) içinde$")];
405
- const rollingPastPatterns = [countedPattern("^(?:son|geçen|önceki|geçtiğimiz) ([1-9]\\d*) (UNIT)$"), countedPattern("^([1-9]\\d*) (?:son|geçen|önceki) (UNIT)$")];
406
- const rollingFuturePatterns = [countedPattern("^(?:gelecek|önümüzdeki|sonraki) ([1-9]\\d*) (UNIT)$"), countedPattern("^([1-9]\\d*) (?:gelecek|sonraki) (UNIT)$")];
407
- const rollingSincePattern = countedPattern("^([1-9]\\d*) (UNIT) boyunca$");
408
- const rollingBarePattern = countedPattern("^([1-9]\\d*) (UNIT)$");
486
+ const calendarPastPattern = /^([1-9]\d*) ([^ ]+) önce$/u;
487
+ const calendarFuturePatterns = [/^([1-9]\d*) ([^ ]+) sonra$/u, /^([1-9]\d*) ([^ ]+) içinde$/u];
488
+ const rollingPastPatterns = [/^(?:son|geçen|önceki|geçtiğimiz) ([1-9]\d*) ([^ ]+)$/u, /^([1-9]\d*) (?:son|geçen|önceki) ([^ ]+)$/u];
489
+ const rollingFuturePatterns = [/^(?:gelecek|önümüzdeki|sonraki) ([1-9]\d*) ([^ ]+)$/u, /^([1-9]\d*) (?:gelecek|sonraki) ([^ ]+)$/u];
490
+ const rollingSincePattern = /^([1-9]\d*) ([^ ]+) boyunca$/u;
491
+ const rollingBarePattern = /^([1-9]\d*) ([^ ]+)$/u;
409
492
  const firstPatternMatch = (input, patterns) => Option.firstSomeOf(patterns.map((pattern) => String$1.match(pattern)(input)));
410
493
  const singularRollingCanonical = (entry, future) => future ? `önümüzdeki bir ${entry.singular}` : `son bir ${entry.singular}`;
411
494
  const singularRollingPhrases = units.flatMap((entry) => [
@@ -446,7 +529,7 @@ const singularCalendarOffsets = units.flatMap((entry) => [{
446
529
  }]);
447
530
  const parseCalendarOffset = (input) => {
448
531
  const singular = singularCalendarOffsets.find((entry) => entry.phrase === input);
449
- if (singular !== void 0) return Option.some(candidate(periodRange(relativePeriod(singular.entry.unit, singular.direction, singular.phrase)), singular.phrase));
532
+ if (singular !== void 0) return Option.some(relativePeriod(singular.entry.unit, singular.direction, singular.phrase));
450
533
  const past = String$1.match(calendarPastPattern)(input);
451
534
  const future = firstPatternMatch(input, calendarFuturePatterns);
452
535
  const match = Option.firstSomeOf([past, future]);
@@ -459,7 +542,7 @@ const parseCalendarOffset = (input) => {
459
542
  const direction = Option.isSome(past) ? -amount.value : amount.value;
460
543
  const noun = amount.value === 1 ? entry.singular : entry.plural;
461
544
  const canonical = direction < 0 ? `${amount.value} ${noun} önce` : `${amount.value} ${noun} sonra`;
462
- return Option.some(candidate(periodRange(relativePeriod(unit, direction, canonical)), canonical));
545
+ return Option.some(relativePeriod(unit, direction, canonical));
463
546
  };
464
547
  const parseRollingPeriod = (input) => {
465
548
  const singular = singularRollingPhrases.find((entry) => entry.phrase === input);
@@ -543,8 +626,6 @@ const parseTurkish = (input) => {
543
626
  "şu andan itibaren",
544
627
  "bundan sonra"
545
628
  ].includes(input)) return Option.some(candidate(fromNowRange(), "bugünden itibaren"));
546
- const offset = parseCalendarOffset(input);
547
- if (Option.isSome(offset)) return offset;
548
629
  const rolling = parseRollingPeriod(input);
549
630
  if (Option.isSome(rolling)) return rolling;
550
631
  const toDate = toDatePhrases.find((entry) => entry.phrase === input);
@@ -552,7 +633,12 @@ const parseTurkish = (input) => {
552
633
  const elided = parseElidedDateRange(input);
553
634
  if (Option.isSome(elided)) return elided;
554
635
  const joinedInput = input.endsWith(" arası") ? input.slice(0, -6) : input;
555
- const nowBounded = joinedNowCandidate(joinedInput, [["", " ile bugün"], ["", " ve bugün"]], ["bugün ile ", "bugün ve "], parsePeriod, (period) => `${period} ile bugün arası`, (period) => `bugün ile ${period} arası`);
636
+ const nowBounded = joinedNowCandidate(joinedInput, [["", " ile bugün"], ["", " ve bugün"]], [
637
+ "bugün ile ",
638
+ "bugün ve ",
639
+ "şimdi ile ",
640
+ "şimdi ve "
641
+ ], parsePeriod, (period) => `${period} ile bugün arası`, (period) => `bugün ile ${period} arası`);
556
642
  if (Option.isSome(nowBounded)) return nowBounded;
557
643
  const bounded = joinedPeriodCandidate(joinedInput, [
558
644
  ["", " ile "],
@@ -591,6 +677,8 @@ const staticPeriodPhrases = [
591
677
  "geçen yılın sonu",
592
678
  "gelecek yılın başı",
593
679
  "gelecek yılın sonu",
680
+ ...nextWeekdayPhrases,
681
+ ...months.flatMap((month) => [`geçen ${month}`, `gelecek ${month}`]),
594
682
  ...[
595
683
  1,
596
684
  2,
@@ -651,6 +739,17 @@ const suggestTurkish = (input, limit) => {
651
739
  ], limit);
652
740
  };
653
741
  const renderTurkish = (range) => {
742
+ const shifted = decomposeShiftedPeriodRange(range);
743
+ if (Option.isSome(shifted)) {
744
+ const base = renderTurkish(shifted.value.baseRange);
745
+ const entry = units.find((unit) => unit.unit === shifted.value.unit);
746
+ if (Option.isSome(base) && entry !== void 0) {
747
+ const amount = Math.abs(shifted.value.amount);
748
+ const noun = amount === 1 ? entry.singular : entry.plural;
749
+ const direction = shifted.value.amount < 0 ? "önce" : "sonra";
750
+ return Option.some(`${amount} ${noun} ${direction} ${base.value}`);
751
+ }
752
+ }
654
753
  const offset = calendarPeriodOffset(range);
655
754
  if (Option.isSome(offset) && Math.abs(offset.value.amount) > 1) {
656
755
  const entry = units.find((unit) => unit.unit === offset.value.unit);
@@ -677,11 +776,12 @@ const renderTurkish = (range) => {
677
776
  ];
678
777
  return renderPeriodRange(range, [...units.map((entry) => candidate(periodToDateRange(entry.unit), entry.toDate)), ...units.map((entry) => candidate(remainingPeriodRange(entry.unit), entry.remaining))], periods, (period) => `${period} itibarıyla`, (period) => `${period} öncesi`, (period) => `${period} sonuna kadar`, (period) => `${period} sonrası`, (lower, upper) => `${lower} ile ${upper} arası`, (period) => `${period} ile bugün arası`, (period) => `bugün ile ${period} arası`, () => "şimdiye kadar", () => "bugünden itibaren");
679
778
  };
680
- const normalizeTurkish = (input, locale) => normalizeNaturalText(input, locale).replaceAll("’", "'");
779
+ const normalizeTurkish = (input, locale) => normalizeTurkishCounts(normalizeNaturalText(input, locale).replaceAll("’", "'"));
681
780
  const TurkishContribution = new BaseLanguageContribution({
682
781
  locale: "tr",
683
782
  vocabulary: [
684
783
  ...months,
784
+ ...weekdays,
685
785
  ...monthAbbreviations.flatMap((aliases) => aliases),
686
786
  ...units.flatMap((entry) => [
687
787
  entry.singular,
@@ -698,6 +798,7 @@ const TurkishContribution = new BaseLanguageContribution({
698
798
  "gelecek",
699
799
  "geçen",
700
800
  "geri",
801
+ "ile",
701
802
  "itibaren",
702
803
  "itibarıyla",
703
804
  "kadar",
@@ -708,10 +809,11 @@ const TurkishContribution = new BaseLanguageContribution({
708
809
  "son",
709
810
  "sonra",
710
811
  "sonrası",
812
+ "şimdi",
711
813
  "şimdiye"
712
814
  ],
713
815
  normalize: normalizeTurkish,
714
- correct: correctWhitespaceSeparatedText,
816
+ correct: correctTurkish,
715
817
  parseExact: parseTurkish,
716
818
  suggest: suggestTurkish,
717
819
  render: renderTurkish
@@ -1,5 +1,5 @@
1
1
  //#region src/natural/correction.d.ts
2
- declare const correctWhitespaceSeparatedText: (input: string, vocabulary: ReadonlyArray<string>) => {
2
+ declare const correctWhitespaceSeparatedText: (input: string, vocabulary: ReadonlyArray<string>, segmentationVocabulary?: ReadonlySet<string>) => {
3
3
  readonly text: string;
4
4
  readonly corrections: readonly {
5
5
  readonly original: string;
@@ -1,50 +1,119 @@
1
1
  import { Correction, NaturalCorrectionCandidate } from "../language/model.mjs";
2
2
  import { naturalWords } from "./text.mjs";
3
- import { Option, String } from "effect";
4
3
  //#region src/natural/correction.ts
5
- const isProtectedValue = (word) => Option.isSome(String.match(/^\d+$/u)(word)) || Option.isSome(String.match(/^\d{4}-\d{2}-\d{2}$/u)(word));
6
- const damerauLevenshteinDistance = (left, right) => {
7
- const fallback = left.length + right.length;
8
- let previousPrevious;
9
- let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
4
+ const isProtectedValue = (word) => /^(?:\d+|\d{4}-\d{2}-\d{2})$/u.test(word);
5
+ const damerauLevenshteinDistance = (left, right, maximum = Math.max(left.length, right.length), workspace = [
6
+ [],
7
+ [],
8
+ []
9
+ ]) => {
10
+ const outside = maximum + 1;
11
+ if (Math.abs(left.length - right.length) > maximum) return outside;
12
+ const width = right.length + 1;
13
+ let [previousPrevious, previous, current] = workspace;
14
+ for (const row of workspace) {
15
+ row.length = width;
16
+ row.fill(outside);
17
+ }
18
+ for (let column = 0; column <= Math.min(right.length, maximum); column += 1) previous[column] = column;
10
19
  for (let row = 1; row <= left.length; row += 1) {
11
- const current = [row];
12
- for (let column = 1; column <= right.length; column += 1) {
13
- const substitution = left.charAt(row - 1) === right.charAt(column - 1) ? 0 : 1;
14
- const distance = Math.min((previous[column] ?? fallback) + 1, (current[column - 1] ?? fallback) + 1, (previous[column - 1] ?? fallback) + substitution);
15
- const transposed = previousPrevious !== void 0 && row > 1 && column > 1 && left.charAt(row - 1) === right.charAt(column - 2) && left.charAt(row - 2) === right.charAt(column - 1) ? (previousPrevious[column - 2] ?? fallback) + 1 : fallback;
16
- current.push(Math.min(distance, transposed));
20
+ current.fill(outside);
21
+ if (row <= maximum) current[0] = row;
22
+ const firstColumn = Math.max(1, row - maximum);
23
+ const lastColumn = Math.min(right.length, row + maximum);
24
+ let rowMinimum = row <= maximum ? row : outside;
25
+ for (let column = firstColumn; column <= lastColumn; column += 1) {
26
+ const substitution = left.charCodeAt(row - 1) === right.charCodeAt(column - 1) ? 0 : 1;
27
+ let distance = Math.min((previous[column] ?? outside) + 1, (current[column - 1] ?? outside) + 1, (previous[column - 1] ?? outside) + substitution);
28
+ if (row > 1 && column > 1 && left.charCodeAt(row - 1) === right.charCodeAt(column - 2) && left.charCodeAt(row - 2) === right.charCodeAt(column - 1)) distance = Math.min(distance, (previousPrevious[column - 2] ?? outside) + 1);
29
+ current[column] = distance;
30
+ rowMinimum = Math.min(rowMinimum, distance);
17
31
  }
32
+ if (rowMinimum > maximum) return outside;
33
+ const reusable = previousPrevious;
18
34
  previousPrevious = previous;
19
35
  previous = current;
36
+ current = reusable;
37
+ }
38
+ return previous[right.length] ?? outside;
39
+ };
40
+ const segmentedReplacements = (word, vocabulary) => {
41
+ const segmentations = Array.from({ length: word.length + 1 }, () => []);
42
+ const initial = segmentations[0];
43
+ if (initial !== void 0) initial.push([]);
44
+ for (let start = 0; start < word.length; start += 1) {
45
+ const prefixes = segmentations[start] ?? [];
46
+ if (prefixes.length === 0) continue;
47
+ for (let end = start + 1; end <= word.length; end += 1) {
48
+ const part = word.slice(start, end);
49
+ if (!vocabulary.has(part)) continue;
50
+ const target = segmentations[end];
51
+ if (target === void 0) continue;
52
+ for (const prefix of prefixes) {
53
+ if (target.length >= 4) break;
54
+ target.push([...prefix, part]);
55
+ }
56
+ }
20
57
  }
21
- return previous[right.length] ?? fallback;
58
+ return (segmentations[word.length] ?? []).filter((parts) => parts.length > 1).map((parts) => ({
59
+ word: parts.join(" "),
60
+ distance: parts.length - 1
61
+ }));
62
+ };
63
+ const vocabularySets = /* @__PURE__ */ new WeakMap();
64
+ const vocabularySet = (vocabulary) => {
65
+ const cached = vocabularySets.get(vocabulary);
66
+ if (cached !== void 0) return cached;
67
+ const words = new Set(vocabulary);
68
+ vocabularySets.set(vocabulary, words);
69
+ return words;
22
70
  };
23
- const replacementsFor = (word, vocabulary) => {
24
- if (vocabulary.includes(word) || isProtectedValue(word)) return [{
71
+ const replacementsFor = (word, vocabulary, segmentationVocabulary, workspace) => {
72
+ if (vocabulary.has(word) || isProtectedValue(word)) return [{
25
73
  word,
26
74
  distance: 0
27
75
  }];
28
- if (word.length <= 3) return [];
76
+ const segmented = segmentedReplacements(word, segmentationVocabulary);
77
+ if (word.length <= 3) return segmented;
29
78
  const maximum = word.length >= 6 ? 2 : 1;
30
- const matches = vocabulary.filter((candidate) => Math.abs(candidate.length - word.length) <= maximum).map((candidate) => ({
31
- word: candidate,
32
- distance: damerauLevenshteinDistance(word, candidate)
33
- })).filter((candidate) => candidate.distance <= maximum);
34
- if (matches.length === 0) return [];
35
- const minimum = Math.min(...matches.map((candidate) => candidate.distance));
36
- return matches.filter((candidate) => candidate.distance === minimum).slice(0, 4);
79
+ let minimum = Number.POSITIVE_INFINITY;
80
+ const matches = [];
81
+ const addMatch = (replacement, distance) => {
82
+ if (distance > minimum) return;
83
+ if (distance < minimum) {
84
+ minimum = distance;
85
+ matches.length = 0;
86
+ }
87
+ if (matches.length < 4) matches.push({
88
+ word: replacement,
89
+ distance
90
+ });
91
+ };
92
+ for (const replacement of segmented) addMatch(replacement.word, replacement.distance);
93
+ for (const candidate of vocabulary) {
94
+ if (Math.abs(candidate.length - word.length) > maximum) continue;
95
+ const distance = damerauLevenshteinDistance(word, candidate, maximum, workspace);
96
+ if (distance <= maximum) addMatch(candidate, distance);
97
+ }
98
+ return matches;
37
99
  };
38
- const correctWhitespaceSeparatedText = (input, vocabulary) => {
100
+ const emptySegmentationVocabulary = /* @__PURE__ */ new Set();
101
+ const correctWhitespaceSeparatedText = (input, vocabulary, segmentationVocabulary = emptySegmentationVocabulary) => {
39
102
  const words = naturalWords(input);
103
+ const wordsInVocabulary = vocabularySet(vocabulary);
104
+ const workspace = [
105
+ [],
106
+ [],
107
+ []
108
+ ];
40
109
  let partials = [{
41
- words: [],
110
+ text: "",
42
111
  corrections: [],
43
112
  cost: 0,
44
113
  offset: 0
45
114
  }];
46
115
  for (const word of words) {
47
- const replacements = replacementsFor(word, vocabulary);
116
+ const replacements = replacementsFor(word, wordsInVocabulary, segmentationVocabulary, workspace);
48
117
  if (replacements.length === 0) return [];
49
118
  const next = [];
50
119
  for (const partial of partials) for (const replacement of replacements) {
@@ -55,7 +124,7 @@ const correctWhitespaceSeparatedText = (input, vocabulary) => {
55
124
  offset: partial.offset
56
125
  })];
57
126
  next.push({
58
- words: [...partial.words, replacement.word],
127
+ text: partial.text.length === 0 ? replacement.word : `${partial.text} ${replacement.word}`,
59
128
  corrections: correction,
60
129
  cost: partial.cost + replacement.distance,
61
130
  offset: partial.offset + word.length + 1
@@ -64,7 +133,7 @@ const correctWhitespaceSeparatedText = (input, vocabulary) => {
64
133
  partials = next.slice(0, 32);
65
134
  }
66
135
  return partials.filter((partial) => partial.corrections.length > 0).map((partial) => NaturalCorrectionCandidate.make({
67
- text: partial.words.join(" "),
136
+ text: partial.text,
68
137
  corrections: partial.corrections,
69
138
  cost: partial.cost
70
139
  }));
@@ -7,7 +7,7 @@ const formatNatural = Effect.fn("chronolizer.formatNatural")(function* (range, o
7
7
  if (Option.isSome(rendered)) return rendered.value;
8
8
  return yield* new NaturalLanguageRenderError({
9
9
  locale: options.locale,
10
- message: "The range has no canonical natural-language form in this locale"
10
+ message: "The range has no standard natural-language form for this language"
11
11
  });
12
12
  });
13
13
  //#endregion
@@ -5,7 +5,7 @@ import { LanguageRegistry } from "../language/registry.mjs";
5
5
  import { applyFuturePolicy } from "./policy.mjs";
6
6
  import { Array, Effect, Order, Result } from "effect";
7
7
  //#region src/natural/parse.ts
8
- const distinctCandidates = (candidates) => Array.dedupeWith(candidates, (left, right) => rangeKey(left.range) === rangeKey(right.range));
8
+ const distinctCandidates = (candidates) => candidates.length === 1 ? candidates : Array.dedupeWith(candidates, (left, right) => rangeKey(left.range) === rangeKey(right.range));
9
9
  const parseQuality = (hasAlternatives, hasCorrections) => {
10
10
  if (hasAlternatives) return "ambiguous";
11
11
  if (hasCorrections) return "corrected";
@@ -31,13 +31,13 @@ const parseNatural = Effect.fn("chronolizer.parseNatural")(function* (input, opt
31
31
  if (normalized.length === 0) return yield* new NaturalLanguageParseError({
32
32
  input,
33
33
  locale: options.locale,
34
- message: "The complete input must contain a date-range expression"
34
+ message: "The complete input must contain a date range"
35
35
  });
36
36
  const exact = language.parseExact(normalized);
37
37
  const allowedExact = applyFuturePolicy(exact, options.allowFuture);
38
38
  if (Array.isReadonlyArrayNonEmpty(allowedExact)) return resultFromCandidates(allowedExact, []);
39
39
  if (options.typoMode !== "tolerant") {
40
- const message = exact.length > 0 && options.allowFuture === false ? "The expression contains a positive relative shift, but future ranges are disabled" : "The complete input is not a supported date-range expression";
40
+ const message = exact.length > 0 && options.allowFuture === false ? "This date range is in the future, but future ranges are disabled" : "The complete input is not a supported date range";
41
41
  return yield* new NaturalLanguageParseError({
42
42
  input,
43
43
  locale: options.locale,
@@ -46,7 +46,8 @@ const parseNatural = Effect.fn("chronolizer.parseNatural")(function* (input, opt
46
46
  }
47
47
  const corrected = language.correct?.(normalized, language.vocabulary) ?? [];
48
48
  const parsedCorrections = Array.filterMap(corrected, (correction) => {
49
- const candidates = applyFuturePolicy(language.parseExact(correction.text), options.allowFuture);
49
+ const correctedText = language.normalize(correction.text, language.locale);
50
+ const candidates = applyFuturePolicy(language.parseExact(correctedText), options.allowFuture);
50
51
  return Array.isReadonlyArrayNonEmpty(candidates) ? Result.succeed({
51
52
  correction,
52
53
  candidates
@@ -56,7 +57,7 @@ const parseNatural = Effect.fn("chronolizer.parseNatural")(function* (input, opt
56
57
  if (!Array.isReadonlyArrayNonEmpty(successful)) return yield* new NaturalLanguageParseError({
57
58
  input,
58
59
  locale: options.locale,
59
- message: "No conservative typo correction produced a complete expression"
60
+ message: "No supported date range was found after spelling correction"
60
61
  });
61
62
  const first = Array.headNonEmpty(successful);
62
63
  const best = Array.prepend(Array.takeWhile(Array.tailNonEmpty(successful), (entry) => entry.correction.cost === first.correction.cost), first);
@@ -1,24 +1,48 @@
1
1
  import { rangeKey } from "../filter/codec.mjs";
2
2
  import { NaturalSuggestion } from "../language/model.mjs";
3
+ import { naturalWords } from "./text.mjs";
3
4
  import { LanguageRegistry } from "../language/registry.mjs";
4
5
  import { applyFuturePolicy } from "./policy.mjs";
5
- import { Array, Effect } from "effect";
6
+ import { Array as Array$1, Effect } from "effect";
6
7
  //#region src/natural/suggest.ts
7
8
  const suggestionLimit = (limit) => {
8
9
  if (limit === void 0) return 10;
9
10
  if (!Number.isSafeInteger(limit) || limit <= 0) return 0;
10
11
  return Math.min(limit, 100);
11
12
  };
13
+ const singleWordCompletions = (input, vocabulary) => {
14
+ const words = naturalWords(input);
15
+ if (words.length === 0 || words.length > 12) return [];
16
+ const positions = Array.from({ length: words.length + 1 }, (_, index) => index);
17
+ const insertions = Array$1.flatMap(positions, (index) => Array$1.flatMap(vocabulary, (word) => word.length === 0 || word.includes(" ") ? [] : [[
18
+ ...words.slice(0, index),
19
+ word,
20
+ ...words.slice(index)
21
+ ].join(" ")]));
22
+ return Array$1.dedupe(insertions);
23
+ };
12
24
  const suggestNatural = Effect.fn("chronolizer.suggestNatural")(function* (input, options) {
13
25
  const language = yield* (yield* LanguageRegistry).resolve(options.locale);
14
26
  const normalized = language.normalize(input, language.locale);
15
27
  const limit = suggestionLimit(options.limit);
16
28
  if (limit === 0) return [];
17
- const suggestions = Array.flatMap(Array.dedupe(language.suggest(normalized, limit * 2)), (text) => applyFuturePolicy(language.parseExact(language.normalize(text, language.locale)), options.allowFuture).map((candidate) => NaturalSuggestion.make({
29
+ const suggestionsFrom = (texts) => Array$1.flatMap(Array$1.dedupe(texts), (text) => applyFuturePolicy(language.parseExact(language.normalize(text, language.locale)), options.allowFuture).map((candidate) => NaturalSuggestion.make({
18
30
  text: candidate.canonical,
19
31
  range: candidate.range
20
32
  })));
21
- return Array.take(Array.dedupeWith(suggestions, (left, right) => rangeKey(left.range) === rangeKey(right.range)), limit);
33
+ const distinct = (suggestions) => {
34
+ const keys = /* @__PURE__ */ new Set();
35
+ return suggestions.filter((suggestion) => {
36
+ const key = rangeKey(suggestion.range);
37
+ if (keys.has(key)) return false;
38
+ keys.add(key);
39
+ return true;
40
+ });
41
+ };
42
+ const suggested = distinct(suggestionsFrom([...language.suggest(normalized, limit * 2), normalized]));
43
+ if (suggested.length > 0) return Array$1.take(suggested, limit);
44
+ const completionVocabulary = Array$1.dedupe([...Array$1.flatMap(language.suggest("", 100), naturalWords), ...language.vocabulary]);
45
+ return Array$1.take(distinct(suggestionsFrom(singleWordCompletions(normalized, completionVocabulary))), limit);
22
46
  });
23
47
  //#endregion
24
48
  export { suggestNatural };