chronolizer 0.2.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 { calendarPeriodOffset, candidate, datedPeriods, datedQuarterPeriods, fixedDatePeriod, fixedMonthPeriod, fixedQuarterPeriod, fixedYearPeriod, fromNowRange, futurePeriod, futureRange, isoDate, joinedNowCandidate, joinedPeriodCandidate, monthOfRelativeYear, 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"],
@@ -298,17 +353,22 @@ const monthNumber = (value) => {
298
353
  return short === -1 ? void 0 : short + 1;
299
354
  };
300
355
  const dateLabel = (day, month, year) => `${day} ${textAt(months, month - 1)} ${year}`;
356
+ const currentDateLabel = (day, month) => `${day} ${textAt(months, month - 1)}`;
301
357
  const parseNamedDate = (input) => {
302
358
  const named = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)(?:,)? (\d{4})$/u)(input);
303
359
  if (Option.isSome(named)) return namedDatePeriod(textAt(named.value, 3), textAt(named.value, 2), textAt(named.value, 1), monthNumber, dateLabel);
304
360
  const numeric = String$1.match(/^([0-3]?\d)[./-]([01]?\d)[./-](\d{4})$/u)(input);
305
- if (Option.isNone(numeric)) return Option.none();
306
- const year = validYear(textAt(numeric.value, 3));
307
- const month = Number(textAt(numeric.value, 2));
308
- const day = Number(textAt(numeric.value, 1));
309
- if (year === void 0 || month < 1 || month > 12) return Option.none();
310
- const value = isoDate(year, month, day);
311
- return isIsoDate(value) && value !== "9999-12-31" ? Option.some(fixedDatePeriod(value, dateLabel(day, month, year))) : Option.none();
361
+ if (Option.isSome(numeric)) {
362
+ const year = validYear(textAt(numeric.value, 3));
363
+ const month = Number(textAt(numeric.value, 2));
364
+ const day = Number(textAt(numeric.value, 1));
365
+ if (year !== void 0 && month >= 1 && month <= 12) {
366
+ const value = isoDate(year, month, day);
367
+ if (isIsoDate(value) && value !== "9999-12-31") return Option.some(fixedDatePeriod(value, dateLabel(day, month, year)));
368
+ }
369
+ }
370
+ const current = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)$/u)(input);
371
+ return Option.isSome(current) ? namedCurrentYearDatePeriod(textAt(current.value, 2), textAt(current.value, 1), monthNumber, currentDateLabel) : Option.none();
312
372
  };
313
373
  const quarterNumber = (value) => {
314
374
  if ((value.startsWith("q") || value.startsWith("ç")) && value.length === 2) return Number(value.slice(1));
@@ -335,12 +395,13 @@ const parseQuarter = (input) => {
335
395
  const quarter = quarterNumber(textAt(standalone.value, 1));
336
396
  return quarter === void 0 ? Option.none() : Option.some(quarterOfRelativeYear(quarter, 0, `Ç${quarter}`));
337
397
  };
338
- const parseBasePeriod = (input) => {
339
- if (isIsoDate(input) && input !== "9999-12-31") return Option.some(fixedDatePeriod(input, input));
340
- const namedDate = parseNamedDate(input);
341
- if (Option.isSome(namedDate)) return namedDate;
342
- const quarter = parseQuarter(input);
343
- 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;
344
405
  const yearMatch = String$1.match(/^(?:yıl |yılı )?(\d{4})$/u)(input);
345
406
  if (Option.isSome(yearMatch)) {
346
407
  const year = validYear(textAt(yearMatch.value, 1));
@@ -352,6 +413,20 @@ const parseBasePeriod = (input) => {
352
413
  const year = validYear(textAt(monthYear.value, 2));
353
414
  if (month !== void 0 && year !== void 0) return Option.some(fixedMonthPeriod(year, month, `${title(textAt(months, month - 1))} ${year}`));
354
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
+ }
355
430
  const relativeMonth = String$1.match(/^([a-zçğıöşü]+\.?) (geçen yıl|gelecek yıl|bu yıl)$/u)(input);
356
431
  const relativeMonthYearFirst = String$1.match(/^(geçen yıl|gelecek yıl|bu yıl) ([a-zçğıöşü]+\.?)$/u)(input);
357
432
  const relativeMatch = Option.firstSomeOf([relativeMonth, relativeMonthYearFirst]);
@@ -372,16 +447,38 @@ const parseBasePeriod = (input) => {
372
447
  if (["gelecek hafta sonu", "önümüzdeki hafta sonu"].includes(input)) return Option.some(relativeWeekend(1, "gelecek hafta sonu"));
373
448
  if (input === "geçen hafta sonundan önceki hafta sonu") return Option.some(relativeWeekend(-2, input));
374
449
  if (input === "gelecek hafta sonundan sonraki hafta sonu") return Option.some(relativeWeekend(2, input));
375
- return Option.none();
450
+ const weekday = nextWeekdayPhrases.indexOf(input);
451
+ return weekday === -1 ? Option.none() : Option.some(relativeWeekday(weekday, 1, nextWeekdayPhrases[weekday] ?? input));
376
452
  };
377
- const parsePeriod = (input) => {
453
+ const parsePeriodEdge = (input) => {
378
454
  const edge = String$1.match(/^(.+?)(?:ın|in|un|ün) (başı|başlangıcı|sonu)$/u)(input);
379
- if (Option.isSome(edge)) {
380
- const period = parseBasePeriod(textAt(edge.value, 1));
381
- if (Option.isSome(period)) {
382
- const isEnd = textAt(edge.value, 2) === "sonu";
383
- const canonical = `${period.value.canonical} ${isEnd ? "sonu" : "başı"}`;
384
- 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));
385
482
  }
386
483
  }
387
484
  const wrapper = [
@@ -389,7 +486,11 @@ const parsePeriod = (input) => {
389
486
  "içinde ",
390
487
  "tüm "
391
488
  ].find((prefix) => input.startsWith(prefix));
392
- 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
+ ]);
393
494
  };
394
495
  const countedUnit = (value) => unitAliases.find((entry) => entry[0] === value)?.[1];
395
496
  const countedUnitPattern = "gün|gun|hafta|ay|çeyrek|ceyrek|yıl|yil|sene";
@@ -401,7 +502,46 @@ const rollingFuturePatterns = [countedPattern("^(?:gelecek|önümüzdeki|sonraki
401
502
  const rollingSincePattern = countedPattern("^([1-9]\\d*) (UNIT) boyunca$");
402
503
  const rollingBarePattern = countedPattern("^([1-9]\\d*) (UNIT)$");
403
504
  const firstPatternMatch = (input, patterns) => Option.firstSomeOf(patterns.map((pattern) => String$1.match(pattern)(input)));
505
+ const singularRollingCanonical = (entry, future) => future ? `önümüzdeki bir ${entry.singular}` : `son bir ${entry.singular}`;
506
+ const singularRollingPhrases = units.flatMap((entry) => [
507
+ {
508
+ phrase: `son bir ${entry.singular}`,
509
+ entry,
510
+ future: false
511
+ },
512
+ {
513
+ phrase: `bir ${entry.singular} boyunca`,
514
+ entry,
515
+ future: false
516
+ },
517
+ {
518
+ phrase: `geçtiğimiz bir ${entry.singular}`,
519
+ entry,
520
+ future: false
521
+ },
522
+ {
523
+ phrase: `önümüzdeki bir ${entry.singular}`,
524
+ entry,
525
+ future: true
526
+ },
527
+ {
528
+ phrase: `bugünden itibaren bir ${entry.singular} boyunca`,
529
+ entry,
530
+ future: true
531
+ }
532
+ ]);
533
+ const singularCalendarOffsets = units.flatMap((entry) => [{
534
+ phrase: `bir ${entry.singular} önce`,
535
+ entry,
536
+ direction: -1
537
+ }, {
538
+ phrase: `bir ${entry.singular} sonra`,
539
+ entry,
540
+ direction: 1
541
+ }]);
404
542
  const parseCalendarOffset = (input) => {
543
+ const singular = singularCalendarOffsets.find((entry) => entry.phrase === input);
544
+ if (singular !== void 0) return Option.some(relativePeriod(singular.entry.unit, singular.direction, singular.phrase));
405
545
  const past = String$1.match(calendarPastPattern)(input);
406
546
  const future = firstPatternMatch(input, calendarFuturePatterns);
407
547
  const match = Option.firstSomeOf([past, future]);
@@ -414,9 +554,14 @@ const parseCalendarOffset = (input) => {
414
554
  const direction = Option.isSome(past) ? -amount.value : amount.value;
415
555
  const noun = amount.value === 1 ? entry.singular : entry.plural;
416
556
  const canonical = direction < 0 ? `${amount.value} ${noun} önce` : `${amount.value} ${noun} sonra`;
417
- return Option.some(candidate(periodRange(relativePeriod(unit, direction, canonical)), canonical));
557
+ return Option.some(relativePeriod(unit, direction, canonical));
418
558
  };
419
559
  const parseRollingPeriod = (input) => {
560
+ const singular = singularRollingPhrases.find((entry) => entry.phrase === input);
561
+ if (singular !== void 0) {
562
+ const range = singular.future ? futureRange(1, singular.entry.unit) : trailingRange(1, singular.entry.unit);
563
+ return Option.some(candidate(range, singularRollingCanonical(singular.entry, singular.future)));
564
+ }
420
565
  const since = String$1.match(rollingSincePattern)(input);
421
566
  const past = firstPatternMatch(input, rollingPastPatterns);
422
567
  const bare = String$1.match(rollingBarePattern)(input);
@@ -435,9 +580,22 @@ const parseRollingPeriod = (input) => {
435
580
  if (entry === void 0) return Option.none();
436
581
  const isFuture = Option.isSome(future);
437
582
  const range = isFuture ? futureRange(amount.value, unit) : trailingRange(amount.value, unit);
583
+ if (amount.value === 1) return Option.some(candidate(range, singularRollingCanonical(entry, isFuture)));
438
584
  const modifier = isFuture ? "gelecek" : "son";
439
- const noun = amount.value === 1 ? entry.singular : entry.plural;
440
- return Option.some(candidate(range, `${modifier} ${amount.value} ${noun}`));
585
+ return Option.some(candidate(range, `${modifier} ${amount.value} ${entry.plural}`));
586
+ };
587
+ const parseElidedDateRange = (input) => {
588
+ const full = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)'?(?:den|dan|ten|tan) ([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)'?(?:e|a|ye|ya) kadar$/u)(input);
589
+ if (Option.isSome(full)) return joinedPeriodCandidate(`${textAt(full.value, 1)} ${textAt(full.value, 2)} ile ${textAt(full.value, 3)} ${textAt(full.value, 4)}`, [["", " ile "]], parsePeriod, (lower, upper) => `${lower} ile ${upper} arası`);
590
+ const elided = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)'?(?:den|dan|ten|tan) ([0-3]?\d)'?(?:sine|sına|üne|una|ine|ına) kadar$/u)(input);
591
+ const dashed = String$1.match(/^([0-3]?\d)[–—-]([0-3]?\d) ([a-zçğıöşü]+\.?)$/u)(input);
592
+ const match = Option.firstSomeOf([elided, dashed]);
593
+ if (Option.isNone(match)) return Option.none();
594
+ const isElided = Option.isSome(elided);
595
+ const lowerDay = textAt(match.value, 1);
596
+ const upperDay = textAt(match.value, isElided ? 3 : 2);
597
+ const month = textAt(match.value, isElided ? 2 : 3);
598
+ return joinedPeriodCandidate(`${lowerDay} ${month} ile ${upperDay} ${month}`, [["", " ile "]], parsePeriod, (lower, upper) => `${lower} ile ${upper} arası`);
441
599
  };
442
600
  const suffixBoundary = (input) => {
443
601
  const boundaries = [
@@ -480,14 +638,19 @@ const parseTurkish = (input) => {
480
638
  "şu andan itibaren",
481
639
  "bundan sonra"
482
640
  ].includes(input)) return Option.some(candidate(fromNowRange(), "bugünden itibaren"));
483
- const offset = parseCalendarOffset(input);
484
- if (Option.isSome(offset)) return offset;
485
641
  const rolling = parseRollingPeriod(input);
486
642
  if (Option.isSome(rolling)) return rolling;
487
643
  const toDate = toDatePhrases.find((entry) => entry.phrase === input);
488
644
  if (toDate !== void 0) return Option.some(candidate(periodToDateRange(toDate.entry.unit), toDate.entry.toDate));
645
+ const elided = parseElidedDateRange(input);
646
+ if (Option.isSome(elided)) return elided;
489
647
  const joinedInput = input.endsWith(" arası") ? input.slice(0, -6) : input;
490
- 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ı`);
491
654
  if (Option.isSome(nowBounded)) return nowBounded;
492
655
  const bounded = joinedPeriodCandidate(joinedInput, [
493
656
  ["", " ile "],
@@ -499,7 +662,7 @@ const parseTurkish = (input) => {
499
662
  if (Option.isSome(bounded)) return bounded;
500
663
  const boundary = boundaryCandidate(input);
501
664
  if (Option.isSome(boundary)) return boundary;
502
- return Option.map(parsePeriod(input), (period) => candidate(periodRange(period), period.canonical));
665
+ return parsePeriod(input).pipe(Option.map((period) => candidate(periodRange(period), period.canonical)));
503
666
  };
504
667
  const staticPeriodPhrases = [
505
668
  ...periodAliases.map((entry) => entry[0]),
@@ -526,6 +689,8 @@ const staticPeriodPhrases = [
526
689
  "geçen yılın sonu",
527
690
  "gelecek yılın başı",
528
691
  "gelecek yılın sonu",
692
+ ...nextWeekdayPhrases,
693
+ ...months.flatMap((month) => [`geçen ${month}`, `gelecek ${month}`]),
529
694
  ...[
530
695
  1,
531
696
  2,
@@ -569,6 +734,8 @@ const countedSuggestions = (input) => {
569
734
  const turkishSuggestionPhrases = [
570
735
  ...units.map((entry) => entry.toDate),
571
736
  ...units.map((entry) => entry.remaining),
737
+ ...singularRollingPhrases.map((entry) => entry.phrase),
738
+ ...singularCalendarOffsets.map((entry) => entry.phrase),
572
739
  ...staticPeriodPhrases,
573
740
  ...prefixNaturalPhrases(staticPeriodPhrases, boundaryPrefixes),
574
741
  "şimdiye kadar",
@@ -584,6 +751,17 @@ const suggestTurkish = (input, limit) => {
584
751
  ], limit);
585
752
  };
586
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
+ }
587
765
  const offset = calendarPeriodOffset(range);
588
766
  if (Option.isSome(offset) && Math.abs(offset.value.amount) > 1) {
589
767
  const entry = units.find((unit) => unit.unit === offset.value.unit);
@@ -596,27 +774,26 @@ const renderTurkish = (range) => {
596
774
  const future = futurePeriod(range);
597
775
  if (Option.isSome(future)) {
598
776
  const entry = units.find((unit) => unit.unit === future.value.unit);
599
- if (entry !== void 0) {
600
- const noun = future.value.amount === 1 ? entry.singular : entry.plural;
601
- return Option.some(`gelecek ${future.value.amount} ${noun}`);
602
- }
777
+ if (entry !== void 0) return Option.some(future.value.amount === 1 ? singularRollingCanonical(entry, true) : `gelecek ${future.value.amount} ${entry.plural}`);
603
778
  }
604
779
  const trailing = trailingPeriod(range);
605
780
  if (Option.isSome(trailing)) {
606
781
  const entry = units.find((unit) => unit.unit === trailing.value.unit);
607
- if (entry !== void 0) {
608
- const noun = trailing.value.amount === 1 ? entry.singular : entry.plural;
609
- return Option.some(`son ${trailing.value.amount} ${noun}`);
610
- }
782
+ if (entry !== void 0) return Option.some(trailing.value.amount === 1 ? singularRollingCanonical(entry, false) : `son ${trailing.value.amount} ${entry.plural}`);
611
783
  }
612
- const periods = [...staticPeriods, ...periodsFromPhrases([...datedPeriods(range, months), ...datedQuarterPeriods(range).map((phrase) => phrase.startsWith("Q") ? `Ç${phrase.slice(1)}` : phrase)], parsePeriod)];
784
+ const periods = [
785
+ ...staticPeriods,
786
+ ...currentYearDatePeriods(range, currentDateLabel),
787
+ ...periodsFromPhrases([...datedPeriods(range, months), ...datedQuarterPeriods(range).map((phrase) => phrase.startsWith("Q") ? `Ç${phrase.slice(1)}` : phrase)], parsePeriod)
788
+ ];
613
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");
614
790
  };
615
- const normalizeTurkish = (input, locale) => normalizeNaturalText(input, locale).replaceAll("’", "'");
791
+ const normalizeTurkish = (input, locale) => normalizeTurkishCounts(normalizeNaturalText(input, locale).replaceAll("’", "'"));
616
792
  const TurkishContribution = new BaseLanguageContribution({
617
793
  locale: "tr",
618
794
  vocabulary: [
619
795
  ...months,
796
+ ...weekdays,
620
797
  ...monthAbbreviations.flatMap((aliases) => aliases),
621
798
  ...units.flatMap((entry) => [
622
799
  entry.singular,
@@ -633,6 +810,7 @@ const TurkishContribution = new BaseLanguageContribution({
633
810
  "gelecek",
634
811
  "geçen",
635
812
  "geri",
813
+ "ile",
636
814
  "itibaren",
637
815
  "itibarıyla",
638
816
  "kadar",
@@ -643,10 +821,11 @@ const TurkishContribution = new BaseLanguageContribution({
643
821
  "son",
644
822
  "sonra",
645
823
  "sonrası",
824
+ "şimdi",
646
825
  "şimdiye"
647
826
  ],
648
827
  normalize: normalizeTurkish,
649
- correct: correctWhitespaceSeparatedText,
828
+ correct: correctTurkish,
650
829
  parseExact: parseTurkish,
651
830
  suggest: suggestTurkish,
652
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
@@ -55,7 +55,6 @@ declare const parseNatural: (input: string, options: ParseNaturalOptions) => Eff
55
55
  readonly offset: number;
56
56
  }[];
57
57
  readonly alternatives: readonly {
58
- readonly canonical: string;
59
58
  readonly range: {
60
59
  readonly _tag: "DateRange";
61
60
  readonly lower: {
@@ -93,6 +92,7 @@ declare const parseNatural: (input: string, options: ParseNaturalOptions) => Eff
93
92
  readonly value: InstantExpr;
94
93
  };
95
94
  };
95
+ readonly canonical: string;
96
96
  }[];
97
97
  }, NaturalLanguageParseError | UnsupportedLocaleError, LanguageRegistry>;
98
98
  //#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,6 +1,6 @@
1
- import { containsPositiveShift } from "../ast/fold.mjs";
1
+ import { containsPositiveShift, isCurrentPeriod } from "../ast/fold.mjs";
2
2
  import { Array } from "effect";
3
3
  //#region src/natural/policy.ts
4
- const applyFuturePolicy = (candidates, allowFuture) => allowFuture === false ? Array.filter(candidates, (candidate) => !containsPositiveShift(candidate.range)) : candidates;
4
+ const applyFuturePolicy = (candidates, allowFuture) => allowFuture === false ? Array.filter(candidates, (candidate) => isCurrentPeriod(candidate.range) || !containsPositiveShift(candidate.range)) : candidates;
5
5
  //#endregion
6
6
  export { applyFuturePolicy };
@@ -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);
@@ -2,15 +2,7 @@ import { foldInstant } from "../ast/fold.mjs";
2
2
  import { ResolutionError, ResolvedDateRange, ResolvedGreaterThan, ResolvedGreaterThanOrEqual, ResolvedLessThan, ResolvedLessThanOrEqual } from "./schema.mjs";
3
3
  import { DateTime, Effect, Match, Option } from "effect";
4
4
  //#region src/resolve/resolve.ts
5
- const shiftDateTime = (value, amount, unit) => {
6
- switch (unit) {
7
- case "day": return DateTime.add(value, { days: amount });
8
- case "week": return DateTime.add(value, { weeks: amount });
9
- case "month": return DateTime.add(value, { months: amount });
10
- case "quarter": return DateTime.add(value, { months: amount * 3 });
11
- case "year": return DateTime.add(value, { years: amount });
12
- }
13
- };
5
+ const shiftDateTime = (value, amount, unit) => Match.value(unit).pipe(Match.when("day", () => DateTime.add(value, { days: amount })), Match.when("week", () => DateTime.add(value, { weeks: amount })), Match.when("month", () => DateTime.add(value, { months: amount })), Match.when("quarter", () => DateTime.add(value, { months: amount * 3 })), Match.when("year", () => DateTime.add(value, { years: amount })), Match.exhaustive);
14
6
  const startOfDateTime = (value, unit) => {
15
7
  if (unit === "quarter") {
16
8
  const month = DateTime.getPart(value, "month");
@@ -53,7 +45,7 @@ const resolve = Effect.fn("chronolizer.resolve")(function* (range) {
53
45
  if (range.lower !== void 0 && range.upper !== void 0) {
54
46
  const lower = yield* resolveLower(range.lower, reference, zone);
55
47
  const upper = yield* resolveUpper(range.upper, reference, zone);
56
- 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" });
57
49
  return ResolvedDateRange.make({
58
50
  lower,
59
51
  upper