chronolizer 0.3.0 → 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.
@@ -4,7 +4,7 @@ import { normalizeNaturalText } from "../natural/text.mjs";
4
4
  import { defineLanguagePlugin, languagePluginsLayer } from "../language/registry.mjs";
5
5
  import { correctWhitespaceSeparatedText } from "../natural/correction.mjs";
6
6
  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";
7
+ import { absoluteDatePeriod, calendarPeriodOffset, candidate, compileCountAliasNormalizer, compoundCountAliases, countAliasVocabulary, currentYearDatePeriods, datedPeriods, datedQuarterPeriods, decimalTens, decomposeShiftedPeriodRange, fixedDatePeriod, fixedMonthPeriod, fixedQuarterPeriod, fixedYearPeriod, fromNowRange, futurePeriod, futureRange, isoDate, 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
8
  import { Effect, Option, String as String$1 } from "effect";
9
9
  //#region src/locales/tr.ts
10
10
  const months = [
@@ -21,6 +21,61 @@ const months = [
21
21
  "kasım",
22
22
  "aralık"
23
23
  ];
24
+ const weekdays = [
25
+ "pazartesi",
26
+ "salı",
27
+ "çarşamba",
28
+ "perşembe",
29
+ "cuma",
30
+ "cumartesi",
31
+ "pazar"
32
+ ];
33
+ const nextWeekdayPhrases = weekdays.map((weekday) => `gelecek ${weekday}`);
34
+ const turkishCountWords = [
35
+ ["iki"],
36
+ ["üç", "uc"],
37
+ ["dört", "dort"],
38
+ ["beş", "bes"],
39
+ ["altı", "alti"],
40
+ ["yedi"],
41
+ ["sekiz"],
42
+ ["dokuz"],
43
+ ["on"],
44
+ ["on bir"],
45
+ ["on iki"],
46
+ ["on üç"],
47
+ ["on dört"],
48
+ ["on beş"],
49
+ ["on altı"],
50
+ ["on yedi"],
51
+ ["on sekiz"],
52
+ ["on dokuz"],
53
+ ["yirmi"]
54
+ ];
55
+ const turkishCountOnes = [
56
+ [1, "bir"],
57
+ [2, "iki"],
58
+ [3, "üç"],
59
+ [4, "dört"],
60
+ [5, "beş"],
61
+ [6, "altı"],
62
+ [7, "yedi"],
63
+ [8, "sekiz"],
64
+ [9, "dokuz"]
65
+ ];
66
+ const turkishCountAliases = [...sequentialCountAliases(turkishCountWords, 2), ...compoundCountAliases(decimalTens([
67
+ "yirmi",
68
+ "otuz",
69
+ "kırk",
70
+ "elli",
71
+ "altmış",
72
+ "yetmiş",
73
+ "seksen",
74
+ "doksan"
75
+ ]), turkishCountOnes, (ten, one) => [`${ten} ${one}`])];
76
+ const normalizeTurkishCounts = compileCountAliasNormalizer(turkishCountAliases);
77
+ const turkishCountVocabulary = new Set(countAliasVocabulary(turkishCountAliases));
78
+ const correctTurkish = (input, vocabulary) => correctWhitespaceSeparatedText(input, vocabulary, turkishCountVocabulary);
24
79
  const monthAbbreviations = [
25
80
  ["oca"],
26
81
  ["şub", "sub"],
@@ -340,13 +395,13 @@ const parseQuarter = (input) => {
340
395
  const quarter = quarterNumber(textAt(standalone.value, 1));
341
396
  return quarter === void 0 ? Option.none() : Option.some(quarterOfRelativeYear(quarter, 0, `Ç${quarter}`));
342
397
  };
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;
398
+ const parseDatedPeriod = (input) => {
399
+ const knownPeriod = Option.firstSomeOf([
400
+ absoluteDatePeriod(input, "tr"),
401
+ parseNamedDate(input),
402
+ parseQuarter(input)
403
+ ]);
404
+ if (Option.isSome(knownPeriod)) return knownPeriod;
350
405
  const yearMatch = String$1.match(/^(?:yıl |yılı )?(\d{4})$/u)(input);
351
406
  if (Option.isSome(yearMatch)) {
352
407
  const year = validYear(textAt(yearMatch.value, 1));
@@ -358,6 +413,20 @@ const parseBasePeriod = (input) => {
358
413
  const year = validYear(textAt(monthYear.value, 2));
359
414
  if (month !== void 0 && year !== void 0) return Option.some(fixedMonthPeriod(year, month, `${title(textAt(months, month - 1))} ${year}`));
360
415
  }
416
+ return Option.none();
417
+ };
418
+ const parseBasePeriod = (input) => {
419
+ const datedPeriod = parseDatedPeriod(input);
420
+ if (Option.isSome(datedPeriod)) return datedPeriod;
421
+ const prefixedRelativeMonth = String$1.match(/^(geçen|bu|gelecek) ([a-zçğıöşü]+\.?)$/u)(input);
422
+ if (Option.isSome(prefixedRelativeMonth)) {
423
+ const month = monthNumber(textAt(prefixedRelativeMonth.value, 2));
424
+ if (month !== void 0) {
425
+ const modifier = textAt(prefixedRelativeMonth.value, 1);
426
+ const direction = relativeYearDirection(modifier);
427
+ return Option.some(monthOfRelativeYear(month, direction, `${modifier} ${title(textAt(months, month - 1))}`));
428
+ }
429
+ }
361
430
  const relativeMonth = String$1.match(/^([a-zçğıöşü]+\.?) (geçen yıl|gelecek yıl|bu yıl)$/u)(input);
362
431
  const relativeMonthYearFirst = String$1.match(/^(geçen yıl|gelecek yıl|bu yıl) ([a-zçğıöşü]+\.?)$/u)(input);
363
432
  const relativeMatch = Option.firstSomeOf([relativeMonth, relativeMonthYearFirst]);
@@ -378,16 +447,38 @@ const parseBasePeriod = (input) => {
378
447
  if (["gelecek hafta sonu", "önümüzdeki hafta sonu"].includes(input)) return Option.some(relativeWeekend(1, "gelecek hafta sonu"));
379
448
  if (input === "geçen hafta sonundan önceki hafta sonu") return Option.some(relativeWeekend(-2, input));
380
449
  if (input === "gelecek hafta sonundan sonraki hafta sonu") return Option.some(relativeWeekend(2, input));
381
- return Option.none();
450
+ const weekday = nextWeekdayPhrases.indexOf(input);
451
+ return weekday === -1 ? Option.none() : Option.some(relativeWeekday(weekday, 1, nextWeekdayPhrases[weekday] ?? input));
382
452
  };
383
- const parsePeriod = (input) => {
453
+ const parsePeriodEdge = (input) => {
384
454
  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));
455
+ if (Option.isNone(edge)) return Option.none();
456
+ const periodText = textAt(edge.value, 1);
457
+ const basePeriod = parseBasePeriod(periodText);
458
+ const implicitUnit = unitAliases.find((entry) => entry[0] === periodText)?.[1];
459
+ const implicit = units.find((entry) => entry.unit === implicitUnit);
460
+ const period = Option.isSome(basePeriod) || implicit === void 0 ? basePeriod : Option.some(relativePeriod(implicit.unit, 0, implicit.current));
461
+ if (Option.isNone(period)) return Option.none();
462
+ const isEnd = textAt(edge.value, 2) === "sonu";
463
+ const canonical = `${period.value.canonical} ${isEnd ? "sonu" : "başı"}`;
464
+ return Option.some(isEnd ? periodEndDay(period.value, canonical) : periodStartDay(period.value, canonical));
465
+ };
466
+ const parsePeriod = (input) => {
467
+ const prefix = String$1.match(/^([1-9]\d*) (gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene) (önce|sonra) (.+)$/u)(input);
468
+ const suffix = String$1.match(/^(.+) ([1-9]\d*) (gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene) (önce|sonra)$/u)(input);
469
+ const shifted = Option.firstSomeOf([prefix, suffix]);
470
+ if (Option.isSome(shifted)) {
471
+ const prefixOrder = Option.isSome(prefix);
472
+ const amount = parseTrailingCount(textAt(shifted.value, prefixOrder ? 1 : 2));
473
+ const alias = unitAliases.find((unit) => unit[0] === textAt(shifted.value, prefixOrder ? 2 : 3));
474
+ const entry = alias === void 0 ? void 0 : units.find((unit) => unit.unit === alias[1]);
475
+ const period = parsePeriod(textAt(shifted.value, prefixOrder ? 4 : 1));
476
+ if (Option.isSome(amount) && entry !== void 0 && Option.isSome(period)) {
477
+ const past = textAt(shifted.value, prefixOrder ? 3 : 4) === "önce";
478
+ const direction = past ? -amount.value : amount.value;
479
+ const noun = amount.value === 1 ? entry.singular : entry.plural;
480
+ const canonical = `${amount.value} ${noun} ${past ? "önce" : "sonra"} ${period.value.canonical}`;
481
+ return Option.some(shiftPeriod(period.value, direction, entry.unit, canonical));
391
482
  }
392
483
  }
393
484
  const wrapper = [
@@ -395,7 +486,11 @@ const parsePeriod = (input) => {
395
486
  "içinde ",
396
487
  "tüm "
397
488
  ].find((prefix) => input.startsWith(prefix));
398
- return parseBasePeriod(wrapper === void 0 ? input : input.slice(wrapper.length));
489
+ return Option.firstSomeOf([
490
+ parsePeriodEdge(input),
491
+ parseBasePeriod(wrapper === void 0 ? input : input.slice(wrapper.length)),
492
+ parseCalendarOffset(input)
493
+ ]);
399
494
  };
400
495
  const countedUnit = (value) => unitAliases.find((entry) => entry[0] === value)?.[1];
401
496
  const countedUnitPattern = "gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene";
@@ -446,7 +541,7 @@ const singularCalendarOffsets = units.flatMap((entry) => [{
446
541
  }]);
447
542
  const parseCalendarOffset = (input) => {
448
543
  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));
544
+ if (singular !== void 0) return Option.some(relativePeriod(singular.entry.unit, singular.direction, singular.phrase));
450
545
  const past = String$1.match(calendarPastPattern)(input);
451
546
  const future = firstPatternMatch(input, calendarFuturePatterns);
452
547
  const match = Option.firstSomeOf([past, future]);
@@ -459,7 +554,7 @@ const parseCalendarOffset = (input) => {
459
554
  const direction = Option.isSome(past) ? -amount.value : amount.value;
460
555
  const noun = amount.value === 1 ? entry.singular : entry.plural;
461
556
  const canonical = direction < 0 ? `${amount.value} ${noun} önce` : `${amount.value} ${noun} sonra`;
462
- return Option.some(candidate(periodRange(relativePeriod(unit, direction, canonical)), canonical));
557
+ return Option.some(relativePeriod(unit, direction, canonical));
463
558
  };
464
559
  const parseRollingPeriod = (input) => {
465
560
  const singular = singularRollingPhrases.find((entry) => entry.phrase === input);
@@ -543,8 +638,6 @@ const parseTurkish = (input) => {
543
638
  "şu andan itibaren",
544
639
  "bundan sonra"
545
640
  ].includes(input)) return Option.some(candidate(fromNowRange(), "bugünden itibaren"));
546
- const offset = parseCalendarOffset(input);
547
- if (Option.isSome(offset)) return offset;
548
641
  const rolling = parseRollingPeriod(input);
549
642
  if (Option.isSome(rolling)) return rolling;
550
643
  const toDate = toDatePhrases.find((entry) => entry.phrase === input);
@@ -552,7 +645,12 @@ const parseTurkish = (input) => {
552
645
  const elided = parseElidedDateRange(input);
553
646
  if (Option.isSome(elided)) return elided;
554
647
  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ı`);
648
+ const nowBounded = joinedNowCandidate(joinedInput, [["", " ile bugün"], ["", " ve bugün"]], [
649
+ "bugün ile ",
650
+ "bugün ve ",
651
+ "şimdi ile ",
652
+ "şimdi ve "
653
+ ], parsePeriod, (period) => `${period} ile bugün arası`, (period) => `bugün ile ${period} arası`);
556
654
  if (Option.isSome(nowBounded)) return nowBounded;
557
655
  const bounded = joinedPeriodCandidate(joinedInput, [
558
656
  ["", " ile "],
@@ -591,6 +689,8 @@ const staticPeriodPhrases = [
591
689
  "geçen yılın sonu",
592
690
  "gelecek yılın başı",
593
691
  "gelecek yılın sonu",
692
+ ...nextWeekdayPhrases,
693
+ ...months.flatMap((month) => [`geçen ${month}`, `gelecek ${month}`]),
594
694
  ...[
595
695
  1,
596
696
  2,
@@ -651,6 +751,17 @@ const suggestTurkish = (input, limit) => {
651
751
  ], limit);
652
752
  };
653
753
  const renderTurkish = (range) => {
754
+ const shifted = decomposeShiftedPeriodRange(range);
755
+ if (Option.isSome(shifted)) {
756
+ const base = renderTurkish(shifted.value.baseRange);
757
+ const entry = units.find((unit) => unit.unit === shifted.value.unit);
758
+ if (Option.isSome(base) && entry !== void 0) {
759
+ const amount = Math.abs(shifted.value.amount);
760
+ const noun = amount === 1 ? entry.singular : entry.plural;
761
+ const direction = shifted.value.amount < 0 ? "önce" : "sonra";
762
+ return Option.some(`${amount} ${noun} ${direction} ${base.value}`);
763
+ }
764
+ }
654
765
  const offset = calendarPeriodOffset(range);
655
766
  if (Option.isSome(offset) && Math.abs(offset.value.amount) > 1) {
656
767
  const entry = units.find((unit) => unit.unit === offset.value.unit);
@@ -677,11 +788,12 @@ const renderTurkish = (range) => {
677
788
  ];
678
789
  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
790
  };
680
- const normalizeTurkish = (input, locale) => normalizeNaturalText(input, locale).replaceAll("’", "'");
791
+ const normalizeTurkish = (input, locale) => normalizeTurkishCounts(normalizeNaturalText(input, locale).replaceAll("’", "'"));
681
792
  const TurkishContribution = new BaseLanguageContribution({
682
793
  locale: "tr",
683
794
  vocabulary: [
684
795
  ...months,
796
+ ...weekdays,
685
797
  ...monthAbbreviations.flatMap((aliases) => aliases),
686
798
  ...units.flatMap((entry) => [
687
799
  entry.singular,
@@ -698,6 +810,7 @@ const TurkishContribution = new BaseLanguageContribution({
698
810
  "gelecek",
699
811
  "geçen",
700
812
  "geri",
813
+ "ile",
701
814
  "itibaren",
702
815
  "itibarıyla",
703
816
  "kadar",
@@ -708,10 +821,11 @@ const TurkishContribution = new BaseLanguageContribution({
708
821
  "son",
709
822
  "sonra",
710
823
  "sonrası",
824
+ "şimdi",
711
825
  "şimdiye"
712
826
  ],
713
827
  normalize: normalizeTurkish,
714
- correct: correctWhitespaceSeparatedText,
828
+ correct: correctTurkish,
715
829
  parseExact: parseTurkish,
716
830
  suggest: suggestTurkish,
717
831
  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;
@@ -20,22 +20,48 @@ const damerauLevenshteinDistance = (left, right) => {
20
20
  }
21
21
  return previous[right.length] ?? fallback;
22
22
  };
23
- const replacementsFor = (word, vocabulary) => {
23
+ const segmentedReplacements = (word, vocabulary) => {
24
+ const segmentations = Array.from({ length: word.length + 1 }, () => []);
25
+ const initial = segmentations[0];
26
+ if (initial !== void 0) initial.push([]);
27
+ for (let start = 0; start < word.length; start += 1) {
28
+ const prefixes = segmentations[start] ?? [];
29
+ if (prefixes.length === 0) continue;
30
+ for (let end = start + 1; end <= word.length; end += 1) {
31
+ const part = word.slice(start, end);
32
+ if (!vocabulary.has(part)) continue;
33
+ const target = segmentations[end];
34
+ if (target === void 0) continue;
35
+ for (const prefix of prefixes) {
36
+ if (target.length >= 4) break;
37
+ target.push([...prefix, part]);
38
+ }
39
+ }
40
+ }
41
+ return (segmentations[word.length] ?? []).filter((parts) => parts.length > 1).map((parts) => ({
42
+ word: parts.join(" "),
43
+ distance: parts.length - 1
44
+ }));
45
+ };
46
+ const replacementsFor = (word, vocabulary, segmentationVocabulary) => {
24
47
  if (vocabulary.includes(word) || isProtectedValue(word)) return [{
25
48
  word,
26
49
  distance: 0
27
50
  }];
28
- if (word.length <= 3) return [];
51
+ const segmented = segmentedReplacements(word, segmentationVocabulary);
52
+ if (word.length <= 3) return segmented;
29
53
  const maximum = word.length >= 6 ? 2 : 1;
30
- const matches = vocabulary.filter((candidate) => Math.abs(candidate.length - word.length) <= maximum).map((candidate) => ({
54
+ const fuzzy = vocabulary.filter((candidate) => Math.abs(candidate.length - word.length) <= maximum).map((candidate) => ({
31
55
  word: candidate,
32
56
  distance: damerauLevenshteinDistance(word, candidate)
33
57
  })).filter((candidate) => candidate.distance <= maximum);
58
+ const matches = [...segmented, ...fuzzy];
34
59
  if (matches.length === 0) return [];
35
60
  const minimum = Math.min(...matches.map((candidate) => candidate.distance));
36
61
  return matches.filter((candidate) => candidate.distance === minimum).slice(0, 4);
37
62
  };
38
- const correctWhitespaceSeparatedText = (input, vocabulary) => {
63
+ const emptySegmentationVocabulary = /* @__PURE__ */ new Set();
64
+ const correctWhitespaceSeparatedText = (input, vocabulary, segmentationVocabulary = emptySegmentationVocabulary) => {
39
65
  const words = naturalWords(input);
40
66
  let partials = [{
41
67
  words: [],
@@ -44,7 +70,7 @@ const correctWhitespaceSeparatedText = (input, vocabulary) => {
44
70
  offset: 0
45
71
  }];
46
72
  for (const word of words) {
47
- const replacements = replacementsFor(word, vocabulary);
73
+ const replacements = replacementsFor(word, vocabulary, segmentationVocabulary);
48
74
  if (replacements.length === 0) return [];
49
75
  const next = [];
50
76
  for (const partial of partials) for (const replacement of replacements) {
@@ -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
@@ -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,40 @@
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) => Array$1.dedupeWith(suggestions, (left, right) => rangeKey(left.range) === rangeKey(right.range));
34
+ const suggested = distinct(suggestionsFrom([...language.suggest(normalized, limit * 2), normalized]));
35
+ if (suggested.length > 0) return Array$1.take(suggested, limit);
36
+ const completionVocabulary = Array$1.dedupe([...Array$1.flatMap(language.suggest("", 100), naturalWords), ...language.vocabulary]);
37
+ return Array$1.take(distinct(suggestionsFrom(singleWordCompletions(normalized, completionVocabulary))), limit);
22
38
  });
23
39
  //#endregion
24
40
  export { suggestNatural };
@@ -23,14 +23,20 @@ const phraseScore = (input, phrase) => {
23
23
  const phraseWords = phrase.split(" ");
24
24
  if (inputWords.length > phraseWords.length) return void 0;
25
25
  let cost = 0;
26
- for (const [index, inputWord] of inputWords.entries()) {
27
- const targetWord = phraseWords[index];
28
- if (targetWord === void 0) return void 0;
29
- const wordCost = completionCost(inputWord, targetWord);
26
+ let addedWords = 0;
27
+ let phraseIndex = 0;
28
+ for (const inputWord of inputWords) {
29
+ let wordCost = completionCost(inputWord, phraseWords[phraseIndex] ?? "");
30
+ while (wordCost === void 0 && addedWords < 3) {
31
+ phraseIndex += 1;
32
+ addedWords += 1;
33
+ wordCost = completionCost(inputWord, phraseWords[phraseIndex] ?? "");
34
+ }
30
35
  if (wordCost === void 0) return void 0;
31
36
  cost += wordCost;
37
+ phraseIndex += 1;
32
38
  }
33
- return cost === 0 ? 2 : 2 + cost;
39
+ return 2 + cost + addedWords;
34
40
  };
35
41
  const completeYearPrefix = (input) => {
36
42
  const match = String.match(/(?:^| )(\d{3,4})$/u)(input);
@@ -45,7 +45,7 @@ const resolve = Effect.fn("chronolizer.resolve")(function* (range) {
45
45
  if (range.lower !== void 0 && range.upper !== void 0) {
46
46
  const lower = yield* resolveLower(range.lower, reference, zone);
47
47
  const upper = yield* resolveUpper(range.upper, reference, zone);
48
- if (DateTime.toEpochMillis(lower.value) >= DateTime.toEpochMillis(upper.value)) return yield* new ResolutionError({ message: "The lower range endpoint must be before the upper endpoint" });
48
+ if (DateTime.toEpochMillis(lower.value) >= DateTime.toEpochMillis(upper.value)) return yield* new ResolutionError({ message: "The range start must be before the range end" });
49
49
  return ResolvedDateRange.make({
50
50
  lower,
51
51
  upper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chronolizer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Bidirectional natural-language date ranges for Effect.",
5
5
  "homepage": "https://github.com/tobimori/chronolizer#readme",
6
6
  "bugs": "https://github.com/tobimori/chronolizer/issues",