@fhss-web-team/fuzzy-dates 1.2.3 → 2.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/README.md +99 -36
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -2
- package/dist/src/collation/collationKey.d.ts +2 -0
- package/dist/src/collation/collationKey.js +44 -0
- package/dist/{fuzzyDate → src}/fuzzyDate.d.ts +85 -69
- package/dist/src/fuzzyDate.js +238 -0
- package/dist/src/gedcomX/toGedcomX.js +28 -0
- package/dist/src/helpers/constants.d.ts +2 -0
- package/dist/src/helpers/constants.js +2 -0
- package/dist/src/helpers/types.d.ts +12 -0
- package/dist/src/helpers/types.js +3 -0
- package/dist/src/normalize/normalize.js +51 -0
- package/dist/src/parse/index.d.ts +47 -0
- package/dist/src/parse/index.js +41 -0
- package/dist/src/parse/modifiers.d.ts +182 -0
- package/dist/src/parse/modifiers.js +62 -0
- package/dist/{fuzzyDate/parse/inputDateFormats.d.ts → src/parse/simpleDate/formats.d.ts} +33 -50
- package/dist/{fuzzyDate/parse/inputDateFormats.js → src/parse/simpleDate/formats.js} +69 -88
- package/dist/src/parse/simpleDate/helpers.d.ts +21 -0
- package/dist/src/parse/simpleDate/helpers.js +58 -0
- package/dist/src/parse/simpleDate/parse.d.ts +31 -0
- package/dist/{fuzzyDate/parse/stringToDate.js → src/parse/simpleDate/parse.js} +4 -5
- package/package.json +1 -11
- package/dist/fuzzyDate/collate/collate.d.ts +0 -2
- package/dist/fuzzyDate/collate/collate.js +0 -15
- package/dist/fuzzyDate/fuzzyDate.js +0 -246
- package/dist/fuzzyDate/fuzzyDate.spec.d.ts +0 -1
- package/dist/fuzzyDate/fuzzyDate.spec.js +0 -158
- package/dist/fuzzyDate/gedcomX/toGedcomX.js +0 -31
- package/dist/fuzzyDate/helpers/constants.d.ts +0 -4
- package/dist/fuzzyDate/helpers/constants.js +0 -20
- package/dist/fuzzyDate/helpers/schemas.d.ts +0 -36
- package/dist/fuzzyDate/helpers/schemas.js +0 -12
- package/dist/fuzzyDate/helpers/types.d.ts +0 -16
- package/dist/fuzzyDate/helpers/types.js +0 -1
- package/dist/fuzzyDate/normalize/normalize.js +0 -47
- package/dist/fuzzyDate/parse/index.d.ts +0 -178
- package/dist/fuzzyDate/parse/index.js +0 -92
- package/dist/fuzzyDate/parse/modifiers.d.ts +0 -231
- package/dist/fuzzyDate/parse/modifiers.js +0 -185
- package/dist/fuzzyDate/parse/stringToDate.d.ts +0 -38
- /package/dist/{fuzzyDate → src}/gedcomX/toGedcomX.d.ts +0 -0
- /package/dist/{fuzzyDate → src}/helpers/result.d.ts +0 -0
- /package/dist/{fuzzyDate → src}/helpers/result.js +0 -0
- /package/dist/{fuzzyDate → src}/normalize/normalize.d.ts +0 -0
- /package/dist/{fuzzyDate/helpers → src/parse/simpleDate}/maps.d.ts +0 -0
- /package/dist/{fuzzyDate/helpers → src/parse/simpleDate}/maps.js +0 -0
package/README.md
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
# Fuzzy Dates
|
|
2
2
|
|
|
3
|
-
Parse
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- Handles modifiers like `before`, `after`, `about`, `between`, `from`, `early`, `mid`, `late`.
|
|
10
|
-
- Normalizes many input shapes (`1st of February 1900`, `Feb 1 1900`, `winter 1890`, `1800s`).
|
|
11
|
-
- Produces inclusive lower/upper bounds for range filtering and a collation key for stable chronological sorting.
|
|
12
|
-
- Outputs a canonical JSON model plus GEDCOM X formal dates for genealogy interoperability.
|
|
13
|
-
- ESM + TypeScript ready (`.d.ts` shipped with the package).
|
|
3
|
+
Parse human-entered fuzzy date text into a consistent model you can use to:
|
|
4
|
+
- normalize display text,
|
|
5
|
+
- filter with inclusive UTC bounds,
|
|
6
|
+
- sort chronologically with deterministic keys,
|
|
7
|
+
- serialize to GEDCOM X formal dates.
|
|
14
8
|
|
|
15
9
|
## Installation
|
|
16
10
|
|
|
@@ -21,43 +15,112 @@ npm i @fhss-web-team/fuzzy-dates
|
|
|
21
15
|
## Quick start
|
|
22
16
|
|
|
23
17
|
```ts
|
|
24
|
-
import { FuzzyDate } from
|
|
18
|
+
import { FuzzyDate } from '@fhss-web-team/fuzzy-dates';
|
|
25
19
|
|
|
26
|
-
const parsed = FuzzyDate.parse(
|
|
27
|
-
if (!parsed.ok) throw parsed.error;
|
|
20
|
+
const parsed = FuzzyDate.parse('about Feb 1900');
|
|
21
|
+
if (!parsed.ok) throw new Error(parsed.error);
|
|
28
22
|
|
|
29
23
|
const date = parsed.value;
|
|
30
|
-
console.log(date.normalized); // "about
|
|
31
|
-
console.log(date.
|
|
32
|
-
console.log(date.
|
|
33
|
-
console.log(date.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
console.log(date.normalized); // "about February 1900"
|
|
25
|
+
console.log(date.earliest.toISOString()); // "1900-02-01T00:00:00.000Z"
|
|
26
|
+
console.log(date.latest.toISOString()); // "1900-02-28T23:59:59.999Z"
|
|
27
|
+
console.log(date.collationKeys); // [primary, timeRelation, range, approximate]
|
|
28
|
+
console.log(date.formal); // "A+1900-02"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### `FuzzyDate.parse(input: string): Result<FuzzyDate, string>`
|
|
34
|
+
Non-throwing parse entrypoint.
|
|
35
|
+
|
|
36
|
+
- Success shape: `{ ok: true, value: FuzzyDate }`
|
|
37
|
+
- Error shape: `{ ok: false, error: string }`
|
|
38
|
+
|
|
39
|
+
### `FuzzyDate.sort(a: FuzzyDate, b: FuzzyDate): number`
|
|
40
|
+
Comparator for chronological ordering.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
dates.sort(FuzzyDate.sort);
|
|
38
44
|
```
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
### `FuzzyDate.query(searchStart: Date, searchEnd: Date, dates: readonly FuzzyDate[]): FuzzyDate[]`
|
|
47
|
+
Filters using endpoint-in-window logic (inclusive):
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
(searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
|
|
51
|
+
```
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
This is intentionally different from generic interval-overlap matching.
|
|
54
|
+
|
|
55
|
+
### Instance getters
|
|
56
|
+
|
|
57
|
+
- `normalized: string`
|
|
58
|
+
Standardized human-readable expression.
|
|
59
|
+
- `formal: string`
|
|
60
|
+
GEDCOM X formal date representation.
|
|
61
|
+
- `approximate: boolean`
|
|
62
|
+
Whether the parsed expression is marked approximate.
|
|
63
|
+
- `earliest: Date` / `latest: Date`
|
|
64
|
+
Inclusive UTC bounds. Open-ended ranges are represented with sentinel values:
|
|
65
|
+
- `before ...` uses `earliest = new Date(-8640000000000000)`
|
|
66
|
+
- `after ...` uses `latest = new Date(8640000000000000)`
|
|
67
|
+
- `collationKeys: readonly [number, -1 | 0 | 1, number, 0 | 1]`
|
|
68
|
+
Keys used by `FuzzyDate.sort`.
|
|
69
|
+
|
|
70
|
+
## Supported input
|
|
71
|
+
|
|
72
|
+
### Modifiers
|
|
73
|
+
|
|
74
|
+
- `about ...`, `approximately ...`, `around ...`
|
|
75
|
+
- `before ...`
|
|
76
|
+
- `after ...`
|
|
77
|
+
- `between ... and ...`
|
|
78
|
+
- `from ... to ...`
|
|
79
|
+
|
|
80
|
+
Notes:
|
|
81
|
+
- `between ... and ...` is treated as approximate.
|
|
82
|
+
- `from ... to ...` is exact unless prefixed with an approximation modifier.
|
|
83
|
+
|
|
84
|
+
### Simple date forms
|
|
85
|
+
|
|
86
|
+
Supported orderings include:
|
|
87
|
+
- `YYYY`
|
|
88
|
+
- `month YYYY` / `YYYY month` (named month, numeric month, or season)
|
|
89
|
+
- `day month YYYY`
|
|
90
|
+
- `month day YYYY`
|
|
91
|
+
- `YYYY month day`
|
|
92
|
+
- `YYYY day month`
|
|
93
|
+
- digit variants for month/day in those positions
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
- `1900`
|
|
97
|
+
- `January 1900`, `1900 Jan`, `01 1900`, `1900 1`
|
|
98
|
+
- `1 Jan 1900`, `Jan 1 1900`, `1900 January 1`, `1900 1 Jan`
|
|
99
|
+
- `winter 1900`, `1900 autumn`
|
|
100
|
+
|
|
101
|
+
Input normalization steps:
|
|
102
|
+
- case-insensitive,
|
|
103
|
+
- strips ordinal suffixes (`1st`, `2nd`, `3rd`, `4th`, ...),
|
|
104
|
+
- treats punctuation/separators (`. , / - _`) as whitespace.
|
|
105
|
+
|
|
106
|
+
## Behavior details
|
|
107
|
+
|
|
108
|
+
- All parsing and bounds are UTC-based.
|
|
109
|
+
- Calendar-overflow dates are normalized by JavaScript `Date` semantics.
|
|
110
|
+
- `29 feb 1900` normalizes to `1 March 1900`.
|
|
111
|
+
- `31 apr 1900` normalizes to `1 May 1900`.
|
|
112
|
+
- Current year support is strictly four digits (`0000`-`9999` pattern in input).
|
|
113
|
+
- Decade shorthand like `1800s` is not currently supported.
|
|
50
114
|
|
|
51
115
|
## Development
|
|
52
116
|
|
|
53
|
-
- Tests: `npm test`
|
|
54
|
-
- Build: `npm run build`
|
|
55
|
-
-
|
|
117
|
+
- Tests: `npm test`
|
|
118
|
+
- Build: `npm run build`
|
|
119
|
+
- Package check: `npm pack`
|
|
56
120
|
|
|
57
121
|
## Support
|
|
58
122
|
|
|
59
|
-
This package was developed by the [BYU
|
|
60
|
-
our mission to provide quality online family history research and resources for the public at no cost, consider donating [HERE](https://donate.churchofjesuschrist.org/contribute/byu/family-home-social-sciences/center-family-history-genealogy)
|
|
123
|
+
This package was developed by the [BYU Center for Family History and Genealogy](https://cfhg.byu.edu/). To support our mission to provide quality online family history research and resources for the public at no cost, consider donating [here](https://donate.churchofjesuschrist.org/contribute/byu/family-home-social-sciences/center-family-history-genealogy).
|
|
61
124
|
|
|
62
125
|
## License
|
|
63
126
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export { FuzzyDate } from './
|
|
2
|
-
export { fuzzyDateJsonSchema } from './fuzzyDate/helpers/schemas.js';
|
|
1
|
+
export { FuzzyDate } from './src/fuzzyDate.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isRange } from '../helpers/types';
|
|
2
|
+
export function collate(model) {
|
|
3
|
+
if (isRange(model)) {
|
|
4
|
+
// left open
|
|
5
|
+
if (model.start === null && model.end !== null) {
|
|
6
|
+
return [
|
|
7
|
+
model.end.min.getTime(),
|
|
8
|
+
-1,
|
|
9
|
+
-1 * (model.end.max.getTime() - model.end.min.getTime()),
|
|
10
|
+
model.approximate ? 0 : 1,
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
// right open
|
|
14
|
+
if (model.end === null && model.start !== null) {
|
|
15
|
+
return [
|
|
16
|
+
model.start.max.getTime(),
|
|
17
|
+
1,
|
|
18
|
+
-1 * (model.start.max.getTime() - model.start.min.getTime()),
|
|
19
|
+
model.approximate ? 0 : 1,
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
// closed
|
|
23
|
+
if (model.start !== null && model.end !== null) {
|
|
24
|
+
return [
|
|
25
|
+
model.start.min.getTime(),
|
|
26
|
+
0,
|
|
27
|
+
-1 * (model.end.max.getTime() - model.start.min.getTime()),
|
|
28
|
+
model.approximate ? 0 : 1,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// simple
|
|
34
|
+
if (model.start !== null && model.end !== null) {
|
|
35
|
+
return [
|
|
36
|
+
model.start.min.getTime(),
|
|
37
|
+
0,
|
|
38
|
+
-1 * (model.end.max.getTime() - model.start.min.getTime()),
|
|
39
|
+
model.approximate ? 0 : 1,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER, 1];
|
|
44
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { FuzzyDateJson } from './helpers/types';
|
|
2
1
|
/**
|
|
3
2
|
* Represents an immutable, parsed fuzzy date suitable for genealogy and
|
|
4
3
|
* historical data contexts.
|
|
@@ -19,9 +18,9 @@ import { FuzzyDateJson } from './helpers/types';
|
|
|
19
18
|
* ### Design principles
|
|
20
19
|
*
|
|
21
20
|
* - **Immutable**: Instances cannot be mutated after construction.
|
|
22
|
-
* - **Explicit construction**: Instances must be created via `parse()
|
|
23
|
-
*
|
|
24
|
-
* - **Derived projections**: Query bounds
|
|
21
|
+
* - **Explicit construction**: Instances must be created via `parse()`.
|
|
22
|
+
* Direct construction via `new` is intentionally disallowed.
|
|
23
|
+
* - **Derived projections**: Query bounds and collation keys
|
|
25
24
|
* are all derived from a single internal model to guarantee consistency.
|
|
26
25
|
* - **UTC semantics**: All date calculations and comparisons are performed
|
|
27
26
|
* using UTC to avoid timezone-related ambiguity.
|
|
@@ -31,10 +30,7 @@ import { FuzzyDateJson } from './helpers/types';
|
|
|
31
30
|
* ### Intended usage
|
|
32
31
|
*
|
|
33
32
|
* - Use `parse()` for untrusted, human-entered input.
|
|
34
|
-
* -
|
|
35
|
-
* (e.g. database JSON).
|
|
36
|
-
* - Store the JSON model as the canonical representation.
|
|
37
|
-
* - Store `lowerBound`, `upperBound`, and `collationKey` separately for
|
|
33
|
+
* - Store `lowerBound`, `upperBound`, and `collationKeys` separately for
|
|
38
34
|
* efficient querying and ordering.
|
|
39
35
|
*/
|
|
40
36
|
export declare class FuzzyDate {
|
|
@@ -48,7 +44,7 @@ export declare class FuzzyDate {
|
|
|
48
44
|
/**
|
|
49
45
|
* Private constructor to enforce factory-based creation.
|
|
50
46
|
*
|
|
51
|
-
* Consumers must use {@link FuzzyDate.parse}
|
|
47
|
+
* Consumers must use {@link FuzzyDate.parse}.
|
|
52
48
|
*/
|
|
53
49
|
private constructor();
|
|
54
50
|
/**
|
|
@@ -81,97 +77,78 @@ export declare class FuzzyDate {
|
|
|
81
77
|
*
|
|
82
78
|
* This bound is intended for **endpoint-in-window** searching:
|
|
83
79
|
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
84
|
-
* if **either** its `
|
|
80
|
+
* if **either** its `earliest` **or** its `latest` falls within the
|
|
85
81
|
* search range.
|
|
86
82
|
*
|
|
87
|
-
* Formally (inclusive):
|
|
88
|
-
*
|
|
89
83
|
* ```text
|
|
90
|
-
* (searchStart <=
|
|
84
|
+
* (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
|
|
91
85
|
* ```
|
|
92
86
|
*
|
|
93
|
-
* ## Unbounded intervals
|
|
94
|
-
*
|
|
95
|
-
* Open-ended intervals are represented with `null`:
|
|
96
|
-
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
97
|
-
* - `upperBound === null` means unbounded in the future (+∞)
|
|
98
|
-
*
|
|
99
|
-
* When using the predicate above, treat a `null` bound as not satisfying
|
|
100
|
-
* the endpoint check (i.e., it is not “within” any finite window).
|
|
101
|
-
*
|
|
102
87
|
* @remarks
|
|
103
|
-
* - Derived from the canonical model.
|
|
104
88
|
* - Always interpreted as UTC.
|
|
105
89
|
*/
|
|
106
|
-
get
|
|
90
|
+
get earliest(): Date;
|
|
107
91
|
/**
|
|
108
92
|
* The inclusive upper bound of this fuzzy date's possible interval (UTC).
|
|
109
93
|
*
|
|
110
94
|
* This bound is intended for **endpoint-in-window** searching:
|
|
111
95
|
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
112
|
-
* if **either** its `
|
|
96
|
+
* if **either** its `earliest` **or** its `latest` falls within the
|
|
113
97
|
* search range.
|
|
114
98
|
*
|
|
115
|
-
* Formally (inclusive):
|
|
116
|
-
*
|
|
117
99
|
* ```text
|
|
118
|
-
* (searchStart <=
|
|
100
|
+
* (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
|
|
119
101
|
* ```
|
|
120
102
|
*
|
|
121
|
-
* ## Unbounded intervals
|
|
122
|
-
*
|
|
123
|
-
* Open-ended intervals are represented with `null`:
|
|
124
|
-
* - `lowerBound === null` means unbounded in the past (−∞)
|
|
125
|
-
* - `upperBound === null` means unbounded in the future (+∞)
|
|
126
|
-
*
|
|
127
|
-
* When using the predicate above, treat a `null` bound as not satisfying
|
|
128
|
-
* the endpoint check (i.e., it is not “within” any finite window).
|
|
129
|
-
*
|
|
130
103
|
* @remarks
|
|
131
|
-
* - Derived from the canonical model.
|
|
132
104
|
* - Always interpreted as UTC.
|
|
133
105
|
*/
|
|
134
|
-
get
|
|
106
|
+
get latest(): Date;
|
|
135
107
|
/**
|
|
136
|
-
*
|
|
108
|
+
* A lexicographically sortable tuple representing this fuzzy date.
|
|
137
109
|
*
|
|
138
|
-
*
|
|
139
|
-
* is guaranteed to match the semantic chronological ordering of fuzzy dates.
|
|
110
|
+
* Sort **ascending** by each element in order to achieve correct chronological ordering.
|
|
140
111
|
*
|
|
141
|
-
*
|
|
142
|
-
* a occurs before b ⇔ a.collationKey < b.collationKey
|
|
143
|
-
* ```
|
|
112
|
+
* Tuple structure:
|
|
144
113
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* -
|
|
114
|
+
* 1. `primary` (number)
|
|
115
|
+
* - Epoch milliseconds used as the primary chronological anchor.
|
|
116
|
+
* - For closed ranges and simple dates: the start minimum.
|
|
117
|
+
* - For left-open ranges: the end minimum.
|
|
118
|
+
* - For right-open ranges: the start maximum.
|
|
149
119
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
get collationKey(): string;
|
|
156
|
-
/**
|
|
157
|
-
* Serializes this fuzzy date to its canonical JSON representation.
|
|
120
|
+
* 2. `timeRelation` (-1 | 0 | 1)
|
|
121
|
+
* - Distinguishes range type.
|
|
122
|
+
* - `-1` → left-open range (… end)
|
|
123
|
+
* - `0` → closed range or simple date
|
|
124
|
+
* - `1` → right-open range (start …)
|
|
158
125
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
126
|
+
* 3. `range` (number)
|
|
127
|
+
* - Total span of the range in milliseconds.
|
|
128
|
+
* - For simple dates, this represents the precision window.
|
|
129
|
+
* - Wider ranges sort before smaller ones.
|
|
130
|
+
*
|
|
131
|
+
* 4. `approximate` (0 | 1)
|
|
132
|
+
* - `0` → approximate
|
|
133
|
+
* - `1` → exact
|
|
134
|
+
* - Approximate values sort first.
|
|
135
|
+
*
|
|
136
|
+
* Example (SQL):
|
|
137
|
+
*
|
|
138
|
+
* ```sql
|
|
139
|
+
* ORDER BY primary ASC,
|
|
140
|
+
* timeRelation ASC,
|
|
141
|
+
* range ASC,
|
|
142
|
+
* approximate ASC
|
|
143
|
+
* ```
|
|
161
144
|
*/
|
|
162
|
-
|
|
145
|
+
get collationKeys(): readonly [number, -1 | 0 | 1, number, 0 | 1];
|
|
163
146
|
/**
|
|
164
|
-
*
|
|
147
|
+
* Gets whether the date is marked as approximate.
|
|
165
148
|
*
|
|
166
|
-
* @
|
|
149
|
+
* @returns True if the underlying model marks the date as approximate; otherwise false.
|
|
167
150
|
*/
|
|
168
|
-
|
|
169
|
-
ok: true;
|
|
170
|
-
value: FuzzyDate;
|
|
171
|
-
} | {
|
|
172
|
-
ok: false;
|
|
173
|
-
error: "Invalid JSON data.";
|
|
174
|
-
};
|
|
151
|
+
get approximate(): boolean;
|
|
175
152
|
/**
|
|
176
153
|
* Parses a human-readable date string into a `FuzzyDate`.
|
|
177
154
|
*
|
|
@@ -203,4 +180,43 @@ export declare class FuzzyDate {
|
|
|
203
180
|
ok: true;
|
|
204
181
|
value: FuzzyDate;
|
|
205
182
|
};
|
|
183
|
+
/**
|
|
184
|
+
* Comparator for sorting {@link FuzzyDate} instances chronologically.
|
|
185
|
+
*
|
|
186
|
+
* This method performs a lexicographic ascending comparison using each
|
|
187
|
+
* instance’s {@link FuzzyDate.collationKeys} tuple.
|
|
188
|
+
*
|
|
189
|
+
* Sort precedence (ascending):
|
|
190
|
+
* 1. Primary epoch anchor (milliseconds)
|
|
191
|
+
* 2. Time relation (-1 | 0 | 1)
|
|
192
|
+
* 3. Range span (milliseconds)
|
|
193
|
+
* 4. Approximate flag (0 = approximate, 1 = exact)
|
|
194
|
+
*
|
|
195
|
+
* Designed for use with `Array.prototype.sort`.
|
|
196
|
+
*
|
|
197
|
+
* @param a - The first {@link FuzzyDate} to compare.
|
|
198
|
+
* @param b - The second {@link FuzzyDate} to compare.
|
|
199
|
+
* @returns
|
|
200
|
+
* - A negative number if `a` should sort before `b`
|
|
201
|
+
* - A positive number if `a` should sort after `b`
|
|
202
|
+
* - `0` if they are considered equivalent for sorting
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* dates.sort(FuzzyDate.sort);
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
static sort(a: FuzzyDate, b: FuzzyDate): number;
|
|
210
|
+
/**
|
|
211
|
+
* Filters dates using endpoint-in-window semantics.
|
|
212
|
+
*
|
|
213
|
+
* A date matches when either endpoint (`earliest` or `latest`) is inside
|
|
214
|
+
* the inclusive UTC window `[searchStart, searchEnd]`.
|
|
215
|
+
*
|
|
216
|
+
* @param searchStart Inclusive window start (UTC).
|
|
217
|
+
* @param searchEnd Inclusive window end (UTC).
|
|
218
|
+
* @param dates Collection of fuzzy dates to evaluate.
|
|
219
|
+
* @returns Sublist of dates that match the query.
|
|
220
|
+
*/
|
|
221
|
+
static query(searchStart: Date, searchEnd: Date, dates: readonly FuzzyDate[]): FuzzyDate[];
|
|
206
222
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { parse } from './parse/index';
|
|
2
|
+
import { normalize } from './normalize/normalize';
|
|
3
|
+
import { toGedcomX } from './gedcomX/toGedcomX';
|
|
4
|
+
import { ok } from './helpers/result';
|
|
5
|
+
import { DATE_NEG_INFINITY, DATE_POS_INFINITY } from './helpers/constants';
|
|
6
|
+
import { collate } from './collation/collationKey';
|
|
7
|
+
/**
|
|
8
|
+
* Represents an immutable, parsed fuzzy date suitable for genealogy and
|
|
9
|
+
* historical data contexts.
|
|
10
|
+
*
|
|
11
|
+
* A `FuzzyDate` encapsulates a human-entered date expression
|
|
12
|
+
* (e.g. `"before 1900"`, `"between 1 Jan 1900 and 31 Dec 1901"`,
|
|
13
|
+
* `"March 1890"`) and exposes multiple *derived representations* optimized
|
|
14
|
+
* for different use cases:
|
|
15
|
+
*
|
|
16
|
+
* - A canonical JSON model for storage and transport
|
|
17
|
+
* - Query bounds for database filtering
|
|
18
|
+
* - A collation key for stable chronological ordering
|
|
19
|
+
* - A normalized, human-readable string form
|
|
20
|
+
* - A GEDCOM X formal date representation
|
|
21
|
+
*
|
|
22
|
+
* ---
|
|
23
|
+
*
|
|
24
|
+
* ### Design principles
|
|
25
|
+
*
|
|
26
|
+
* - **Immutable**: Instances cannot be mutated after construction.
|
|
27
|
+
* - **Explicit construction**: Instances must be created via `parse()`.
|
|
28
|
+
* Direct construction via `new` is intentionally disallowed.
|
|
29
|
+
* - **Derived projections**: Query bounds and collation keys
|
|
30
|
+
* are all derived from a single internal model to guarantee consistency.
|
|
31
|
+
* - **UTC semantics**: All date calculations and comparisons are performed
|
|
32
|
+
* using UTC to avoid timezone-related ambiguity.
|
|
33
|
+
*
|
|
34
|
+
* ---
|
|
35
|
+
*
|
|
36
|
+
* ### Intended usage
|
|
37
|
+
*
|
|
38
|
+
* - Use `parse()` for untrusted, human-entered input.
|
|
39
|
+
* - Store `lowerBound`, `upperBound`, and `collationKeys` separately for
|
|
40
|
+
* efficient querying and ordering.
|
|
41
|
+
*/
|
|
42
|
+
export class FuzzyDate {
|
|
43
|
+
/**
|
|
44
|
+
* Canonical internal model representing the parsed fuzzy date.
|
|
45
|
+
*
|
|
46
|
+
* This model is considered the single source of truth; all other
|
|
47
|
+
* representations are derived from it.
|
|
48
|
+
*/
|
|
49
|
+
_model;
|
|
50
|
+
/**
|
|
51
|
+
* Private constructor to enforce factory-based creation.
|
|
52
|
+
*
|
|
53
|
+
* Consumers must use {@link FuzzyDate.parse}.
|
|
54
|
+
*/
|
|
55
|
+
constructor(model) {
|
|
56
|
+
this._model = model;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* GEDCOM X formal date representation of this fuzzy date.
|
|
60
|
+
*
|
|
61
|
+
* This value is suitable for interoperability with genealogy systems
|
|
62
|
+
* that support the GEDCOM X date specification.
|
|
63
|
+
*
|
|
64
|
+
* @see https://github.com/FamilySearch/gedcomx/blob/master/specifications/date-format-specification.md
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* This representation is derived and may evolve as GEDCOM X mapping
|
|
68
|
+
* rules are refined.
|
|
69
|
+
*/
|
|
70
|
+
get formal() {
|
|
71
|
+
return toGedcomX(this._model);
|
|
72
|
+
}
|
|
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() {
|
|
85
|
+
return normalize(this._model);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The inclusive lower bound of this fuzzy date's possible interval (UTC).
|
|
89
|
+
*
|
|
90
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
91
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
92
|
+
* if **either** its `earliest` **or** its `latest` falls within the
|
|
93
|
+
* search range.
|
|
94
|
+
*
|
|
95
|
+
* ```text
|
|
96
|
+
* (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* - Always interpreted as UTC.
|
|
101
|
+
*/
|
|
102
|
+
get earliest() {
|
|
103
|
+
return this._model.start?.min ?? DATE_NEG_INFINITY;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The inclusive upper bound of this fuzzy date's possible interval (UTC).
|
|
107
|
+
*
|
|
108
|
+
* This bound is intended for **endpoint-in-window** searching:
|
|
109
|
+
* a fuzzy date matches an inclusive search range `[searchStart, searchEnd]`
|
|
110
|
+
* if **either** its `earliest` **or** its `latest` falls within the
|
|
111
|
+
* search range.
|
|
112
|
+
*
|
|
113
|
+
* ```text
|
|
114
|
+
* (searchStart <= earliest <= searchEnd) OR (searchStart <= latest <= searchEnd)
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @remarks
|
|
118
|
+
* - Always interpreted as UTC.
|
|
119
|
+
*/
|
|
120
|
+
get latest() {
|
|
121
|
+
return this._model.end?.max ?? DATE_POS_INFINITY;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A lexicographically sortable tuple representing this fuzzy date.
|
|
125
|
+
*
|
|
126
|
+
* Sort **ascending** by each element in order to achieve correct chronological ordering.
|
|
127
|
+
*
|
|
128
|
+
* Tuple structure:
|
|
129
|
+
*
|
|
130
|
+
* 1. `primary` (number)
|
|
131
|
+
* - Epoch milliseconds used as the primary chronological anchor.
|
|
132
|
+
* - For closed ranges and simple dates: the start minimum.
|
|
133
|
+
* - For left-open ranges: the end minimum.
|
|
134
|
+
* - For right-open ranges: the start maximum.
|
|
135
|
+
*
|
|
136
|
+
* 2. `timeRelation` (-1 | 0 | 1)
|
|
137
|
+
* - Distinguishes range type.
|
|
138
|
+
* - `-1` → left-open range (… end)
|
|
139
|
+
* - `0` → closed range or simple date
|
|
140
|
+
* - `1` → right-open range (start …)
|
|
141
|
+
*
|
|
142
|
+
* 3. `range` (number)
|
|
143
|
+
* - Total span of the range in milliseconds.
|
|
144
|
+
* - For simple dates, this represents the precision window.
|
|
145
|
+
* - Wider ranges sort before smaller ones.
|
|
146
|
+
*
|
|
147
|
+
* 4. `approximate` (0 | 1)
|
|
148
|
+
* - `0` → approximate
|
|
149
|
+
* - `1` → exact
|
|
150
|
+
* - Approximate values sort first.
|
|
151
|
+
*
|
|
152
|
+
* Example (SQL):
|
|
153
|
+
*
|
|
154
|
+
* ```sql
|
|
155
|
+
* ORDER BY primary ASC,
|
|
156
|
+
* timeRelation ASC,
|
|
157
|
+
* range ASC,
|
|
158
|
+
* approximate ASC
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
get collationKeys() {
|
|
162
|
+
return collate(this._model);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Gets whether the date is marked as approximate.
|
|
166
|
+
*
|
|
167
|
+
* @returns True if the underlying model marks the date as approximate; otherwise false.
|
|
168
|
+
*/
|
|
169
|
+
get approximate() {
|
|
170
|
+
return this._model.approximate;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Parses a human-readable date string into a `FuzzyDate`.
|
|
174
|
+
*
|
|
175
|
+
* This method should be used for all untrusted or user-provided input.
|
|
176
|
+
*
|
|
177
|
+
* @param input Human-readable date expression.
|
|
178
|
+
* @returns A result object containing either a `FuzzyDate` or parse issues.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* - Parsing is non-throwing; errors are returned as structured issues.
|
|
182
|
+
* - Successful results are guaranteed to produce a valid canonical model.
|
|
183
|
+
*/
|
|
184
|
+
static parse(input) {
|
|
185
|
+
const result = parse(input);
|
|
186
|
+
if (!result.ok)
|
|
187
|
+
return result;
|
|
188
|
+
const date = new FuzzyDate(result.value);
|
|
189
|
+
return ok(date);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Comparator for sorting {@link FuzzyDate} instances chronologically.
|
|
193
|
+
*
|
|
194
|
+
* This method performs a lexicographic ascending comparison using each
|
|
195
|
+
* instance’s {@link FuzzyDate.collationKeys} tuple.
|
|
196
|
+
*
|
|
197
|
+
* Sort precedence (ascending):
|
|
198
|
+
* 1. Primary epoch anchor (milliseconds)
|
|
199
|
+
* 2. Time relation (-1 | 0 | 1)
|
|
200
|
+
* 3. Range span (milliseconds)
|
|
201
|
+
* 4. Approximate flag (0 = approximate, 1 = exact)
|
|
202
|
+
*
|
|
203
|
+
* Designed for use with `Array.prototype.sort`.
|
|
204
|
+
*
|
|
205
|
+
* @param a - The first {@link FuzzyDate} to compare.
|
|
206
|
+
* @param b - The second {@link FuzzyDate} to compare.
|
|
207
|
+
* @returns
|
|
208
|
+
* - A negative number if `a` should sort before `b`
|
|
209
|
+
* - A positive number if `a` should sort after `b`
|
|
210
|
+
* - `0` if they are considered equivalent for sorting
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```ts
|
|
214
|
+
* dates.sort(FuzzyDate.sort);
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
static sort(a, b) {
|
|
218
|
+
return (a.collationKeys[0] - b.collationKeys[0] ||
|
|
219
|
+
a.collationKeys[1] - b.collationKeys[1] ||
|
|
220
|
+
a.collationKeys[2] - b.collationKeys[2] ||
|
|
221
|
+
a.collationKeys[3] - b.collationKeys[3]);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Filters dates using endpoint-in-window semantics.
|
|
225
|
+
*
|
|
226
|
+
* A date matches when either endpoint (`earliest` or `latest`) is inside
|
|
227
|
+
* the inclusive UTC window `[searchStart, searchEnd]`.
|
|
228
|
+
*
|
|
229
|
+
* @param searchStart Inclusive window start (UTC).
|
|
230
|
+
* @param searchEnd Inclusive window end (UTC).
|
|
231
|
+
* @param dates Collection of fuzzy dates to evaluate.
|
|
232
|
+
* @returns Sublist of dates that match the query.
|
|
233
|
+
*/
|
|
234
|
+
static query(searchStart, searchEnd, dates) {
|
|
235
|
+
return dates.filter((date) => (searchStart <= date.earliest && date.earliest <= searchEnd) ||
|
|
236
|
+
(searchStart <= date.latest && date.latest <= searchEnd));
|
|
237
|
+
}
|
|
238
|
+
}
|