chronolizer 0.1.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.
- package/README.md +184 -119
- package/dist/ast/constructors.d.mts +135 -0
- package/dist/ast/constructors.mjs +25 -0
- package/dist/ast/fold.d.mts +13 -0
- package/dist/ast/fold.mjs +58 -0
- package/dist/ast/normalize.d.mts +42 -0
- package/dist/ast/normalize.mjs +34 -0
- package/dist/ast/schemas.d.mts +80 -0
- package/dist/ast/schemas.mjs +65 -0
- package/dist/filter/codec.d.mts +92 -0
- package/dist/filter/codec.mjs +48 -0
- package/dist/filter/errors.d.mts +14 -0
- package/dist/filter/errors.mjs +10 -0
- package/dist/filter/expression.d.mts +8 -0
- package/dist/filter/expression.mjs +76 -0
- package/dist/filter/schema.d.mts +13 -0
- package/dist/filter/schema.mjs +11 -0
- package/dist/filter/transformation.d.mts +37 -0
- package/dist/filter/transformation.mjs +20 -0
- package/dist/index.d.mts +22 -969
- package/dist/index.mjs +22 -2305
- package/dist/language/errors.d.mts +38 -0
- package/dist/language/errors.mjs +30 -0
- package/dist/language/model.d.mts +239 -0
- package/dist/language/model.mjs +50 -0
- package/dist/language/registry.d.mts +17 -0
- package/dist/language/registry.mjs +116 -0
- package/dist/locales/cs.d.mts +10 -0
- package/dist/locales/cs.mjs +746 -0
- package/dist/locales/de.d.mts +10 -0
- package/dist/locales/de.mjs +1039 -0
- package/dist/locales/en.d.mts +10 -0
- package/dist/locales/en.mjs +598 -0
- package/dist/locales/es.d.mts +10 -0
- package/dist/locales/es.mjs +908 -0
- package/dist/locales/fr.d.mts +10 -0
- package/dist/locales/fr.mjs +901 -0
- package/dist/locales/nl.d.mts +10 -0
- package/dist/locales/nl.mjs +791 -0
- package/dist/locales/pl.d.mts +10 -0
- package/dist/locales/pl.mjs +885 -0
- package/dist/locales/shared.mjs +395 -0
- package/dist/locales/tr.d.mts +10 -0
- package/dist/locales/tr.mjs +725 -0
- package/dist/natural/correction.d.mts +13 -0
- package/dist/natural/correction.mjs +73 -0
- package/dist/natural/format.d.mts +47 -0
- package/dist/natural/format.mjs +14 -0
- package/dist/natural/parse.d.mts +99 -0
- package/dist/natural/parse.mjs +66 -0
- package/dist/natural/policy.mjs +6 -0
- package/dist/natural/suggest.d.mts +53 -0
- package/dist/natural/suggest.mjs +24 -0
- package/dist/natural/suggestion.d.mts +4 -0
- package/dist/natural/suggestion.mjs +79 -0
- package/dist/natural/text.d.mts +5 -0
- package/dist/natural/text.mjs +5 -0
- package/dist/resolve/resolve.d.mts +79 -0
- package/dist/resolve/resolve.mjs +58 -0
- package/dist/resolve/schema.d.mts +59 -0
- package/dist/resolve/schema.mjs +28 -0
- package/package.json +15 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/natural/correction.d.ts
|
|
2
|
+
declare const correctWhitespaceSeparatedText: (input: string, vocabulary: ReadonlyArray<string>) => {
|
|
3
|
+
readonly text: string;
|
|
4
|
+
readonly corrections: readonly {
|
|
5
|
+
readonly original: string;
|
|
6
|
+
readonly replacement: string;
|
|
7
|
+
readonly distance: number;
|
|
8
|
+
readonly offset: number;
|
|
9
|
+
}[];
|
|
10
|
+
readonly cost: number;
|
|
11
|
+
}[];
|
|
12
|
+
//#endregion
|
|
13
|
+
export { correctWhitespaceSeparatedText };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Correction, NaturalCorrectionCandidate } from "../language/model.mjs";
|
|
2
|
+
import { naturalWords } from "./text.mjs";
|
|
3
|
+
import { Option, String } from "effect";
|
|
4
|
+
//#region src/natural/correction.ts
|
|
5
|
+
const isProtectedValue = (word) => Option.isSome(String.match(/^\d+$/u)(word)) || Option.isSome(String.match(/^\d{4}-\d{2}-\d{2}$/u)(word));
|
|
6
|
+
const damerauLevenshteinDistance = (left, right) => {
|
|
7
|
+
const fallback = left.length + right.length;
|
|
8
|
+
let previousPrevious;
|
|
9
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
10
|
+
for (let row = 1; row <= left.length; row += 1) {
|
|
11
|
+
const current = [row];
|
|
12
|
+
for (let column = 1; column <= right.length; column += 1) {
|
|
13
|
+
const substitution = left.charAt(row - 1) === right.charAt(column - 1) ? 0 : 1;
|
|
14
|
+
const distance = Math.min((previous[column] ?? fallback) + 1, (current[column - 1] ?? fallback) + 1, (previous[column - 1] ?? fallback) + substitution);
|
|
15
|
+
const transposed = previousPrevious !== void 0 && row > 1 && column > 1 && left.charAt(row - 1) === right.charAt(column - 2) && left.charAt(row - 2) === right.charAt(column - 1) ? (previousPrevious[column - 2] ?? fallback) + 1 : fallback;
|
|
16
|
+
current.push(Math.min(distance, transposed));
|
|
17
|
+
}
|
|
18
|
+
previousPrevious = previous;
|
|
19
|
+
previous = current;
|
|
20
|
+
}
|
|
21
|
+
return previous[right.length] ?? fallback;
|
|
22
|
+
};
|
|
23
|
+
const replacementsFor = (word, vocabulary) => {
|
|
24
|
+
if (vocabulary.includes(word) || isProtectedValue(word)) return [{
|
|
25
|
+
word,
|
|
26
|
+
distance: 0
|
|
27
|
+
}];
|
|
28
|
+
if (word.length <= 3) return [];
|
|
29
|
+
const maximum = word.length >= 6 ? 2 : 1;
|
|
30
|
+
const matches = vocabulary.filter((candidate) => Math.abs(candidate.length - word.length) <= maximum).map((candidate) => ({
|
|
31
|
+
word: candidate,
|
|
32
|
+
distance: damerauLevenshteinDistance(word, candidate)
|
|
33
|
+
})).filter((candidate) => candidate.distance <= maximum);
|
|
34
|
+
if (matches.length === 0) return [];
|
|
35
|
+
const minimum = Math.min(...matches.map((candidate) => candidate.distance));
|
|
36
|
+
return matches.filter((candidate) => candidate.distance === minimum).slice(0, 4);
|
|
37
|
+
};
|
|
38
|
+
const correctWhitespaceSeparatedText = (input, vocabulary) => {
|
|
39
|
+
const words = naturalWords(input);
|
|
40
|
+
let partials = [{
|
|
41
|
+
words: [],
|
|
42
|
+
corrections: [],
|
|
43
|
+
cost: 0,
|
|
44
|
+
offset: 0
|
|
45
|
+
}];
|
|
46
|
+
for (const word of words) {
|
|
47
|
+
const replacements = replacementsFor(word, vocabulary);
|
|
48
|
+
if (replacements.length === 0) return [];
|
|
49
|
+
const next = [];
|
|
50
|
+
for (const partial of partials) for (const replacement of replacements) {
|
|
51
|
+
const correction = replacement.distance === 0 ? partial.corrections : [...partial.corrections, Correction.make({
|
|
52
|
+
original: word,
|
|
53
|
+
replacement: replacement.word,
|
|
54
|
+
distance: replacement.distance,
|
|
55
|
+
offset: partial.offset
|
|
56
|
+
})];
|
|
57
|
+
next.push({
|
|
58
|
+
words: [...partial.words, replacement.word],
|
|
59
|
+
corrections: correction,
|
|
60
|
+
cost: partial.cost + replacement.distance,
|
|
61
|
+
offset: partial.offset + word.length + 1
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
partials = next.slice(0, 32);
|
|
65
|
+
}
|
|
66
|
+
return partials.filter((partial) => partial.corrections.length > 0).map((partial) => NaturalCorrectionCandidate.make({
|
|
67
|
+
text: partial.words.join(" "),
|
|
68
|
+
corrections: partial.corrections,
|
|
69
|
+
cost: partial.cost
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { correctWhitespaceSeparatedText, damerauLevenshteinDistance };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { InstantExpr } from "../ast/schemas.mjs";
|
|
2
|
+
import { NaturalLanguageRenderError, UnsupportedLocaleError } from "../language/errors.mjs";
|
|
3
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
//#region src/natural/format.d.ts
|
|
6
|
+
interface FormatNaturalOptions {
|
|
7
|
+
readonly locale: string;
|
|
8
|
+
}
|
|
9
|
+
declare const formatNatural: (range: {
|
|
10
|
+
readonly _tag: "DateRange";
|
|
11
|
+
readonly lower: {
|
|
12
|
+
readonly _tag: "GreaterThan";
|
|
13
|
+
readonly value: InstantExpr;
|
|
14
|
+
} | {
|
|
15
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
16
|
+
readonly value: InstantExpr;
|
|
17
|
+
};
|
|
18
|
+
readonly upper: {
|
|
19
|
+
readonly _tag: "LessThan";
|
|
20
|
+
readonly value: InstantExpr;
|
|
21
|
+
} | {
|
|
22
|
+
readonly _tag: "LessThanOrEqual";
|
|
23
|
+
readonly value: InstantExpr;
|
|
24
|
+
};
|
|
25
|
+
} | {
|
|
26
|
+
readonly _tag: "DateRange";
|
|
27
|
+
readonly lower: {
|
|
28
|
+
readonly _tag: "GreaterThan";
|
|
29
|
+
readonly value: InstantExpr;
|
|
30
|
+
} | {
|
|
31
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
32
|
+
readonly value: InstantExpr;
|
|
33
|
+
};
|
|
34
|
+
readonly upper?: never;
|
|
35
|
+
} | {
|
|
36
|
+
readonly _tag: "DateRange";
|
|
37
|
+
readonly lower?: never;
|
|
38
|
+
readonly upper: {
|
|
39
|
+
readonly _tag: "LessThan";
|
|
40
|
+
readonly value: InstantExpr;
|
|
41
|
+
} | {
|
|
42
|
+
readonly _tag: "LessThanOrEqual";
|
|
43
|
+
readonly value: InstantExpr;
|
|
44
|
+
};
|
|
45
|
+
}, options: FormatNaturalOptions) => Effect.Effect<string, NaturalLanguageRenderError | UnsupportedLocaleError, LanguageRegistry>;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { FormatNaturalOptions, formatNatural };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NaturalLanguageRenderError } from "../language/errors.mjs";
|
|
2
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
//#region src/natural/format.ts
|
|
5
|
+
const formatNatural = Effect.fn("chronolizer.formatNatural")(function* (range, options) {
|
|
6
|
+
const rendered = (yield* (yield* LanguageRegistry).resolve(options.locale)).render(range);
|
|
7
|
+
if (Option.isSome(rendered)) return rendered.value;
|
|
8
|
+
return yield* new NaturalLanguageRenderError({
|
|
9
|
+
locale: options.locale,
|
|
10
|
+
message: "The range has no canonical natural-language form in this locale"
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
//#endregion
|
|
14
|
+
export { formatNatural };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { InstantExpr } from "../ast/schemas.mjs";
|
|
2
|
+
import { NaturalLanguageParseError, UnsupportedLocaleError } from "../language/errors.mjs";
|
|
3
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
4
|
+
import "../index.mjs";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
//#region src/natural/parse.d.ts
|
|
7
|
+
interface ParseNaturalOptions {
|
|
8
|
+
readonly locale: string;
|
|
9
|
+
readonly typoMode?: "strict" | "tolerant";
|
|
10
|
+
readonly allowFuture?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const parseNatural: (input: string, options: ParseNaturalOptions) => Effect.Effect<{
|
|
13
|
+
readonly range: {
|
|
14
|
+
readonly _tag: "DateRange";
|
|
15
|
+
readonly lower: {
|
|
16
|
+
readonly _tag: "GreaterThan";
|
|
17
|
+
readonly value: InstantExpr;
|
|
18
|
+
} | {
|
|
19
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
20
|
+
readonly value: InstantExpr;
|
|
21
|
+
};
|
|
22
|
+
readonly upper: {
|
|
23
|
+
readonly _tag: "LessThan";
|
|
24
|
+
readonly value: InstantExpr;
|
|
25
|
+
} | {
|
|
26
|
+
readonly _tag: "LessThanOrEqual";
|
|
27
|
+
readonly value: InstantExpr;
|
|
28
|
+
};
|
|
29
|
+
} | {
|
|
30
|
+
readonly _tag: "DateRange";
|
|
31
|
+
readonly lower: {
|
|
32
|
+
readonly _tag: "GreaterThan";
|
|
33
|
+
readonly value: InstantExpr;
|
|
34
|
+
} | {
|
|
35
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
36
|
+
readonly value: InstantExpr;
|
|
37
|
+
};
|
|
38
|
+
readonly upper?: never;
|
|
39
|
+
} | {
|
|
40
|
+
readonly _tag: "DateRange";
|
|
41
|
+
readonly lower?: never;
|
|
42
|
+
readonly upper: {
|
|
43
|
+
readonly _tag: "LessThan";
|
|
44
|
+
readonly value: InstantExpr;
|
|
45
|
+
} | {
|
|
46
|
+
readonly _tag: "LessThanOrEqual";
|
|
47
|
+
readonly value: InstantExpr;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
readonly quality: "ambiguous" | "corrected" | "exact";
|
|
51
|
+
readonly corrections: readonly {
|
|
52
|
+
readonly original: string;
|
|
53
|
+
readonly replacement: string;
|
|
54
|
+
readonly distance: number;
|
|
55
|
+
readonly offset: number;
|
|
56
|
+
}[];
|
|
57
|
+
readonly alternatives: readonly {
|
|
58
|
+
readonly range: {
|
|
59
|
+
readonly _tag: "DateRange";
|
|
60
|
+
readonly lower: {
|
|
61
|
+
readonly _tag: "GreaterThan";
|
|
62
|
+
readonly value: InstantExpr;
|
|
63
|
+
} | {
|
|
64
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
65
|
+
readonly value: InstantExpr;
|
|
66
|
+
};
|
|
67
|
+
readonly upper: {
|
|
68
|
+
readonly _tag: "LessThan";
|
|
69
|
+
readonly value: InstantExpr;
|
|
70
|
+
} | {
|
|
71
|
+
readonly _tag: "LessThanOrEqual";
|
|
72
|
+
readonly value: InstantExpr;
|
|
73
|
+
};
|
|
74
|
+
} | {
|
|
75
|
+
readonly _tag: "DateRange";
|
|
76
|
+
readonly lower: {
|
|
77
|
+
readonly _tag: "GreaterThan";
|
|
78
|
+
readonly value: InstantExpr;
|
|
79
|
+
} | {
|
|
80
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
81
|
+
readonly value: InstantExpr;
|
|
82
|
+
};
|
|
83
|
+
readonly upper?: never;
|
|
84
|
+
} | {
|
|
85
|
+
readonly _tag: "DateRange";
|
|
86
|
+
readonly lower?: never;
|
|
87
|
+
readonly upper: {
|
|
88
|
+
readonly _tag: "LessThan";
|
|
89
|
+
readonly value: InstantExpr;
|
|
90
|
+
} | {
|
|
91
|
+
readonly _tag: "LessThanOrEqual";
|
|
92
|
+
readonly value: InstantExpr;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
readonly canonical: string;
|
|
96
|
+
}[];
|
|
97
|
+
}, NaturalLanguageParseError | UnsupportedLocaleError, LanguageRegistry>;
|
|
98
|
+
//#endregion
|
|
99
|
+
export { ParseNaturalOptions, parseNatural };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { rangeKey } from "../filter/codec.mjs";
|
|
2
|
+
import { NaturalLanguageParseError } from "../language/errors.mjs";
|
|
3
|
+
import { NaturalAlternative, NaturalParseResult } from "../language/model.mjs";
|
|
4
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
5
|
+
import { applyFuturePolicy } from "./policy.mjs";
|
|
6
|
+
import { Array, Effect, Order, Result } from "effect";
|
|
7
|
+
//#region src/natural/parse.ts
|
|
8
|
+
const distinctCandidates = (candidates) => Array.dedupeWith(candidates, (left, right) => rangeKey(left.range) === rangeKey(right.range));
|
|
9
|
+
const parseQuality = (hasAlternatives, hasCorrections) => {
|
|
10
|
+
if (hasAlternatives) return "ambiguous";
|
|
11
|
+
if (hasCorrections) return "corrected";
|
|
12
|
+
return "exact";
|
|
13
|
+
};
|
|
14
|
+
const resultFromCandidates = (candidates, corrections) => {
|
|
15
|
+
const distinct = distinctCandidates(candidates);
|
|
16
|
+
const selected = Array.headNonEmpty(distinct);
|
|
17
|
+
const alternatives = Array.tailNonEmpty(distinct).map((candidate) => NaturalAlternative.make({
|
|
18
|
+
canonical: candidate.canonical,
|
|
19
|
+
range: candidate.range
|
|
20
|
+
}));
|
|
21
|
+
return NaturalParseResult.make({
|
|
22
|
+
range: selected.range,
|
|
23
|
+
quality: parseQuality(alternatives.length > 0, corrections.length > 0),
|
|
24
|
+
corrections,
|
|
25
|
+
alternatives
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const parseNatural = Effect.fn("chronolizer.parseNatural")(function* (input, options) {
|
|
29
|
+
const language = yield* (yield* LanguageRegistry).resolve(options.locale);
|
|
30
|
+
const normalized = language.normalize(input, language.locale);
|
|
31
|
+
if (normalized.length === 0) return yield* new NaturalLanguageParseError({
|
|
32
|
+
input,
|
|
33
|
+
locale: options.locale,
|
|
34
|
+
message: "The complete input must contain a date-range expression"
|
|
35
|
+
});
|
|
36
|
+
const exact = language.parseExact(normalized);
|
|
37
|
+
const allowedExact = applyFuturePolicy(exact, options.allowFuture);
|
|
38
|
+
if (Array.isReadonlyArrayNonEmpty(allowedExact)) return resultFromCandidates(allowedExact, []);
|
|
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";
|
|
41
|
+
return yield* new NaturalLanguageParseError({
|
|
42
|
+
input,
|
|
43
|
+
locale: options.locale,
|
|
44
|
+
message
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const corrected = language.correct?.(normalized, language.vocabulary) ?? [];
|
|
48
|
+
const parsedCorrections = Array.filterMap(corrected, (correction) => {
|
|
49
|
+
const candidates = applyFuturePolicy(language.parseExact(correction.text), options.allowFuture);
|
|
50
|
+
return Array.isReadonlyArrayNonEmpty(candidates) ? Result.succeed({
|
|
51
|
+
correction,
|
|
52
|
+
candidates
|
|
53
|
+
}) : Result.failVoid;
|
|
54
|
+
});
|
|
55
|
+
const successful = Array.sortWith(parsedCorrections, (entry) => entry.correction.cost, Order.Number);
|
|
56
|
+
if (!Array.isReadonlyArrayNonEmpty(successful)) return yield* new NaturalLanguageParseError({
|
|
57
|
+
input,
|
|
58
|
+
locale: options.locale,
|
|
59
|
+
message: "No conservative typo correction produced a complete expression"
|
|
60
|
+
});
|
|
61
|
+
const first = Array.headNonEmpty(successful);
|
|
62
|
+
const best = Array.prepend(Array.takeWhile(Array.tailNonEmpty(successful), (entry) => entry.correction.cost === first.correction.cost), first);
|
|
63
|
+
return resultFromCandidates(Array.flatMap(best, (entry) => entry.candidates), first.correction.corrections);
|
|
64
|
+
});
|
|
65
|
+
//#endregion
|
|
66
|
+
export { parseNatural };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { containsPositiveShift, isCurrentPeriod } from "../ast/fold.mjs";
|
|
2
|
+
import { Array } from "effect";
|
|
3
|
+
//#region src/natural/policy.ts
|
|
4
|
+
const applyFuturePolicy = (candidates, allowFuture) => allowFuture === false ? Array.filter(candidates, (candidate) => isCurrentPeriod(candidate.range) || !containsPositiveShift(candidate.range)) : candidates;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { applyFuturePolicy };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { InstantExpr } from "../ast/schemas.mjs";
|
|
2
|
+
import { UnsupportedLocaleError } from "../language/errors.mjs";
|
|
3
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
4
|
+
import "../index.mjs";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
//#region src/natural/suggest.d.ts
|
|
7
|
+
interface SuggestNaturalOptions {
|
|
8
|
+
readonly locale: string;
|
|
9
|
+
readonly limit?: number;
|
|
10
|
+
readonly allowFuture?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const suggestNatural: (input: string, options: SuggestNaturalOptions) => Effect.Effect<{
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly range: {
|
|
15
|
+
readonly _tag: "DateRange";
|
|
16
|
+
readonly lower: {
|
|
17
|
+
readonly _tag: "GreaterThan";
|
|
18
|
+
readonly value: InstantExpr;
|
|
19
|
+
} | {
|
|
20
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
21
|
+
readonly value: InstantExpr;
|
|
22
|
+
};
|
|
23
|
+
readonly upper: {
|
|
24
|
+
readonly _tag: "LessThan";
|
|
25
|
+
readonly value: InstantExpr;
|
|
26
|
+
} | {
|
|
27
|
+
readonly _tag: "LessThanOrEqual";
|
|
28
|
+
readonly value: InstantExpr;
|
|
29
|
+
};
|
|
30
|
+
} | {
|
|
31
|
+
readonly _tag: "DateRange";
|
|
32
|
+
readonly lower: {
|
|
33
|
+
readonly _tag: "GreaterThan";
|
|
34
|
+
readonly value: InstantExpr;
|
|
35
|
+
} | {
|
|
36
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
37
|
+
readonly value: InstantExpr;
|
|
38
|
+
};
|
|
39
|
+
readonly upper?: never;
|
|
40
|
+
} | {
|
|
41
|
+
readonly _tag: "DateRange";
|
|
42
|
+
readonly lower?: never;
|
|
43
|
+
readonly upper: {
|
|
44
|
+
readonly _tag: "LessThan";
|
|
45
|
+
readonly value: InstantExpr;
|
|
46
|
+
} | {
|
|
47
|
+
readonly _tag: "LessThanOrEqual";
|
|
48
|
+
readonly value: InstantExpr;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}[], UnsupportedLocaleError, LanguageRegistry>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { SuggestNaturalOptions, suggestNatural };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { rangeKey } from "../filter/codec.mjs";
|
|
2
|
+
import { NaturalSuggestion } from "../language/model.mjs";
|
|
3
|
+
import { LanguageRegistry } from "../language/registry.mjs";
|
|
4
|
+
import { applyFuturePolicy } from "./policy.mjs";
|
|
5
|
+
import { Array, Effect } from "effect";
|
|
6
|
+
//#region src/natural/suggest.ts
|
|
7
|
+
const suggestionLimit = (limit) => {
|
|
8
|
+
if (limit === void 0) return 10;
|
|
9
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) return 0;
|
|
10
|
+
return Math.min(limit, 100);
|
|
11
|
+
};
|
|
12
|
+
const suggestNatural = Effect.fn("chronolizer.suggestNatural")(function* (input, options) {
|
|
13
|
+
const language = yield* (yield* LanguageRegistry).resolve(options.locale);
|
|
14
|
+
const normalized = language.normalize(input, language.locale);
|
|
15
|
+
const limit = suggestionLimit(options.limit);
|
|
16
|
+
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({
|
|
18
|
+
text: candidate.canonical,
|
|
19
|
+
range: candidate.range
|
|
20
|
+
})));
|
|
21
|
+
return Array.take(Array.dedupeWith(suggestions, (left, right) => rangeKey(left.range) === rangeKey(right.range)), limit);
|
|
22
|
+
});
|
|
23
|
+
//#endregion
|
|
24
|
+
export { suggestNatural };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { damerauLevenshteinDistance } from "./correction.mjs";
|
|
2
|
+
import { Array as Array$1, Option, String } from "effect";
|
|
3
|
+
//#region src/natural/suggestion.ts
|
|
4
|
+
const fuzzyPrefixDistance = (input, target) => {
|
|
5
|
+
const lengths = Array$1.dedupe([
|
|
6
|
+
Math.max(1, input.length - 1),
|
|
7
|
+
input.length,
|
|
8
|
+
Math.min(target.length, input.length + 1)
|
|
9
|
+
]);
|
|
10
|
+
return Math.min(...lengths.map((length) => damerauLevenshteinDistance(input, target.slice(0, length))));
|
|
11
|
+
};
|
|
12
|
+
const completionCost = (input, target) => {
|
|
13
|
+
if (input === target || target.startsWith(input)) return 0;
|
|
14
|
+
if (input.length < 3 || input[0] !== target[0]) return void 0;
|
|
15
|
+
const maximum = input.length >= 5 ? 2 : 1;
|
|
16
|
+
const distance = fuzzyPrefixDistance(input, target);
|
|
17
|
+
return distance <= maximum ? distance : void 0;
|
|
18
|
+
};
|
|
19
|
+
const phraseScore = (input, phrase) => {
|
|
20
|
+
if (input === phrase) return 0;
|
|
21
|
+
if (phrase.startsWith(input)) return 1;
|
|
22
|
+
const inputWords = input.split(" ");
|
|
23
|
+
const phraseWords = phrase.split(" ");
|
|
24
|
+
if (inputWords.length > phraseWords.length) return void 0;
|
|
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);
|
|
30
|
+
if (wordCost === void 0) return void 0;
|
|
31
|
+
cost += wordCost;
|
|
32
|
+
}
|
|
33
|
+
return cost === 0 ? 2 : 2 + cost;
|
|
34
|
+
};
|
|
35
|
+
const completeYearPrefix = (input) => {
|
|
36
|
+
const match = String.match(/(?:^| )(\d{3,4})$/u)(input);
|
|
37
|
+
if (Option.isNone(match)) return [];
|
|
38
|
+
const prefix = match.value[1] ?? "";
|
|
39
|
+
if (prefix.length === 4) {
|
|
40
|
+
const year = Number(prefix);
|
|
41
|
+
return year >= 1 && year <= 9998 ? [prefix] : [];
|
|
42
|
+
}
|
|
43
|
+
return Array.from({ length: 10 }, (_, digit) => `${prefix}${digit}`).filter((year) => {
|
|
44
|
+
const value = Number(year);
|
|
45
|
+
return value >= 1 && value <= 9998;
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const fixedCalendarPeriodPhrases = (input, months) => completeYearPrefix(input).flatMap((year) => [
|
|
49
|
+
year,
|
|
50
|
+
...months.map((month) => `${month} ${year}`),
|
|
51
|
+
...[
|
|
52
|
+
1,
|
|
53
|
+
2,
|
|
54
|
+
3,
|
|
55
|
+
4
|
|
56
|
+
].map((quarter) => `q${quarter} ${year}`)
|
|
57
|
+
]);
|
|
58
|
+
const prefixNaturalPhrases = (phrases, prefixes) => phrases.flatMap((phrase) => prefixes.map((prefix) => `${prefix}${phrase}`));
|
|
59
|
+
const naturalCount = (input) => {
|
|
60
|
+
const match = String.match(/(?:^| )([1-9]\d*)(?: |$)/u)(input);
|
|
61
|
+
if (Option.isNone(match)) return void 0;
|
|
62
|
+
const value = Number(match.value[1]);
|
|
63
|
+
return Number.isSafeInteger(value) ? value : void 0;
|
|
64
|
+
};
|
|
65
|
+
const completeNaturalPhrases = (input, phrases, limit) => {
|
|
66
|
+
const ranked = [];
|
|
67
|
+
for (const [index, phrase] of Array$1.dedupe(phrases).entries()) {
|
|
68
|
+
const score = phraseScore(input, phrase);
|
|
69
|
+
if (score !== void 0) ranked.push({
|
|
70
|
+
phrase,
|
|
71
|
+
score,
|
|
72
|
+
index
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const sorted = ranked.sort((left, right) => left.score - right.score || left.index - right.index);
|
|
76
|
+
return (sorted.some((entry) => entry.score <= 1) ? sorted.filter((entry) => entry.score <= 1) : sorted).slice(0, limit).map((entry) => entry.phrase);
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
export { completeNaturalPhrases, fixedCalendarPeriodPhrases, naturalCount, prefixNaturalPhrases };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/natural/text.ts
|
|
2
|
+
const normalizeNaturalText = (input, locale) => input.normalize("NFKC").toLocaleLowerCase(locale).trim().replace(/\s+/gu, " ");
|
|
3
|
+
const naturalWords = (input) => input.length === 0 ? [] : input.split(" ");
|
|
4
|
+
//#endregion
|
|
5
|
+
export { naturalWords, normalizeNaturalText };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { InstantExpr } from "../ast/schemas.mjs";
|
|
2
|
+
import { ResolutionError } from "./schema.mjs";
|
|
3
|
+
import { DateTime, Effect } from "effect";
|
|
4
|
+
//#region src/resolve/resolve.d.ts
|
|
5
|
+
declare const resolve: (range: {
|
|
6
|
+
readonly _tag: "DateRange";
|
|
7
|
+
readonly lower: {
|
|
8
|
+
readonly _tag: "GreaterThan";
|
|
9
|
+
readonly value: InstantExpr;
|
|
10
|
+
} | {
|
|
11
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
12
|
+
readonly value: InstantExpr;
|
|
13
|
+
};
|
|
14
|
+
readonly upper: {
|
|
15
|
+
readonly _tag: "LessThan";
|
|
16
|
+
readonly value: InstantExpr;
|
|
17
|
+
} | {
|
|
18
|
+
readonly _tag: "LessThanOrEqual";
|
|
19
|
+
readonly value: InstantExpr;
|
|
20
|
+
};
|
|
21
|
+
} | {
|
|
22
|
+
readonly _tag: "DateRange";
|
|
23
|
+
readonly lower: {
|
|
24
|
+
readonly _tag: "GreaterThan";
|
|
25
|
+
readonly value: InstantExpr;
|
|
26
|
+
} | {
|
|
27
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
28
|
+
readonly value: InstantExpr;
|
|
29
|
+
};
|
|
30
|
+
readonly upper?: never;
|
|
31
|
+
} | {
|
|
32
|
+
readonly _tag: "DateRange";
|
|
33
|
+
readonly lower?: never;
|
|
34
|
+
readonly upper: {
|
|
35
|
+
readonly _tag: "LessThan";
|
|
36
|
+
readonly value: InstantExpr;
|
|
37
|
+
} | {
|
|
38
|
+
readonly _tag: "LessThanOrEqual";
|
|
39
|
+
readonly value: InstantExpr;
|
|
40
|
+
};
|
|
41
|
+
}) => Effect.Effect<{
|
|
42
|
+
readonly _tag: "ResolvedDateRange";
|
|
43
|
+
readonly lower: {
|
|
44
|
+
readonly _tag: "GreaterThan";
|
|
45
|
+
readonly value: DateTime.Zoned;
|
|
46
|
+
} | {
|
|
47
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
48
|
+
readonly value: DateTime.Zoned;
|
|
49
|
+
};
|
|
50
|
+
readonly upper: {
|
|
51
|
+
readonly _tag: "LessThan";
|
|
52
|
+
readonly value: DateTime.Zoned;
|
|
53
|
+
} | {
|
|
54
|
+
readonly _tag: "LessThanOrEqual";
|
|
55
|
+
readonly value: DateTime.Zoned;
|
|
56
|
+
};
|
|
57
|
+
} | {
|
|
58
|
+
readonly _tag: "ResolvedDateRange";
|
|
59
|
+
readonly lower: {
|
|
60
|
+
readonly _tag: "GreaterThan";
|
|
61
|
+
readonly value: DateTime.Zoned;
|
|
62
|
+
} | {
|
|
63
|
+
readonly _tag: "GreaterThanOrEqual";
|
|
64
|
+
readonly value: DateTime.Zoned;
|
|
65
|
+
};
|
|
66
|
+
readonly upper?: never;
|
|
67
|
+
} | {
|
|
68
|
+
readonly _tag: "ResolvedDateRange";
|
|
69
|
+
readonly lower?: never;
|
|
70
|
+
readonly upper: {
|
|
71
|
+
readonly _tag: "LessThan";
|
|
72
|
+
readonly value: DateTime.Zoned;
|
|
73
|
+
} | {
|
|
74
|
+
readonly _tag: "LessThanOrEqual";
|
|
75
|
+
readonly value: DateTime.Zoned;
|
|
76
|
+
};
|
|
77
|
+
}, ResolutionError, DateTime.CurrentTimeZone>;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { resolve };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { foldInstant } from "../ast/fold.mjs";
|
|
2
|
+
import { ResolutionError, ResolvedDateRange, ResolvedGreaterThan, ResolvedGreaterThanOrEqual, ResolvedLessThan, ResolvedLessThanOrEqual } from "./schema.mjs";
|
|
3
|
+
import { DateTime, Effect, Match, Option } from "effect";
|
|
4
|
+
//#region src/resolve/resolve.ts
|
|
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);
|
|
6
|
+
const startOfDateTime = (value, unit) => {
|
|
7
|
+
if (unit === "quarter") {
|
|
8
|
+
const month = DateTime.getPart(value, "month");
|
|
9
|
+
const quarterMonth = Math.floor((month - 1) / 3) * 3 + 1;
|
|
10
|
+
return DateTime.startOf(DateTime.setParts(value, { month: quarterMonth }), "month");
|
|
11
|
+
}
|
|
12
|
+
return DateTime.startOf(value, unit, { weekStartsOn: 1 });
|
|
13
|
+
};
|
|
14
|
+
const literalInZone = (value, zone) => {
|
|
15
|
+
const zoned = DateTime.makeZoned({
|
|
16
|
+
year: Number(value.slice(0, 4)),
|
|
17
|
+
month: Number(value.slice(5, 7)),
|
|
18
|
+
day: Number(value.slice(8, 10))
|
|
19
|
+
}, {
|
|
20
|
+
timeZone: zone,
|
|
21
|
+
adjustForTimeZone: true
|
|
22
|
+
});
|
|
23
|
+
return Option.match(zoned, {
|
|
24
|
+
onNone: () => Effect.fail(new ResolutionError({ message: `Cannot resolve the date ${value}` })),
|
|
25
|
+
onSome: Effect.succeed
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const evaluateInstant = (expression, reference, zone) => foldInstant(expression, {
|
|
29
|
+
now: () => Effect.succeed(reference),
|
|
30
|
+
dateLiteral: (value) => literalInZone(value, zone),
|
|
31
|
+
shift: (base, amount, unit) => Effect.map(base, (value) => shiftDateTime(value, amount, unit)),
|
|
32
|
+
startOf: (base, unit) => Effect.map(base, (value) => startOfDateTime(value, unit))
|
|
33
|
+
});
|
|
34
|
+
const resolveLower = (bound, reference, zone) => Match.valueTags(bound, {
|
|
35
|
+
GreaterThan: (value) => Effect.map(evaluateInstant(value.value, reference, zone), (resolved) => ResolvedGreaterThan.make({ value: resolved })),
|
|
36
|
+
GreaterThanOrEqual: (value) => Effect.map(evaluateInstant(value.value, reference, zone), (resolved) => ResolvedGreaterThanOrEqual.make({ value: resolved }))
|
|
37
|
+
});
|
|
38
|
+
const resolveUpper = (bound, reference, zone) => Match.valueTags(bound, {
|
|
39
|
+
LessThan: (value) => Effect.map(evaluateInstant(value.value, reference, zone), (resolved) => ResolvedLessThan.make({ value: resolved })),
|
|
40
|
+
LessThanOrEqual: (value) => Effect.map(evaluateInstant(value.value, reference, zone), (resolved) => ResolvedLessThanOrEqual.make({ value: resolved }))
|
|
41
|
+
});
|
|
42
|
+
const resolve = Effect.fn("chronolizer.resolve")(function* (range) {
|
|
43
|
+
const zone = yield* DateTime.CurrentTimeZone;
|
|
44
|
+
const reference = yield* DateTime.nowInCurrentZone;
|
|
45
|
+
if (range.lower !== void 0 && range.upper !== void 0) {
|
|
46
|
+
const lower = yield* resolveLower(range.lower, reference, zone);
|
|
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" });
|
|
49
|
+
return ResolvedDateRange.make({
|
|
50
|
+
lower,
|
|
51
|
+
upper
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (range.lower !== void 0) return ResolvedDateRange.make({ lower: yield* resolveLower(range.lower, reference, zone) });
|
|
55
|
+
return ResolvedDateRange.make({ upper: yield* resolveUpper(range.upper, reference, zone) });
|
|
56
|
+
});
|
|
57
|
+
//#endregion
|
|
58
|
+
export { resolve };
|