@fhss-web-team/fuzzy-dates 1.0.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/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/fuzzyDate/collate/collate.d.ts +2 -0
- package/dist/fuzzyDate/collate/collate.js +15 -0
- package/dist/fuzzyDate/fuzzyDate.d.ts +206 -0
- package/dist/fuzzyDate/fuzzyDate.js +220 -0
- package/dist/fuzzyDate/fuzzyDate.spec.d.ts +1 -0
- package/dist/fuzzyDate/fuzzyDate.spec.js +158 -0
- package/dist/fuzzyDate/gedcomX/toGedcomX.d.ts +2 -0
- package/dist/fuzzyDate/gedcomX/toGedcomX.js +31 -0
- package/dist/fuzzyDate/normalize/normalize.d.ts +2 -0
- package/dist/fuzzyDate/normalize/normalize.js +47 -0
- package/dist/fuzzyDate/parse/index.d.ts +187 -0
- package/dist/fuzzyDate/parse/index.js +91 -0
- package/dist/fuzzyDate/parse/inputDateFormats.d.ts +204 -0
- package/dist/fuzzyDate/parse/inputDateFormats.js +225 -0
- package/dist/fuzzyDate/parse/modifiers.d.ts +240 -0
- package/dist/fuzzyDate/parse/modifiers.js +193 -0
- package/dist/fuzzyDate/parse/stringToDate.d.ts +38 -0
- package/dist/fuzzyDate/parse/stringToDate.js +24 -0
- package/dist/fuzzyDate/types.d.ts +79 -0
- package/dist/fuzzyDate/types.js +88 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FHSS Web Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# CFHG Fuzzy Dates
|
|
2
|
+
|
|
3
|
+
Parse imprecise, human-entered date strings into a structured, consistent model that you can normalize, search, sort, and serialize.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Handles modifiers like `before`, `after`, `about`, `between`, `from`, `early`, `mid`, `late`.
|
|
7
|
+
- Normalizes many input shapes (`1st of February 1900`, `Feb 1 1900`, `winter 1890`, `1800s`).
|
|
8
|
+
- Produces inclusive lower/upper bounds for range filtering and a collation key for stable chronological sorting.
|
|
9
|
+
- Outputs a canonical JSON model plus GEDCOM X formal dates for genealogy interoperability.
|
|
10
|
+
- ESM + TypeScript ready (`.d.ts` shipped with the package).
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
```bash
|
|
14
|
+
npm install cfhg-fuzzy-dates
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
```ts
|
|
19
|
+
import { FuzzyDate } from 'cfhg-fuzzy-dates';
|
|
20
|
+
|
|
21
|
+
const parsed = FuzzyDate.parse('about Feb 1900');
|
|
22
|
+
if (!parsed.ok) throw parsed.error;
|
|
23
|
+
|
|
24
|
+
const date = parsed.value;
|
|
25
|
+
console.log(date.normalized); // "about 1 February 1900"
|
|
26
|
+
console.log(date.lowerBound); // 1899-02-01T00:00:00.000Z (Date)
|
|
27
|
+
console.log(date.upperBound); // 1901-01-31T23:59:59.999Z (Date)
|
|
28
|
+
console.log(date.collationKey); // deterministic string for sorting
|
|
29
|
+
|
|
30
|
+
// Serialize for storage and hydrate later
|
|
31
|
+
const json = date.toJSON();
|
|
32
|
+
const hydrated = FuzzyDate.fromJSON(json);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API snapshot
|
|
36
|
+
- `FuzzyDate.parse(input: string): Result<FuzzyDate, string>` — non-throwing parse; errors come back as `{ ok: false, error }`.
|
|
37
|
+
- `FuzzyDate.fromJSON(model: FuzzyDateModel)` — rebuild from the canonical JSON model.
|
|
38
|
+
- `FuzzyDate` instance properties:
|
|
39
|
+
- `original` — original input string.
|
|
40
|
+
- `normalized` — normalized human-readable string.
|
|
41
|
+
- `lowerBound` / `upperBound` — inclusive Date bounds (or `null` for unbounded).
|
|
42
|
+
- `collationKey` — sortable string aligned to chronological order.
|
|
43
|
+
- `formal` — GEDCOM X formal date representation.
|
|
44
|
+
- `toJSON()` — returns the canonical model.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
- Tests: `npm test` (Vitest)
|
|
48
|
+
- Build: `npm run build` (TypeScript -> `dist/`)
|
|
49
|
+
- Pack locally (publish dry-run): `npm pack` then install the generated `.tgz` in a temp project to verify exports and typings.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
MIT © FHSS Web Team
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FORMAT_ORDER, MODIFIER_ORDER } from '../types';
|
|
2
|
+
export function collate(model) {
|
|
3
|
+
const date1 = model.start.minDate.toISOString();
|
|
4
|
+
const modifier = MODIFIER_ORDER.indexOf(model.modifier)
|
|
5
|
+
.toString()
|
|
6
|
+
.padStart(2, '0');
|
|
7
|
+
const format1 = FORMAT_ORDER.indexOf(model.start.format)
|
|
8
|
+
.toString()
|
|
9
|
+
.padStart(2, '0');
|
|
10
|
+
const date2 = model.end.minDate.toISOString();
|
|
11
|
+
const format2 = FORMAT_ORDER.indexOf(model.end.format)
|
|
12
|
+
.toString()
|
|
13
|
+
.padStart(2, '0');
|
|
14
|
+
return `${date1}|${modifier}|${format1}|${date2}|${format2}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { FuzzyDateModel } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Represents an immutable, parsed fuzzy date suitable for genealogy and
|
|
4
|
+
* historical data contexts.
|
|
5
|
+
*
|
|
6
|
+
* A `FuzzyDate` encapsulates a human-entered date expression
|
|
7
|
+
* (e.g. `"before 1900"`, `"between 1 Jan 1900 and 31 Dec 1901"`,
|
|
8
|
+
* `"March 1890"`) and exposes multiple *derived representations* optimized
|
|
9
|
+
* for different use cases:
|
|
10
|
+
*
|
|
11
|
+
* - A canonical JSON model for storage and transport
|
|
12
|
+
* - Query bounds for database filtering
|
|
13
|
+
* - A collation key for stable chronological ordering
|
|
14
|
+
* - A normalized, human-readable string form
|
|
15
|
+
* - A GEDCOM X formal date representation
|
|
16
|
+
*
|
|
17
|
+
* ---
|
|
18
|
+
*
|
|
19
|
+
* ### Design principles
|
|
20
|
+
*
|
|
21
|
+
* - **Immutable**: Instances cannot be mutated after construction.
|
|
22
|
+
* - **Explicit construction**: Instances must be created via `parse()` or
|
|
23
|
+
* `fromJSON()`. Direct construction via `new` is intentionally disallowed.
|
|
24
|
+
* - **Derived projections**: Query bounds, collation keys, and serializations
|
|
25
|
+
* are all derived from a single internal model to guarantee consistency.
|
|
26
|
+
* - **UTC semantics**: All date calculations and comparisons are performed
|
|
27
|
+
* using UTC to avoid timezone-related ambiguity.
|
|
28
|
+
*
|
|
29
|
+
* ---
|
|
30
|
+
*
|
|
31
|
+
* ### Intended usage
|
|
32
|
+
*
|
|
33
|
+
* - Use `parse()` for untrusted, human-entered input.
|
|
34
|
+
* - Use `fromJSON()` when hydrating from a trusted serialized model
|
|
35
|
+
* (e.g. database JSON).
|
|
36
|
+
* - Store the JSON model as the canonical representation.
|
|
37
|
+
* - Store `lowerBound`, `upperBound`, and `collationKey` separately for
|
|
38
|
+
* efficient querying and ordering.
|
|
39
|
+
*/
|
|
40
|
+
export declare class FuzzyDate {
|
|
41
|
+
/**
|
|
42
|
+
* Canonical internal model representing the parsed fuzzy date.
|
|
43
|
+
*
|
|
44
|
+
* This model is considered the single source of truth; all other
|
|
45
|
+
* representations are derived from it.
|
|
46
|
+
*/
|
|
47
|
+
private _model;
|
|
48
|
+
/**
|
|
49
|
+
* Private constructor to enforce factory-based creation.
|
|
50
|
+
*
|
|
51
|
+
* Consumers must use {@link FuzzyDate.parse} or {@link FuzzyDate.fromJSON}.
|
|
52
|
+
*/
|
|
53
|
+
private constructor();
|
|
54
|
+
/**
|
|
55
|
+
* The original human-readable input string used to construct this fuzzy date.
|
|
56
|
+
*
|
|
57
|
+
* This value is preserved verbatim and is not normalized or modified.
|
|
58
|
+
*/
|
|
59
|
+
get original(): string;
|
|
60
|
+
/**
|
|
61
|
+
* GEDCOM X formal date representation of this fuzzy date.
|
|
62
|
+
*
|
|
63
|
+
* This value is suitable for interoperability with genealogy systems
|
|
64
|
+
* that support the GEDCOM X date specification.
|
|
65
|
+
*
|
|
66
|
+
* @see https://github.com/FamilySearch/gedcomx/blob/master/specifications/date-format-specification.md
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* This representation is derived and may evolve as GEDCOM X mapping
|
|
70
|
+
* rules are refined.
|
|
71
|
+
*/
|
|
72
|
+
get formal(): string;
|
|
73
|
+
/**
|
|
74
|
+
* Normalized, human-readable representation of the fuzzy date.
|
|
75
|
+
*
|
|
76
|
+
* This format represents the interpreted meaning of the original input
|
|
77
|
+
* using a standardized vocabulary and ordering.
|
|
78
|
+
*
|
|
79
|
+
* Example outputs:
|
|
80
|
+
* - `"before 1900"`
|
|
81
|
+
* - `"March 1890"`
|
|
82
|
+
* - `"between 1 Jan 1900 and 31 Dec 1901"`
|
|
83
|
+
*/
|
|
84
|
+
get normalized(): string;
|
|
85
|
+
/**
|
|
86
|
+
* The inclusive lower bound of this fuzzy date's possible interval (UTC).
|
|
87
|
+
*
|
|
88
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
89
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
90
|
+
* if **either** its `lowerBound` **or** its `upperBound` falls within the
|
|
91
|
+
* search range.
|
|
92
|
+
*
|
|
93
|
+
* Formally (inclusive):
|
|
94
|
+
*
|
|
95
|
+
* ```text
|
|
96
|
+
* (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* ## Unbounded intervals
|
|
100
|
+
*
|
|
101
|
+
* Open-ended intervals are represented with `null`:
|
|
102
|
+
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
103
|
+
* - `upperBound === null` means unbounded in the future (+∞)
|
|
104
|
+
*
|
|
105
|
+
* When using the predicate above, treat a `null` bound as not satisfying
|
|
106
|
+
* the endpoint check (i.e., it is not “within” any finite window).
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* - Derived from the canonical model.
|
|
110
|
+
* - Always interpreted as UTC.
|
|
111
|
+
*/
|
|
112
|
+
get lowerBound(): Date | null;
|
|
113
|
+
/**
|
|
114
|
+
* The inclusive upper bound of this fuzzy date's possible interval (UTC).
|
|
115
|
+
*
|
|
116
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
117
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
118
|
+
* if **either** its `lowerBound` **or** its `upperBound` falls within the
|
|
119
|
+
* search range.
|
|
120
|
+
*
|
|
121
|
+
* Formally (inclusive):
|
|
122
|
+
*
|
|
123
|
+
* ```text
|
|
124
|
+
* (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* ## Unbounded intervals
|
|
128
|
+
*
|
|
129
|
+
* Open-ended intervals are represented with `null`:
|
|
130
|
+
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
131
|
+
* - `upperBound === null` means unbounded in the future (+∞)
|
|
132
|
+
*
|
|
133
|
+
* When using the predicate above, treat a `null` bound as not satisfying
|
|
134
|
+
* the endpoint check (i.e., it is not “within” any finite window).
|
|
135
|
+
*
|
|
136
|
+
* @remarks
|
|
137
|
+
* - Derived from the canonical model.
|
|
138
|
+
* - Always interpreted as UTC.
|
|
139
|
+
*/
|
|
140
|
+
get upperBound(): Date | null;
|
|
141
|
+
/**
|
|
142
|
+
* Collation key used for chronological ordering.
|
|
143
|
+
*
|
|
144
|
+
* The collation key is a derived string whose lexicographic ordering
|
|
145
|
+
* is guaranteed to match the semantic chronological ordering of fuzzy dates.
|
|
146
|
+
*
|
|
147
|
+
* ```text
|
|
148
|
+
* a occurs before b ⇔ a.collationKey < b.collationKey
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* This property is intended for:
|
|
152
|
+
* - database indexing
|
|
153
|
+
* - `ORDER BY` clauses
|
|
154
|
+
* - stable sorting without parsing
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* - Not human-readable
|
|
158
|
+
* - Not intended for round-tripping
|
|
159
|
+
* - Derived entirely from the canonical model
|
|
160
|
+
*/
|
|
161
|
+
get collationKey(): string;
|
|
162
|
+
/**
|
|
163
|
+
* Serializes this fuzzy date to its canonical JSON representation.
|
|
164
|
+
*
|
|
165
|
+
* The returned object is suitable for long-term storage,
|
|
166
|
+
* transport, and later reconstruction via {@link fromJSON}.
|
|
167
|
+
*/
|
|
168
|
+
toJSON(): FuzzyDateModel;
|
|
169
|
+
/**
|
|
170
|
+
* Reconstructs a `FuzzyDate` from a previously serialized canonical model.
|
|
171
|
+
*
|
|
172
|
+
* @param json A trusted `FuzzyDateModel`, typically loaded from storage.
|
|
173
|
+
*/
|
|
174
|
+
static fromJSON(json: FuzzyDateModel): FuzzyDate;
|
|
175
|
+
/**
|
|
176
|
+
* Parses a human-readable date string into a `FuzzyDate`.
|
|
177
|
+
*
|
|
178
|
+
* This method should be used for all untrusted or user-provided input.
|
|
179
|
+
*
|
|
180
|
+
* @param input Human-readable date expression.
|
|
181
|
+
* @returns A result object containing either a `FuzzyDate` or parse issues.
|
|
182
|
+
*
|
|
183
|
+
* @remarks
|
|
184
|
+
* - Parsing is non-throwing; errors are returned as structured issues.
|
|
185
|
+
* - Successful results are guaranteed to produce a valid canonical model.
|
|
186
|
+
*/
|
|
187
|
+
static parse(input: string): {
|
|
188
|
+
ok: false;
|
|
189
|
+
error: "Year is required.";
|
|
190
|
+
} | {
|
|
191
|
+
ok: false;
|
|
192
|
+
error: "Unknown month.";
|
|
193
|
+
} | {
|
|
194
|
+
ok: false;
|
|
195
|
+
error: "Unknown date format.";
|
|
196
|
+
} | {
|
|
197
|
+
ok: false;
|
|
198
|
+
error: "Invalid \"BETWEEN\" modifier.";
|
|
199
|
+
} | {
|
|
200
|
+
ok: false;
|
|
201
|
+
error: "Invalid \"FROM\" modifier.";
|
|
202
|
+
} | {
|
|
203
|
+
ok: true;
|
|
204
|
+
value: FuzzyDate;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { parse } from './parse/index';
|
|
2
|
+
import { normalize } from './normalize/normalize';
|
|
3
|
+
import { collate } from './collate/collate';
|
|
4
|
+
import { toGedcomX } from './gedcomX/toGedcomX';
|
|
5
|
+
import { DATE_NEG_INFINITY, DATE_POS_INFINITY, ok, } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Represents an immutable, parsed fuzzy date suitable for genealogy and
|
|
8
|
+
* historical data contexts.
|
|
9
|
+
*
|
|
10
|
+
* A `FuzzyDate` encapsulates a human-entered date expression
|
|
11
|
+
* (e.g. `"before 1900"`, `"between 1 Jan 1900 and 31 Dec 1901"`,
|
|
12
|
+
* `"March 1890"`) and exposes multiple *derived representations* optimized
|
|
13
|
+
* for different use cases:
|
|
14
|
+
*
|
|
15
|
+
* - A canonical JSON model for storage and transport
|
|
16
|
+
* - Query bounds for database filtering
|
|
17
|
+
* - A collation key for stable chronological ordering
|
|
18
|
+
* - A normalized, human-readable string form
|
|
19
|
+
* - A GEDCOM X formal date representation
|
|
20
|
+
*
|
|
21
|
+
* ---
|
|
22
|
+
*
|
|
23
|
+
* ### Design principles
|
|
24
|
+
*
|
|
25
|
+
* - **Immutable**: Instances cannot be mutated after construction.
|
|
26
|
+
* - **Explicit construction**: Instances must be created via `parse()` or
|
|
27
|
+
* `fromJSON()`. Direct construction via `new` is intentionally disallowed.
|
|
28
|
+
* - **Derived projections**: Query bounds, collation keys, and serializations
|
|
29
|
+
* are all derived from a single internal model to guarantee consistency.
|
|
30
|
+
* - **UTC semantics**: All date calculations and comparisons are performed
|
|
31
|
+
* using UTC to avoid timezone-related ambiguity.
|
|
32
|
+
*
|
|
33
|
+
* ---
|
|
34
|
+
*
|
|
35
|
+
* ### Intended usage
|
|
36
|
+
*
|
|
37
|
+
* - Use `parse()` for untrusted, human-entered input.
|
|
38
|
+
* - Use `fromJSON()` when hydrating from a trusted serialized model
|
|
39
|
+
* (e.g. database JSON).
|
|
40
|
+
* - Store the JSON model as the canonical representation.
|
|
41
|
+
* - Store `lowerBound`, `upperBound`, and `collationKey` separately for
|
|
42
|
+
* efficient querying and ordering.
|
|
43
|
+
*/
|
|
44
|
+
export class FuzzyDate {
|
|
45
|
+
/**
|
|
46
|
+
* Canonical internal model representing the parsed fuzzy date.
|
|
47
|
+
*
|
|
48
|
+
* This model is considered the single source of truth; all other
|
|
49
|
+
* representations are derived from it.
|
|
50
|
+
*/
|
|
51
|
+
_model;
|
|
52
|
+
/**
|
|
53
|
+
* Private constructor to enforce factory-based creation.
|
|
54
|
+
*
|
|
55
|
+
* Consumers must use {@link FuzzyDate.parse} or {@link FuzzyDate.fromJSON}.
|
|
56
|
+
*/
|
|
57
|
+
constructor(model) {
|
|
58
|
+
this._model = model;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The original human-readable input string used to construct this fuzzy date.
|
|
62
|
+
*
|
|
63
|
+
* This value is preserved verbatim and is not normalized or modified.
|
|
64
|
+
*/
|
|
65
|
+
get original() {
|
|
66
|
+
return this._model.original;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* GEDCOM X formal date representation of this fuzzy date.
|
|
70
|
+
*
|
|
71
|
+
* This value is suitable for interoperability with genealogy systems
|
|
72
|
+
* that support the GEDCOM X date specification.
|
|
73
|
+
*
|
|
74
|
+
* @see https://github.com/FamilySearch/gedcomx/blob/master/specifications/date-format-specification.md
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* This representation is derived and may evolve as GEDCOM X mapping
|
|
78
|
+
* rules are refined.
|
|
79
|
+
*/
|
|
80
|
+
get formal() {
|
|
81
|
+
return toGedcomX(this._model);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Normalized, human-readable representation of the fuzzy date.
|
|
85
|
+
*
|
|
86
|
+
* This format represents the interpreted meaning of the original input
|
|
87
|
+
* using a standardized vocabulary and ordering.
|
|
88
|
+
*
|
|
89
|
+
* Example outputs:
|
|
90
|
+
* - `"before 1900"`
|
|
91
|
+
* - `"March 1890"`
|
|
92
|
+
* - `"between 1 Jan 1900 and 31 Dec 1901"`
|
|
93
|
+
*/
|
|
94
|
+
get normalized() {
|
|
95
|
+
return normalize(this._model);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The inclusive lower bound of this fuzzy date's possible interval (UTC).
|
|
99
|
+
*
|
|
100
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
101
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
102
|
+
* if **either** its `lowerBound` **or** its `upperBound` falls within the
|
|
103
|
+
* search range.
|
|
104
|
+
*
|
|
105
|
+
* Formally (inclusive):
|
|
106
|
+
*
|
|
107
|
+
* ```text
|
|
108
|
+
* (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* ## Unbounded intervals
|
|
112
|
+
*
|
|
113
|
+
* Open-ended intervals are represented with `null`:
|
|
114
|
+
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
115
|
+
* - `upperBound === null` means unbounded in the future (+∞)
|
|
116
|
+
*
|
|
117
|
+
* When using the predicate above, treat a `null` bound as not satisfying
|
|
118
|
+
* the endpoint check (i.e., it is not “within” any finite window).
|
|
119
|
+
*
|
|
120
|
+
* @remarks
|
|
121
|
+
* - Derived from the canonical model.
|
|
122
|
+
* - Always interpreted as UTC.
|
|
123
|
+
*/
|
|
124
|
+
get lowerBound() {
|
|
125
|
+
return this._model.start.minDate === DATE_NEG_INFINITY
|
|
126
|
+
? null
|
|
127
|
+
: this._model.start.minDate;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The inclusive upper bound of this fuzzy date's possible interval (UTC).
|
|
131
|
+
*
|
|
132
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
133
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
134
|
+
* if **either** its `lowerBound` **or** its `upperBound` falls within the
|
|
135
|
+
* search range.
|
|
136
|
+
*
|
|
137
|
+
* Formally (inclusive):
|
|
138
|
+
*
|
|
139
|
+
* ```text
|
|
140
|
+
* (searchStart <= lowerBound <= searchEnd) OR (searchStart <= upperBound <= searchEnd)
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* ## Unbounded intervals
|
|
144
|
+
*
|
|
145
|
+
* Open-ended intervals are represented with `null`:
|
|
146
|
+
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
147
|
+
* - `upperBound === null` means unbounded in the future (+∞)
|
|
148
|
+
*
|
|
149
|
+
* When using the predicate above, treat a `null` bound as not satisfying
|
|
150
|
+
* the endpoint check (i.e., it is not “within” any finite window).
|
|
151
|
+
*
|
|
152
|
+
* @remarks
|
|
153
|
+
* - Derived from the canonical model.
|
|
154
|
+
* - Always interpreted as UTC.
|
|
155
|
+
*/
|
|
156
|
+
get upperBound() {
|
|
157
|
+
return this._model.start.maxDate === DATE_POS_INFINITY
|
|
158
|
+
? null
|
|
159
|
+
: this._model.start.maxDate;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Collation key used for chronological ordering.
|
|
163
|
+
*
|
|
164
|
+
* The collation key is a derived string whose lexicographic ordering
|
|
165
|
+
* is guaranteed to match the semantic chronological ordering of fuzzy dates.
|
|
166
|
+
*
|
|
167
|
+
* ```text
|
|
168
|
+
* a occurs before b ⇔ a.collationKey < b.collationKey
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* This property is intended for:
|
|
172
|
+
* - database indexing
|
|
173
|
+
* - `ORDER BY` clauses
|
|
174
|
+
* - stable sorting without parsing
|
|
175
|
+
*
|
|
176
|
+
* @remarks
|
|
177
|
+
* - Not human-readable
|
|
178
|
+
* - Not intended for round-tripping
|
|
179
|
+
* - Derived entirely from the canonical model
|
|
180
|
+
*/
|
|
181
|
+
get collationKey() {
|
|
182
|
+
return collate(this._model);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Serializes this fuzzy date to its canonical JSON representation.
|
|
186
|
+
*
|
|
187
|
+
* The returned object is suitable for long-term storage,
|
|
188
|
+
* transport, and later reconstruction via {@link fromJSON}.
|
|
189
|
+
*/
|
|
190
|
+
toJSON() {
|
|
191
|
+
return this._model;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Reconstructs a `FuzzyDate` from a previously serialized canonical model.
|
|
195
|
+
*
|
|
196
|
+
* @param json A trusted `FuzzyDateModel`, typically loaded from storage.
|
|
197
|
+
*/
|
|
198
|
+
static fromJSON(json) {
|
|
199
|
+
return new FuzzyDate(json);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Parses a human-readable date string into a `FuzzyDate`.
|
|
203
|
+
*
|
|
204
|
+
* This method should be used for all untrusted or user-provided input.
|
|
205
|
+
*
|
|
206
|
+
* @param input Human-readable date expression.
|
|
207
|
+
* @returns A result object containing either a `FuzzyDate` or parse issues.
|
|
208
|
+
*
|
|
209
|
+
* @remarks
|
|
210
|
+
* - Parsing is non-throwing; errors are returned as structured issues.
|
|
211
|
+
* - Successful results are guaranteed to produce a valid canonical model.
|
|
212
|
+
*/
|
|
213
|
+
static parse(input) {
|
|
214
|
+
const result = parse(input);
|
|
215
|
+
if (!result.ok)
|
|
216
|
+
return result;
|
|
217
|
+
const date = new FuzzyDate(result.value);
|
|
218
|
+
return ok(date);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { assert, describe, expect, it } from 'vitest';
|
|
2
|
+
import { FuzzyDate } from './fuzzyDate';
|
|
3
|
+
describe('Fuzzy Date', () => {
|
|
4
|
+
it('errs if it parses an invalid date', () => {
|
|
5
|
+
const unknownDate = FuzzyDate.parse('a');
|
|
6
|
+
expect(unknownDate).toStrictEqual({
|
|
7
|
+
ok: false,
|
|
8
|
+
error: 'Unknown date format.',
|
|
9
|
+
});
|
|
10
|
+
const invalidBetween = FuzzyDate.parse('between 1900');
|
|
11
|
+
expect(invalidBetween).toStrictEqual({
|
|
12
|
+
ok: false,
|
|
13
|
+
error: 'Invalid "BETWEEN" modifier.',
|
|
14
|
+
});
|
|
15
|
+
const invalidFrom = FuzzyDate.parse('from 1900');
|
|
16
|
+
expect(invalidFrom).toStrictEqual({
|
|
17
|
+
ok: false,
|
|
18
|
+
error: 'Invalid "FROM" modifier.',
|
|
19
|
+
});
|
|
20
|
+
const invalidMonth = FuzzyDate.parse('jam 1900');
|
|
21
|
+
expect(invalidMonth).toStrictEqual({
|
|
22
|
+
ok: false,
|
|
23
|
+
error: 'Unknown month.',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it('parses date inputs', () => {
|
|
27
|
+
const formats = {
|
|
28
|
+
'1800s': '1800s',
|
|
29
|
+
'1800': '1800',
|
|
30
|
+
'Winter 1800': 'winter 1800',
|
|
31
|
+
'1800 Winter': 'winter 1800',
|
|
32
|
+
'Jan 1800': 'January 1800',
|
|
33
|
+
'January 1800': 'January 1800',
|
|
34
|
+
'1800 Jan': 'January 1800',
|
|
35
|
+
'1800 January': 'January 1800',
|
|
36
|
+
'1 Jan 1800': '1 January 1800',
|
|
37
|
+
'1 1800': 'January 1800',
|
|
38
|
+
'01 1800': 'January 1800',
|
|
39
|
+
'1800 1': 'January 1800',
|
|
40
|
+
'1800 01': 'January 1800',
|
|
41
|
+
'1 January 1800': '1 January 1800',
|
|
42
|
+
'01 Jan 1800': '1 January 1800',
|
|
43
|
+
'01 January 1800': '1 January 1800',
|
|
44
|
+
'Jan 1 1800': '1 January 1800',
|
|
45
|
+
'January 1 1800': '1 January 1800',
|
|
46
|
+
'Jan 01 1800': '1 January 1800',
|
|
47
|
+
'January 01 1800': '1 January 1800',
|
|
48
|
+
'1800 Jan 1': '1 January 1800',
|
|
49
|
+
'1800 January 1': '1 January 1800',
|
|
50
|
+
'1800 Jan 01': '1 January 1800',
|
|
51
|
+
'1800 January 01': '1 January 1800',
|
|
52
|
+
'1800 1 Jan': '1 January 1800',
|
|
53
|
+
'1800 1 January': '1 January 1800',
|
|
54
|
+
'1800 01 Jan': '1 January 1800',
|
|
55
|
+
'1800 01 January': '1 January 1800',
|
|
56
|
+
'1 1 1800': '1 January 1800',
|
|
57
|
+
'01 1 1800': '1 January 1800',
|
|
58
|
+
'1 01 1800': '1 January 1800',
|
|
59
|
+
'1800 1 1': '1 January 1800',
|
|
60
|
+
'1800 01 1': '1 January 1800',
|
|
61
|
+
'1800 1 01': '1 January 1800',
|
|
62
|
+
' 1st of February 1900 ': '1 February 1900',
|
|
63
|
+
'feb 1st, 1900': '1 February 1900',
|
|
64
|
+
'February 1st, 1900': '1 February 1900',
|
|
65
|
+
' ---___////,,,, sEpTeMbEr 30 th ,,, ---// 1984': '30 September 1984',
|
|
66
|
+
};
|
|
67
|
+
for (const [key, value] of Object.entries(formats)) {
|
|
68
|
+
const result = FuzzyDate.parse(key);
|
|
69
|
+
let normal;
|
|
70
|
+
if (result.ok) {
|
|
71
|
+
normal = result.value.normalized;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
normal = result.error;
|
|
75
|
+
}
|
|
76
|
+
expect(normal).toStrictEqual(value);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it('test modifiers parsing', () => {
|
|
80
|
+
const modifiers = [
|
|
81
|
+
'',
|
|
82
|
+
'before',
|
|
83
|
+
'after',
|
|
84
|
+
'about',
|
|
85
|
+
'between',
|
|
86
|
+
'from',
|
|
87
|
+
'early',
|
|
88
|
+
'mid',
|
|
89
|
+
'late',
|
|
90
|
+
'or',
|
|
91
|
+
];
|
|
92
|
+
const parse = {
|
|
93
|
+
'before 1st of February 1900': 'before 1 February 1900',
|
|
94
|
+
'after 1st of February 1900': 'after 1 February 1900',
|
|
95
|
+
'about 1st of February 1900': 'about 1 February 1900',
|
|
96
|
+
'between 1st of February 1900 and 18/03/1905': 'between 1 February 1900 and 18 March 1905',
|
|
97
|
+
'from 1st of February 1900 to 18/03/1905': 'from 1 February 1900 to 18 March 1905',
|
|
98
|
+
};
|
|
99
|
+
Object.entries(parse).forEach(([input, expected]) => {
|
|
100
|
+
const result = FuzzyDate.parse(input);
|
|
101
|
+
let normal;
|
|
102
|
+
if (result.ok) {
|
|
103
|
+
normal = result.value.normalized;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
normal = result.error;
|
|
107
|
+
}
|
|
108
|
+
expect(normal).toStrictEqual(expected);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
it('orders serialized dates in chronological order', () => {
|
|
112
|
+
const order = [
|
|
113
|
+
'before 2000',
|
|
114
|
+
'before jan 2000',
|
|
115
|
+
'before jan 1 2000',
|
|
116
|
+
'about 2000',
|
|
117
|
+
'about jan 2000',
|
|
118
|
+
'about jan 1 2000',
|
|
119
|
+
'2000',
|
|
120
|
+
'jan 2000',
|
|
121
|
+
'jan 1 2000',
|
|
122
|
+
'from 2000 to 2001',
|
|
123
|
+
'from 2000 to jan 2001',
|
|
124
|
+
'from 2000 to 1 jan 2001',
|
|
125
|
+
'from jan 2000 to 2001',
|
|
126
|
+
'from jan 2000 to jan 2001',
|
|
127
|
+
'from jan 2000 to 1 jan 2001',
|
|
128
|
+
'from 1 jan 2000 to 2001',
|
|
129
|
+
'from 1 jan 2000 to jan 2001',
|
|
130
|
+
'from 1 jan 2000 to 1 jan 2001',
|
|
131
|
+
'between 2000 and 2001',
|
|
132
|
+
'between 2000 and jan 2001',
|
|
133
|
+
'between 2000 and 1 jan 2001',
|
|
134
|
+
'between jan 2000 and 2001',
|
|
135
|
+
'between jan 2000 and jan 2001',
|
|
136
|
+
'between jan 2000 and 1 jan 2001',
|
|
137
|
+
'between 1 jan 2000 and 2001',
|
|
138
|
+
'between 1 jan 2000 and jan 2001',
|
|
139
|
+
'between 1 jan 2000 and 1 jan 2001',
|
|
140
|
+
'after 2000',
|
|
141
|
+
'after jan 2000',
|
|
142
|
+
'after jan 1 2000',
|
|
143
|
+
'jan 2 2000',
|
|
144
|
+
'dec 31 2000',
|
|
145
|
+
'',
|
|
146
|
+
];
|
|
147
|
+
for (let i = 1; i < order.length - 1; i++) {
|
|
148
|
+
const previous = FuzzyDate.parse(order[i - 1]);
|
|
149
|
+
const current = FuzzyDate.parse(order[i]);
|
|
150
|
+
if (previous.ok && current.ok) {
|
|
151
|
+
expect(previous.value.collationKey < current.value.collationKey).toBeTruthy();
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
assert.fail('failed to parse input');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|