chronolizer 0.2.0 → 0.3.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.
@@ -1,9 +1,9 @@
1
- import { InstantExpr, Unit, daysInMonth, isIsoDate } from "../ast/schemas.mjs";
1
+ import { GreaterThanOrEqual, InstantExpr, LessThan, LessThanOrEqual, Now, Shift, StartOf, Unit, daysInMonth, isIsoDate } from "../ast/schemas.mjs";
2
2
  import { boundedRange, dateLiteral, greaterThanOrEqual, lessThan, lessThanOrEqual, lowerOpenRange, now, shift, startOf, upperOpenRange } from "../ast/constructors.mjs";
3
3
  import { formatInstantExpression } from "../filter/expression.mjs";
4
4
  import { formatFilter, rangeKey } from "../filter/codec.mjs";
5
5
  import { NaturalCandidate } from "../language/model.mjs";
6
- import { Option, Schema, String as String$1 } from "effect";
6
+ import { Match, Option, RegExp as RegExp$1, Schema, String as String$1 } from "effect";
7
7
  //#region src/locales/shared.ts
8
8
  const textAt = (values, index) => values[index] ?? "";
9
9
  const Period = Schema.Struct({
@@ -11,6 +11,12 @@ const Period = Schema.Struct({
11
11
  end: InstantExpr,
12
12
  canonical: Schema.String
13
13
  });
14
+ const isGreaterThanOrEqual = Schema.is(GreaterThanOrEqual);
15
+ const isLessThan = Schema.is(LessThan);
16
+ const isLessThanOrEqual = Schema.is(LessThanOrEqual);
17
+ const isNow = Schema.is(Now);
18
+ const isShift = Schema.is(Shift);
19
+ const isStartOf = Schema.is(StartOf);
14
20
  const periodRange = (period) => boundedRange(greaterThanOrEqual(period.start), lessThan(period.end));
15
21
  const periodToDateRange = (unit) => boundedRange(greaterThanOrEqual(startOf(now(), unit)), lessThanOrEqual(now()));
16
22
  const fromNowRange = () => lowerOpenRange(greaterThanOrEqual(now()));
@@ -29,6 +35,20 @@ const periodEndDay = (period, canonical) => Period.make({
29
35
  end: period.end,
30
36
  canonical
31
37
  });
38
+ const periodDay = (period, day, canonical) => {
39
+ if (!Number.isInteger(day) || day < 1 || day > 28) return Option.none();
40
+ const start = day === 1 ? period.start : shift(period.start, day - 1, "day");
41
+ return Option.some(Period.make({
42
+ start,
43
+ end: shift(start, 1, "day"),
44
+ canonical
45
+ }));
46
+ };
47
+ const periodPreviousDay = (period, canonical) => Period.make({
48
+ start: shift(period.start, -1, "day"),
49
+ end: period.start,
50
+ canonical
51
+ });
32
52
  const relativeWeekend = (direction, canonical) => {
33
53
  const weekBase = direction === 0 ? now() : shift(now(), direction, "week");
34
54
  const weekStart = startOf(weekBase, "week");
@@ -43,11 +63,7 @@ const TrailingCount = Schema.Int.check(Schema.isBetween({
43
63
  minimum: 1,
44
64
  maximum: Number.MAX_SAFE_INTEGER
45
65
  }));
46
- const TrailingPeriod = Schema.Struct({
47
- amount: TrailingCount,
48
- unit: Unit
49
- });
50
- const FuturePeriod = Schema.Struct({
66
+ const CountedPeriod = Schema.Struct({
51
67
  amount: TrailingCount,
52
68
  unit: Unit
53
69
  });
@@ -66,15 +82,17 @@ const parseTrailingCount = (value) => {
66
82
  const trailingRange = (amount, unit) => boundedRange(greaterThanOrEqual(shift(now(), -amount, unit)), lessThanOrEqual(now()));
67
83
  const futureRange = (amount, unit) => boundedRange(greaterThanOrEqual(now()), lessThanOrEqual(shift(now(), amount, unit)));
68
84
  const trailingPeriod = (range) => {
69
- if (range.lower?._tag !== "GreaterThanOrEqual" || range.upper?._tag !== "LessThanOrEqual" || range.lower.value._tag !== "Shift" || range.lower.value.amount >= 0 || range.lower.value.base._tag !== "Now" || range.upper.value._tag !== "Now") return Option.none();
70
- return Option.some(TrailingPeriod.make({
85
+ if (!isGreaterThanOrEqual(range.lower) || !isLessThanOrEqual(range.upper)) return Option.none();
86
+ if (!isShift(range.lower.value) || range.lower.value.amount >= 0 || !isNow(range.lower.value.base) || !isNow(range.upper.value)) return Option.none();
87
+ return Option.some(CountedPeriod.make({
71
88
  amount: -range.lower.value.amount,
72
89
  unit: range.lower.value.unit
73
90
  }));
74
91
  };
75
92
  const futurePeriod = (range) => {
76
- if (range.lower?._tag !== "GreaterThanOrEqual" || range.upper?._tag !== "LessThanOrEqual" || range.lower.value._tag !== "Now" || range.upper.value._tag !== "Shift" || range.upper.value.amount <= 0 || range.upper.value.base._tag !== "Now") return Option.none();
77
- return Option.some(FuturePeriod.make({
93
+ if (!isGreaterThanOrEqual(range.lower) || !isLessThanOrEqual(range.upper)) return Option.none();
94
+ if (!isNow(range.lower.value) || !isShift(range.upper.value) || range.upper.value.amount <= 0 || !isNow(range.upper.value.base)) return Option.none();
95
+ return Option.some(CountedPeriod.make({
78
96
  amount: range.upper.value.amount,
79
97
  unit: range.upper.value.unit
80
98
  }));
@@ -100,6 +118,50 @@ const previousDay = (year, month, day) => {
100
118
  if (month > 1) return isoDate(year, month - 1, daysInMonth(year, month - 1));
101
119
  return isoDate(year - 1, 12, 31);
102
120
  };
121
+ const absoluteDateProfiles = /* @__PURE__ */ new Map();
122
+ const absoluteDateProfile = (locale) => {
123
+ const cached = absoluteDateProfiles.get(locale);
124
+ if (cached !== void 0) return cached;
125
+ const formatter = new Intl.DateTimeFormat(locale, {
126
+ day: "numeric",
127
+ month: "numeric",
128
+ year: "numeric",
129
+ numberingSystem: "latn",
130
+ timeZone: "UTC"
131
+ });
132
+ const parts = [];
133
+ const pattern = formatter.formatToParts(/* @__PURE__ */ new Date("2006-11-22T00:00:00.000Z")).map((part) => {
134
+ if (part.type === "day" || part.type === "month" || part.type === "year") {
135
+ parts.push(part.type);
136
+ return part.type === "year" ? "([0-9]{4})" : "([0-9]{1,2})";
137
+ }
138
+ return RegExp$1.escape(part.value);
139
+ }).join("");
140
+ const profile = {
141
+ formatter,
142
+ pattern: new RegExp(`^${pattern}$`, "u"),
143
+ parts
144
+ };
145
+ absoluteDateProfiles.set(locale, profile);
146
+ return profile;
147
+ };
148
+ const formatAbsoluteDate = (value, locale) => absoluteDateProfile(locale).formatter.format(/* @__PURE__ */ new Date(`${value}T00:00:00.000Z`));
149
+ const parseLocalizedAbsoluteDate = (input, locale) => {
150
+ const profile = absoluteDateProfile(locale);
151
+ const match = String$1.match(profile.pattern)(input);
152
+ if (Option.isNone(match)) return Option.none();
153
+ let day = "";
154
+ let month = "";
155
+ let year = "";
156
+ for (const [index, part] of profile.parts.entries()) {
157
+ const value = textAt(match.value, index + 1);
158
+ if (part === "day") day = value;
159
+ if (part === "month") month = value;
160
+ if (part === "year") year = value;
161
+ }
162
+ const value = isoDate(Number(year), Number(month), Number(day));
163
+ return isIsoDate(value) ? Option.some(value) : Option.none();
164
+ };
103
165
  const fixedDatePeriod = (value, canonical) => {
104
166
  const year = Number(value.slice(0, 4));
105
167
  const month = Number(value.slice(5, 7));
@@ -110,6 +172,9 @@ const fixedDatePeriod = (value, canonical) => {
110
172
  canonical
111
173
  });
112
174
  };
175
+ const absoluteDatePeriod = (input, locale) => {
176
+ return (isIsoDate(input) ? Option.some(input) : parseLocalizedAbsoluteDate(input, locale)).pipe(Option.filter((date) => date !== "9999-12-31"), Option.map((date) => fixedDatePeriod(date, formatAbsoluteDate(date, locale))));
177
+ };
113
178
  const namedDatePeriod = (yearText, monthText, dayText, monthNumber, canonical) => {
114
179
  const year = validYear(yearText);
115
180
  const month = monthNumber(monthText);
@@ -119,6 +184,51 @@ const namedDatePeriod = (yearText, monthText, dayText, monthNumber, canonical) =
119
184
  if (!isIsoDate(value) || value === "9999-12-31") return Option.none();
120
185
  return Option.some(fixedDatePeriod(value, canonical(day, month, year)));
121
186
  };
187
+ const minimumDaysInMonth = (month) => daysInMonth(1, month);
188
+ const currentYearDatePeriod = (month, day, canonical) => {
189
+ const yearStart = startOf(now(), "year");
190
+ const monthStart = month === 1 ? yearStart : shift(yearStart, month - 1, "month");
191
+ const start = day === 1 ? monthStart : shift(monthStart, day - 1, "day");
192
+ return Period.make({
193
+ start,
194
+ end: shift(start, 1, "day"),
195
+ canonical
196
+ });
197
+ };
198
+ const namedCurrentYearDatePeriod = (monthText, dayText, monthNumber, canonical) => {
199
+ const month = monthNumber(monthText);
200
+ const day = Number(dayText);
201
+ if (month === void 0 || !Number.isInteger(day) || day < 1 || day > minimumDaysInMonth(month)) return Option.none();
202
+ return Option.some(currentYearDatePeriod(month, day, canonical(day, month)));
203
+ };
204
+ const currentYearDate = (expression) => {
205
+ const match = String$1.match(/^now\/y(?:\+([1-9]\d*)M)?(?:\+([1-9]\d*)d)?$/u)(expression);
206
+ if (Option.isNone(match)) return Option.none();
207
+ const month = Number(textAt(match.value, 1) || "0") + 1;
208
+ const offset = Number(textAt(match.value, 2) || "0");
209
+ return month >= 1 && month <= 12 ? Option.some([month, offset]) : Option.none();
210
+ };
211
+ const currentYearDatePeriods = (range, canonical) => {
212
+ const filter = formatFilter(range);
213
+ const expressions = [
214
+ filter.gt,
215
+ filter.gte,
216
+ filter.lt,
217
+ filter.lte
218
+ ];
219
+ const dates = /* @__PURE__ */ new Map();
220
+ for (const expression of expressions) {
221
+ if (expression === void 0) continue;
222
+ const date = currentYearDate(expression);
223
+ if (Option.isNone(date)) continue;
224
+ const [month, offset] = date.value;
225
+ for (const day of [offset, offset + 1]) {
226
+ if (day < 1 || day > minimumDaysInMonth(month)) continue;
227
+ dates.set(`${month}-${day}`, [month, day]);
228
+ }
229
+ }
230
+ return [...dates.values()].map(([month, day]) => currentYearDatePeriod(month, day, canonical(day, month)));
231
+ };
122
232
  const fixedMonthPeriod = (year, month, canonical) => {
123
233
  const nextYear = month === 12 ? year + 1 : year;
124
234
  const nextMonth = month === 12 ? 1 : month + 1;
@@ -164,10 +274,13 @@ const monthOfRelativeYear = (month, direction, canonical) => {
164
274
  });
165
275
  };
166
276
  const calendarPeriodOffset = (range) => {
167
- if (range.lower?._tag !== "GreaterThanOrEqual" || range.upper?._tag !== "LessThan" || range.lower.value._tag !== "StartOf" || range.lower.value.base._tag !== "Shift" || range.lower.value.base.amount === 0 || range.lower.value.base.base._tag !== "Now" || range.lower.value.unit !== range.lower.value.base.unit || range.upper.value._tag !== "Shift" || range.upper.value.amount !== 1 || range.upper.value.unit !== range.lower.value.unit || formatInstantExpression(range.upper.value.base) !== formatInstantExpression(range.lower.value)) return Option.none();
277
+ if (!isGreaterThanOrEqual(range.lower) || !isLessThan(range.upper)) return Option.none();
278
+ const start = range.lower.value;
279
+ const end = range.upper.value;
280
+ if (!isStartOf(start) || !isShift(start.base) || start.base.amount === 0 || !isNow(start.base.base) || start.unit !== start.base.unit || !isShift(end) || end.amount !== 1 || end.unit !== start.unit || formatInstantExpression(end.base) !== formatInstantExpression(start)) return Option.none();
168
281
  return Option.some(CalendarPeriodOffset.make({
169
- amount: range.lower.value.base.amount,
170
- unit: range.lower.value.unit
282
+ amount: start.base.amount,
283
+ unit: start.unit
171
284
  }));
172
285
  };
173
286
  const candidate = (range, canonical) => NaturalCandidate.make({
@@ -201,14 +314,7 @@ const joinedPeriodCandidate = (input, joins, parsePeriod, canonical) => {
201
314
  }
202
315
  return Option.none();
203
316
  };
204
- const periodBoundaryCandidate = (period, boundary, canonical) => {
205
- switch (boundary) {
206
- case "since": return candidate(lowerOpenRange(greaterThanOrEqual(period.start)), canonical);
207
- case "before": return candidate(upperOpenRange(lessThan(period.start)), canonical);
208
- case "through": return candidate(upperOpenRange(lessThan(period.end)), canonical);
209
- case "after": return candidate(lowerOpenRange(greaterThanOrEqual(period.end)), canonical);
210
- }
211
- };
317
+ const periodBoundaryCandidate = (period, boundary, canonical) => Match.value(boundary).pipe(Match.when("since", () => candidate(lowerOpenRange(greaterThanOrEqual(period.start)), canonical)), Match.when("before", () => candidate(upperOpenRange(lessThan(period.start)), canonical)), Match.when("through", () => candidate(upperOpenRange(lessThan(period.end)), canonical)), Match.when("after", () => candidate(lowerOpenRange(greaterThanOrEqual(period.end)), canonical)), Match.exhaustive);
212
318
  const openBoundaryCandidate = (input, boundaries, parsePeriod) => {
213
319
  for (const boundary of boundaries) {
214
320
  if (!input.startsWith(boundary[0])) continue;
@@ -286,4 +392,4 @@ const renderPeriodRange = (range, toDate, periods, since, before, through, after
286
392
  return Option.none();
287
393
  };
288
394
  //#endregion
289
- export { Period, 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 };
395
+ export { Period, absoluteDatePeriod, calendarPeriodOffset, candidate, currentYearDatePeriods, datedPeriods, datedQuarterPeriods, fixedDatePeriod, fixedMonthPeriod, fixedQuarterPeriod, fixedYearPeriod, formatAbsoluteDate, fromNowRange, futurePeriod, futureRange, isoDate, joinedNowCandidate, joinedPeriodCandidate, monthOfRelativeYear, namedCurrentYearDatePeriod, namedDatePeriod, openBoundaryCandidate, parseTrailingCount, periodBoundaryCandidate, periodDay, periodEndDay, periodPreviousDay, periodRange, periodStartDay, periodToDateRange, periodsFromPhrases, quarterOfRelativeYear, relativePeriod, relativeWeekend, remainingPeriodRange, renderPeriodRange, textAt, trailingPeriod, trailingRange, untilNowRange, validYear };
@@ -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, 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";
8
8
  import { Effect, Option, String as String$1 } from "effect";
9
9
  //#region src/locales/tr.ts
10
10
  const months = [
@@ -298,17 +298,22 @@ const monthNumber = (value) => {
298
298
  return short === -1 ? void 0 : short + 1;
299
299
  };
300
300
  const dateLabel = (day, month, year) => `${day} ${textAt(months, month - 1)} ${year}`;
301
+ const currentDateLabel = (day, month) => `${day} ${textAt(months, month - 1)}`;
301
302
  const parseNamedDate = (input) => {
302
303
  const named = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)(?:,)? (\d{4})$/u)(input);
303
304
  if (Option.isSome(named)) return namedDatePeriod(textAt(named.value, 3), textAt(named.value, 2), textAt(named.value, 1), monthNumber, dateLabel);
304
305
  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();
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
+ }
315
+ const current = String$1.match(/^([0-3]?\d)(?:\.)? ([a-zçğıöşü]+\.?)$/u)(input);
316
+ return Option.isSome(current) ? namedCurrentYearDatePeriod(textAt(current.value, 2), textAt(current.value, 1), monthNumber, currentDateLabel) : Option.none();
312
317
  };
313
318
  const quarterNumber = (value) => {
314
319
  if ((value.startsWith("q") || value.startsWith("ç")) && value.length === 2) return Number(value.slice(1));
@@ -336,7 +341,8 @@ const parseQuarter = (input) => {
336
341
  return quarter === void 0 ? Option.none() : Option.some(quarterOfRelativeYear(quarter, 0, `Ç${quarter}`));
337
342
  };
338
343
  const parseBasePeriod = (input) => {
339
- if (isIsoDate(input) && input !== "9999-12-31") return Option.some(fixedDatePeriod(input, input));
344
+ const absoluteDate = absoluteDatePeriod(input, "tr");
345
+ if (Option.isSome(absoluteDate)) return absoluteDate;
340
346
  const namedDate = parseNamedDate(input);
341
347
  if (Option.isSome(namedDate)) return namedDate;
342
348
  const quarter = parseQuarter(input);
@@ -401,7 +407,46 @@ const rollingFuturePatterns = [countedPattern("^(?:gelecek|önümüzdeki|sonraki
401
407
  const rollingSincePattern = countedPattern("^([1-9]\\d*) (UNIT) boyunca$");
402
408
  const rollingBarePattern = countedPattern("^([1-9]\\d*) (UNIT)$");
403
409
  const firstPatternMatch = (input, patterns) => Option.firstSomeOf(patterns.map((pattern) => String$1.match(pattern)(input)));
410
+ const singularRollingCanonical = (entry, future) => future ? `önümüzdeki bir ${entry.singular}` : `son bir ${entry.singular}`;
411
+ const singularRollingPhrases = units.flatMap((entry) => [
412
+ {
413
+ phrase: `son bir ${entry.singular}`,
414
+ entry,
415
+ future: false
416
+ },
417
+ {
418
+ phrase: `bir ${entry.singular} boyunca`,
419
+ entry,
420
+ future: false
421
+ },
422
+ {
423
+ phrase: `geçtiğimiz bir ${entry.singular}`,
424
+ entry,
425
+ future: false
426
+ },
427
+ {
428
+ phrase: `önümüzdeki bir ${entry.singular}`,
429
+ entry,
430
+ future: true
431
+ },
432
+ {
433
+ phrase: `bugünden itibaren bir ${entry.singular} boyunca`,
434
+ entry,
435
+ future: true
436
+ }
437
+ ]);
438
+ const singularCalendarOffsets = units.flatMap((entry) => [{
439
+ phrase: `bir ${entry.singular} önce`,
440
+ entry,
441
+ direction: -1
442
+ }, {
443
+ phrase: `bir ${entry.singular} sonra`,
444
+ entry,
445
+ direction: 1
446
+ }]);
404
447
  const parseCalendarOffset = (input) => {
448
+ 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));
405
450
  const past = String$1.match(calendarPastPattern)(input);
406
451
  const future = firstPatternMatch(input, calendarFuturePatterns);
407
452
  const match = Option.firstSomeOf([past, future]);
@@ -417,6 +462,11 @@ const parseCalendarOffset = (input) => {
417
462
  return Option.some(candidate(periodRange(relativePeriod(unit, direction, canonical)), canonical));
418
463
  };
419
464
  const parseRollingPeriod = (input) => {
465
+ const singular = singularRollingPhrases.find((entry) => entry.phrase === input);
466
+ if (singular !== void 0) {
467
+ const range = singular.future ? futureRange(1, singular.entry.unit) : trailingRange(1, singular.entry.unit);
468
+ return Option.some(candidate(range, singularRollingCanonical(singular.entry, singular.future)));
469
+ }
420
470
  const since = String$1.match(rollingSincePattern)(input);
421
471
  const past = firstPatternMatch(input, rollingPastPatterns);
422
472
  const bare = String$1.match(rollingBarePattern)(input);
@@ -435,9 +485,22 @@ const parseRollingPeriod = (input) => {
435
485
  if (entry === void 0) return Option.none();
436
486
  const isFuture = Option.isSome(future);
437
487
  const range = isFuture ? futureRange(amount.value, unit) : trailingRange(amount.value, unit);
488
+ if (amount.value === 1) return Option.some(candidate(range, singularRollingCanonical(entry, isFuture)));
438
489
  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}`));
490
+ return Option.some(candidate(range, `${modifier} ${amount.value} ${entry.plural}`));
491
+ };
492
+ const parseElidedDateRange = (input) => {
493
+ 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);
494
+ 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ı`);
495
+ 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);
496
+ const dashed = String$1.match(/^([0-3]?\d)[–—-]([0-3]?\d) ([a-zçğıöşü]+\.?)$/u)(input);
497
+ const match = Option.firstSomeOf([elided, dashed]);
498
+ if (Option.isNone(match)) return Option.none();
499
+ const isElided = Option.isSome(elided);
500
+ const lowerDay = textAt(match.value, 1);
501
+ const upperDay = textAt(match.value, isElided ? 3 : 2);
502
+ const month = textAt(match.value, isElided ? 2 : 3);
503
+ return joinedPeriodCandidate(`${lowerDay} ${month} ile ${upperDay} ${month}`, [["", " ile "]], parsePeriod, (lower, upper) => `${lower} ile ${upper} arası`);
441
504
  };
442
505
  const suffixBoundary = (input) => {
443
506
  const boundaries = [
@@ -486,6 +549,8 @@ const parseTurkish = (input) => {
486
549
  if (Option.isSome(rolling)) return rolling;
487
550
  const toDate = toDatePhrases.find((entry) => entry.phrase === input);
488
551
  if (toDate !== void 0) return Option.some(candidate(periodToDateRange(toDate.entry.unit), toDate.entry.toDate));
552
+ const elided = parseElidedDateRange(input);
553
+ if (Option.isSome(elided)) return elided;
489
554
  const joinedInput = input.endsWith(" arası") ? input.slice(0, -6) : input;
490
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ı`);
491
556
  if (Option.isSome(nowBounded)) return nowBounded;
@@ -499,7 +564,7 @@ const parseTurkish = (input) => {
499
564
  if (Option.isSome(bounded)) return bounded;
500
565
  const boundary = boundaryCandidate(input);
501
566
  if (Option.isSome(boundary)) return boundary;
502
- return Option.map(parsePeriod(input), (period) => candidate(periodRange(period), period.canonical));
567
+ return parsePeriod(input).pipe(Option.map((period) => candidate(periodRange(period), period.canonical)));
503
568
  };
504
569
  const staticPeriodPhrases = [
505
570
  ...periodAliases.map((entry) => entry[0]),
@@ -569,6 +634,8 @@ const countedSuggestions = (input) => {
569
634
  const turkishSuggestionPhrases = [
570
635
  ...units.map((entry) => entry.toDate),
571
636
  ...units.map((entry) => entry.remaining),
637
+ ...singularRollingPhrases.map((entry) => entry.phrase),
638
+ ...singularCalendarOffsets.map((entry) => entry.phrase),
572
639
  ...staticPeriodPhrases,
573
640
  ...prefixNaturalPhrases(staticPeriodPhrases, boundaryPrefixes),
574
641
  "şimdiye kadar",
@@ -596,20 +663,18 @@ const renderTurkish = (range) => {
596
663
  const future = futurePeriod(range);
597
664
  if (Option.isSome(future)) {
598
665
  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
- }
666
+ if (entry !== void 0) return Option.some(future.value.amount === 1 ? singularRollingCanonical(entry, true) : `gelecek ${future.value.amount} ${entry.plural}`);
603
667
  }
604
668
  const trailing = trailingPeriod(range);
605
669
  if (Option.isSome(trailing)) {
606
670
  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
- }
671
+ if (entry !== void 0) return Option.some(trailing.value.amount === 1 ? singularRollingCanonical(entry, false) : `son ${trailing.value.amount} ${entry.plural}`);
611
672
  }
612
- const periods = [...staticPeriods, ...periodsFromPhrases([...datedPeriods(range, months), ...datedQuarterPeriods(range).map((phrase) => phrase.startsWith("Q") ? `Ç${phrase.slice(1)}` : phrase)], parsePeriod)];
673
+ const periods = [
674
+ ...staticPeriods,
675
+ ...currentYearDatePeriods(range, currentDateLabel),
676
+ ...periodsFromPhrases([...datedPeriods(range, months), ...datedQuarterPeriods(range).map((phrase) => phrase.startsWith("Q") ? `Ç${phrase.slice(1)}` : phrase)], parsePeriod)
677
+ ];
613
678
  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
679
  };
615
680
  const normalizeTurkish = (input, locale) => normalizeNaturalText(input, locale).replaceAll("’", "'");
@@ -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
@@ -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 };
@@ -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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chronolizer",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",