@jarenjs/core 0.9.2 → 0.34.2
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/ARCHITECTURE.md +229 -26
- package/README.md +64 -10
- package/dist/types/array.d.ts +13 -0
- package/dist/types/cache.d.ts +166 -0
- package/dist/types/chunk.d.ts +102 -0
- package/dist/types/color.d.ts +64 -0
- package/dist/types/convert/convert.d.ts +33 -0
- package/dist/types/convert/currency.d.ts +47 -0
- package/dist/types/convert/index.d.ts +9 -0
- package/dist/types/convert/registry.d.ts +55 -0
- package/dist/types/dates/civil.d.ts +146 -0
- package/dist/types/dates/duration.d.ts +83 -0
- package/dist/types/dates/format.d.ts +59 -0
- package/dist/types/dates/index.d.ts +4 -0
- package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
- package/dist/types/errors.d.ts +77 -0
- package/dist/types/finance/amortization.d.ts +51 -0
- package/dist/types/finance/bond.d.ts +45 -0
- package/dist/types/finance/cashflow.d.ts +44 -0
- package/dist/types/finance/depreciation.d.ts +26 -0
- package/dist/types/finance/index.d.ts +16 -0
- package/dist/types/finance/indicators.d.ts +76 -0
- package/dist/types/finance/interest.d.ts +44 -0
- package/dist/types/finance/returns.d.ts +43 -0
- package/dist/types/finance/tvm.d.ts +50 -0
- package/dist/types/geo/angle.d.ts +4 -0
- package/dist/types/geo/bbox.d.ts +32 -0
- package/dist/types/geo/distance.d.ts +67 -0
- package/dist/types/geo/geohash.d.ts +63 -0
- package/dist/types/geo/geojson.d.ts +108 -0
- package/dist/types/geo/index-tree.d.ts +52 -0
- package/dist/types/geo/index.d.ts +11 -0
- package/dist/types/geo/mercator.d.ts +57 -0
- package/dist/types/geo/predicates.d.ts +44 -0
- package/dist/types/geo/ring.d.ts +80 -0
- package/dist/types/geo/simplify.d.ts +31 -0
- package/dist/types/geo/valid.d.ts +24 -0
- package/dist/types/geo/wkt.d.ts +16 -0
- package/dist/types/math/float64.d.ts +126 -3
- package/dist/types/math/format.d.ts +58 -0
- package/dist/types/math/index.d.ts +5 -0
- package/dist/types/math/mat4.d.ts +60 -0
- package/dist/types/math/project.d.ts +54 -0
- package/dist/types/math/solve.d.ts +67 -0
- package/dist/types/math/word.d.ts +82 -0
- package/dist/types/message.d.ts +52 -0
- package/dist/types/object.d.ts +151 -0
- package/dist/types/scan.d.ts +30 -0
- package/dist/types/schema.d.ts +24 -0
- package/dist/types/string.d.ts +96 -0
- package/dist/types/text/base64.d.ts +6 -4
- package/dist/types/text/email.d.ts +0 -1
- package/dist/types/text/host.d.ts +39 -8
- package/dist/types/text/i18n.d.ts +13 -1
- package/dist/types/text/index.d.ts +1 -0
- package/dist/types/text/misc.d.ts +15 -1
- package/dist/types/text/punycode.d.ts +45 -85
- package/docs/CONVERT.md +45 -0
- package/docs/DATES.md +78 -0
- package/docs/FINANCE.md +59 -0
- package/docs/GEO.md +114 -0
- package/docs/MATH.md +75 -0
- package/package.json +57 -4
- package/src/array.js +16 -0
- package/src/cache.js +206 -0
- package/src/chunk.js +159 -0
- package/src/color.js +125 -0
- package/src/convert/convert.js +59 -0
- package/src/convert/currency.js +74 -0
- package/src/convert/index.js +11 -0
- package/src/convert/registry.js +213 -0
- package/src/dates/civil.js +360 -0
- package/src/dates/duration.js +225 -0
- package/src/dates/format.js +238 -0
- package/src/dates/index.js +30 -0
- package/src/dates/rfc3339.js +621 -0
- package/src/errors.js +102 -0
- package/src/finance/amortization.js +50 -0
- package/src/finance/bond.js +100 -0
- package/src/finance/cashflow.js +122 -0
- package/src/finance/depreciation.js +54 -0
- package/src/finance/index.js +27 -0
- package/src/finance/indicators.js +207 -0
- package/src/finance/interest.js +63 -0
- package/src/finance/returns.js +90 -0
- package/src/finance/tvm.js +94 -0
- package/src/function.js +0 -2
- package/src/geo/angle.js +12 -0
- package/src/geo/bbox.js +80 -0
- package/src/geo/distance.js +155 -0
- package/src/geo/geohash.js +224 -0
- package/src/geo/geojson.js +361 -0
- package/src/geo/index-tree.js +294 -0
- package/src/geo/index.js +52 -0
- package/src/geo/mercator.js +124 -0
- package/src/geo/predicates.js +313 -0
- package/src/geo/ring.js +182 -0
- package/src/geo/simplify.js +130 -0
- package/src/geo/valid.js +142 -0
- package/src/geo/wkt.js +262 -0
- package/src/math/float64.js +228 -11
- package/src/math/format.js +157 -0
- package/src/math/index.js +5 -0
- package/src/math/mat4.js +131 -0
- package/src/math/project.js +49 -0
- package/src/math/solve.js +112 -0
- package/src/math/word.js +177 -0
- package/src/message.js +119 -0
- package/src/object.js +329 -0
- package/src/scan.js +42 -0
- package/src/schema.js +37 -0
- package/src/string.js +189 -0
- package/src/text/base64.js +15 -52
- package/src/text/email.js +0 -5
- package/src/text/host.js +515 -140
- package/src/text/i18n.js +13 -6
- package/src/text/identifiers.js +1 -1
- package/src/text/index.js +1 -0
- package/src/text/misc.js +81 -5
- package/src/text/punycode.js +235 -323
- package/src/dates.js +0 -371
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isStringType,
|
|
5
|
+
isObjectOfClass,
|
|
6
|
+
} from '../index.js';
|
|
7
|
+
// the calendar rules live in civil.js, which owns the day tables
|
|
8
|
+
import { isLeapYear, daysInMonth } from './civil.js';
|
|
9
|
+
|
|
10
|
+
export { isLeapYear };
|
|
11
|
+
|
|
12
|
+
//#region Dates Constants
|
|
13
|
+
export const CONST_TICKS_SECOND = 1000;
|
|
14
|
+
export const CONST_TICKS_HOUR = CONST_TICKS_SECOND * 60 * 60;
|
|
15
|
+
export const CONST_TICKS_DAY = CONST_TICKS_HOUR * 24;
|
|
16
|
+
|
|
17
|
+
export const CONST_TIME_INSERTDATE = '1970-01-01T';
|
|
18
|
+
export const CONST_DATE_APPENDTIME = 'T00:00:00Z';
|
|
19
|
+
|
|
20
|
+
export const CONST_RFC3339_DAYS = Object.freeze(
|
|
21
|
+
[0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
|
|
26
|
+
//#region Dates Compare
|
|
27
|
+
export function isDateType(data) {
|
|
28
|
+
return isObjectOfClass(data, Date);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isDateishType(data) {
|
|
32
|
+
return isDateType(data)
|
|
33
|
+
|| !Number.isNaN(Date.parse(data));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isDateOnlyInRange(year = 0, month = 0, day = 0) {
|
|
37
|
+
return month >= 1
|
|
38
|
+
&& month <= 12
|
|
39
|
+
&& day >= 1
|
|
40
|
+
&& day <= daysInMonth(year, month);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads two ASCII digits at index i as a number, or -1 when either
|
|
45
|
+
* character is not an ASCII digit (out-of-range indexes read as NaN and
|
|
46
|
+
* also yield -1).
|
|
47
|
+
* @param {string} str - The string to read from
|
|
48
|
+
* @param {number} i - The index of the first digit
|
|
49
|
+
* @returns {number} The two-digit value, or -1
|
|
50
|
+
*/
|
|
51
|
+
function getTwoDigits(str, i) {
|
|
52
|
+
const a = str.charCodeAt(i) - 48;
|
|
53
|
+
const b = str.charCodeAt(i + 1) - 48;
|
|
54
|
+
return (a >= 0 && a <= 9 && b >= 0 && b <= 9)
|
|
55
|
+
? a * 10 + b
|
|
56
|
+
: -1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates the region [from, to) of str as an RFC 3339 full-date
|
|
61
|
+
* (YYYY-MM-DD, with an optional trailing z/Z) without allocating.
|
|
62
|
+
* @param {string} str - The string containing the date
|
|
63
|
+
* @param {number} from - Start of the region (inclusive)
|
|
64
|
+
* @param {number} to - End of the region (exclusive)
|
|
65
|
+
* @returns {boolean} True when the region is a valid full-date
|
|
66
|
+
*/
|
|
67
|
+
function isDateOnlyRegion(str, from, to) {
|
|
68
|
+
let end = to;
|
|
69
|
+
const zc = str.charCodeAt(end - 1);
|
|
70
|
+
if (zc === 122 || zc === 90) end--; // optional trailing z/Z
|
|
71
|
+
if (end - from !== 10) return false;
|
|
72
|
+
|
|
73
|
+
const y1 = getTwoDigits(str, from);
|
|
74
|
+
const y2 = getTwoDigits(str, from + 2);
|
|
75
|
+
if (y1 < 0 || y2 < 0) return false;
|
|
76
|
+
if (str.charCodeAt(from + 4) !== 45) return false; // '-'
|
|
77
|
+
const m = getTwoDigits(str, from + 5);
|
|
78
|
+
if (m < 0) return false;
|
|
79
|
+
if (str.charCodeAt(from + 7) !== 45) return false; // '-'
|
|
80
|
+
const d = getTwoDigits(str, from + 8);
|
|
81
|
+
if (d < 0) return false;
|
|
82
|
+
return isDateOnlyInRange(y1 * 100 + y2, m, d);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isDateOnlyRFC3339(str) {
|
|
86
|
+
return isStringType(str)
|
|
87
|
+
&& isDateOnlyRegion(str, 0, str.length);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isTimeOnlyInRange(hrs = 0, min = 0, sec = 0, tzh = 0, tzm = 0, tzSign = 1) {
|
|
91
|
+
// Validate timezone offset range
|
|
92
|
+
if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
|
|
93
|
+
return false;
|
|
94
|
+
|
|
95
|
+
// For leap seconds (sec === 60), we need to check if the corresponding UTC time
|
|
96
|
+
// is 23:59:60. A leap second is only valid at the very end of a UTC day.
|
|
97
|
+
if (sec === 60) {
|
|
98
|
+
// Calculate what the UTC time would be
|
|
99
|
+
// UTC = local - offset (if offset is positive, local is ahead of UTC)
|
|
100
|
+
// So: UTC = local + (tzSign * tzh) hours + (tzSign * tzm) minutes
|
|
101
|
+
let utcHrs = hrs - (tzSign * tzh);
|
|
102
|
+
let utcMin = min - (tzSign * tzm);
|
|
103
|
+
|
|
104
|
+
// Handle wrap-around
|
|
105
|
+
while (utcMin < 0) {
|
|
106
|
+
utcMin += 60;
|
|
107
|
+
utcHrs -= 1;
|
|
108
|
+
}
|
|
109
|
+
while (utcMin >= 60) {
|
|
110
|
+
utcMin -= 60;
|
|
111
|
+
utcHrs += 1;
|
|
112
|
+
}
|
|
113
|
+
while (utcHrs < 0) {
|
|
114
|
+
utcHrs += 24;
|
|
115
|
+
}
|
|
116
|
+
utcHrs = utcHrs % 24;
|
|
117
|
+
|
|
118
|
+
// Leap second is only valid at 23:59:60 UTC
|
|
119
|
+
return utcHrs === 23 && utcMin === 59 && sec === 60;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Normal time validation (sec 0-59)
|
|
123
|
+
return hrs >= 0 && hrs <= 23
|
|
124
|
+
&& min >= 0 && min <= 59
|
|
125
|
+
&& sec >= 0 && sec <= 59;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validates the tail of str starting at index from as an RFC 3339
|
|
130
|
+
* full-time (HH:MM:SS with an optional 1-6 digit fraction and a required
|
|
131
|
+
* z/Z or +HH:MM/-HH:MM offset) without allocating.
|
|
132
|
+
* @param {string} str - The string containing the time
|
|
133
|
+
* @param {number} from - Start of the time (inclusive; runs to the end)
|
|
134
|
+
* @returns {boolean} True when the tail is a valid full-time
|
|
135
|
+
*/
|
|
136
|
+
function isTimeOnlyRegion(str, from) {
|
|
137
|
+
const len = str.length;
|
|
138
|
+
const h = getTwoDigits(str, from);
|
|
139
|
+
if (h < 0 || str.charCodeAt(from + 2) !== 58) return false; // ':'
|
|
140
|
+
const m = getTwoDigits(str, from + 3);
|
|
141
|
+
if (m < 0 || str.charCodeAt(from + 5) !== 58) return false; // ':'
|
|
142
|
+
const s = getTwoDigits(str, from + 6);
|
|
143
|
+
if (s < 0) return false;
|
|
144
|
+
|
|
145
|
+
let i = from + 8;
|
|
146
|
+
if (str.charCodeAt(i) === 46) { // '.' starts a 1-6 digit fraction
|
|
147
|
+
const start = ++i;
|
|
148
|
+
while (i < len) {
|
|
149
|
+
const c = str.charCodeAt(i);
|
|
150
|
+
if (c >= 48 && c <= 57) i++;
|
|
151
|
+
else break;
|
|
152
|
+
}
|
|
153
|
+
const digits = i - start;
|
|
154
|
+
if (digits < 1 || digits > 6) return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const c = str.charCodeAt(i);
|
|
158
|
+
if (c === 122 || c === 90) { // z/Z: UTC, must end the string
|
|
159
|
+
return i + 1 === len
|
|
160
|
+
&& isTimeOnlyInRange(h, m, s, 0, 0, 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (c !== 43 && c !== 45) return false; // '+'/'-'
|
|
164
|
+
if (i + 6 !== len) return false;
|
|
165
|
+
const th = getTwoDigits(str, i + 1);
|
|
166
|
+
if (th < 0 || str.charCodeAt(i + 3) !== 58) return false; // ':'
|
|
167
|
+
const tm = getTwoDigits(str, i + 4);
|
|
168
|
+
if (tm < 0) return false;
|
|
169
|
+
// + means local time is ahead of UTC, so we subtract to get UTC
|
|
170
|
+
// - means local time is behind UTC, so we add to get UTC
|
|
171
|
+
return isTimeOnlyInRange(h, m, s, th, tm, c === 45 ? -1 : 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isTimeOnlyRFC3339(str) {
|
|
175
|
+
return isStringType(str)
|
|
176
|
+
&& isTimeOnlyRegion(str, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether the character code separates the date and time parts of a
|
|
181
|
+
* date-time: 't'/'T' or whitespace (the set matched by the \s class).
|
|
182
|
+
* @param {number} c - The character code
|
|
183
|
+
* @returns {boolean} True for a date/time separator
|
|
184
|
+
*/
|
|
185
|
+
function isDateTimeSeparator(c) {
|
|
186
|
+
if (c === 116 || c === 84 || c === 32) return true; // t T space
|
|
187
|
+
if (c >= 9 && c <= 13) return true; // \t \n \v \f \r
|
|
188
|
+
return c === 0x00A0 || c === 0x1680
|
|
189
|
+
|| (c >= 0x2000 && c <= 0x200A)
|
|
190
|
+
|| c === 0x2028 || c === 0x2029 || c === 0x202F
|
|
191
|
+
|| c === 0x205F || c === 0x3000 || c === 0xFEFF;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function isDateTimeRFC3339(str) {
|
|
195
|
+
// http://tools.ietf.org/html/rfc3339#section-5.6
|
|
196
|
+
if (!isStringType(str)) return false;
|
|
197
|
+
// Exactly one separator splits the full-date from the full-time.
|
|
198
|
+
const len = str.length;
|
|
199
|
+
let sep = -1;
|
|
200
|
+
for (let i = 0; i < len; ++i) {
|
|
201
|
+
if (isDateTimeSeparator(str.charCodeAt(i))) {
|
|
202
|
+
if (sep !== -1) return false;
|
|
203
|
+
sep = i;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return sep !== -1
|
|
207
|
+
&& isDateOnlyRegion(str, 0, sep)
|
|
208
|
+
&& isTimeOnlyRegion(str, sep + 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
|
|
213
|
+
//#region Dates Getters
|
|
214
|
+
export function getDateTypeOfDateOnlyRFC3339(str, def = undefined) {
|
|
215
|
+
return isDateOnlyRFC3339(str)
|
|
216
|
+
? new Date(Date.parse(str))
|
|
217
|
+
: def;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getDateTypeOfTimeOnlyRFC3339(str, def = undefined) {
|
|
221
|
+
return isTimeOnlyRFC3339(str)
|
|
222
|
+
? new Date(Date.parse(CONST_TIME_INSERTDATE + str))
|
|
223
|
+
: def;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function getDateTypeOfDateTimeRFC3339(str, def = undefined) {
|
|
227
|
+
return isDateTimeRFC3339(str)
|
|
228
|
+
? new Date(Date.parse(str))
|
|
229
|
+
: def;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The epoch twins of the three getters above: the same validity check
|
|
233
|
+
// and the same engine parse, but the result stays a number, so a
|
|
234
|
+
// caller comparing against precomputed epoch bounds allocates no Date.
|
|
235
|
+
// A string the grammar accepts but the engine cannot parse (a leap
|
|
236
|
+
// second's `:60`) reads as NaN, exactly as its Date twin reads as an
|
|
237
|
+
// invalid Date - every comparison against it is false.
|
|
238
|
+
|
|
239
|
+
export function getEpochOfDateOnlyRFC3339(str, def = undefined) {
|
|
240
|
+
return isDateOnlyRFC3339(str)
|
|
241
|
+
? Date.parse(str)
|
|
242
|
+
: def;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getEpochOfTimeOnlyRFC3339(str, def = undefined) {
|
|
246
|
+
return isTimeOnlyRFC3339(str)
|
|
247
|
+
? Date.parse(CONST_TIME_INSERTDATE + str)
|
|
248
|
+
: def;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getEpochOfDateTimeRFC3339(str, def = undefined) {
|
|
252
|
+
return isDateTimeRFC3339(str)
|
|
253
|
+
? Date.parse(str)
|
|
254
|
+
: def;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reads the full-date at index from as {year, month, day}. The region
|
|
259
|
+
* must already have passed isDateOnlyRegion.
|
|
260
|
+
* @param {string} str - The string containing the date
|
|
261
|
+
* @param {number} from - Index of the first digit
|
|
262
|
+
* @returns {{ year: number, month: number, day: number }}
|
|
263
|
+
*/
|
|
264
|
+
function readDateParts(str, from) {
|
|
265
|
+
return {
|
|
266
|
+
year: getTwoDigits(str, from) * 100 + getTwoDigits(str, from + 2),
|
|
267
|
+
month: getTwoDigits(str, from + 5),
|
|
268
|
+
day: getTwoDigits(str, from + 8),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Reads the full-time at index from as {hours, minutes, seconds, offset}.
|
|
274
|
+
* `seconds` carries the fraction, `offset` is minutes east of UTC. The
|
|
275
|
+
* region must already have passed isTimeOnlyRegion.
|
|
276
|
+
* @param {string} str - The string containing the time
|
|
277
|
+
* @param {number} from - Index of the first digit
|
|
278
|
+
* @returns {{ hours: number, minutes: number, seconds: number, offset: number }}
|
|
279
|
+
*/
|
|
280
|
+
function readTimeParts(str, from) {
|
|
281
|
+
const hours = getTwoDigits(str, from);
|
|
282
|
+
const minutes = getTwoDigits(str, from + 3);
|
|
283
|
+
let seconds = getTwoDigits(str, from + 6);
|
|
284
|
+
let i = from + 8;
|
|
285
|
+
if (str.charCodeAt(i) === 46) { // '.' fraction
|
|
286
|
+
const start = ++i;
|
|
287
|
+
while (i < str.length) {
|
|
288
|
+
const c = str.charCodeAt(i);
|
|
289
|
+
if (c < 48 || c > 57) break;
|
|
290
|
+
i++;
|
|
291
|
+
}
|
|
292
|
+
seconds += Number(str.slice(start - 1, i)); // '.ddd' as a fraction
|
|
293
|
+
}
|
|
294
|
+
const c = str.charCodeAt(i);
|
|
295
|
+
let offset = 0;
|
|
296
|
+
if (c === 43 || c === 45) { // '+' | '-'
|
|
297
|
+
const magnitude = getTwoDigits(str, i + 1) * 60 + getTwoDigits(str, i + 4);
|
|
298
|
+
offset = c === 45 ? -magnitude : magnitude;
|
|
299
|
+
}
|
|
300
|
+
return { hours, minutes, seconds, offset };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Decompose an RFC 3339 date, time, or date-time string into its lexical
|
|
305
|
+
* components, without allocating a `Date` and without shifting anything
|
|
306
|
+
* to UTC — the components are the ones the string spells out, which is
|
|
307
|
+
* what a query grouping by year or month asks for.
|
|
308
|
+
*
|
|
309
|
+
* A missing half reads as -1: a full-date has no `hours`, a full-time no
|
|
310
|
+
* `year`. `offset` is minutes east of UTC, `null` only for a bare
|
|
311
|
+
* full-date (which RFC 3339 leaves offset-less).
|
|
312
|
+
*
|
|
313
|
+
* @param {any} str - The value to decompose
|
|
314
|
+
* @returns {{ year: number, month: number, day: number, hours: number,
|
|
315
|
+
* minutes: number, seconds: number, offset: number | null } | null}
|
|
316
|
+
* the components, or null when str is not an RFC 3339 value
|
|
317
|
+
* @example
|
|
318
|
+
* parseRFC3339Parts('2026-07-27T14:30:05.5+02:00');
|
|
319
|
+
* // { year: 2026, month: 7, day: 27, hours: 14, minutes: 30,
|
|
320
|
+
* // seconds: 5.5, offset: 120 }
|
|
321
|
+
*/
|
|
322
|
+
export function parseRFC3339Parts(str) {
|
|
323
|
+
if (!isStringType(str))
|
|
324
|
+
return null;
|
|
325
|
+
if (isDateTimeRFC3339(str)) {
|
|
326
|
+
let sep = -1;
|
|
327
|
+
for (let i = 0; i < str.length; ++i) {
|
|
328
|
+
if (isDateTimeSeparator(str.charCodeAt(i))) {
|
|
329
|
+
sep = i;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { ...readDateParts(str, 0), ...readTimeParts(str, sep + 1) };
|
|
334
|
+
}
|
|
335
|
+
if (isDateOnlyRFC3339(str)) {
|
|
336
|
+
const zc = str.charCodeAt(str.length - 1);
|
|
337
|
+
const zulu = zc === 122 || zc === 90;
|
|
338
|
+
return {
|
|
339
|
+
...readDateParts(str, 0),
|
|
340
|
+
hours: -1, minutes: -1, seconds: -1,
|
|
341
|
+
offset: zulu ? 0 : null,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (isTimeOnlyRFC3339(str)) {
|
|
345
|
+
return { year: -1, month: -1, day: -1, ...readTimeParts(str, 0) };
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Milliseconds since 1970-01-01T00:00:00Z for RFC 3339 components that
|
|
352
|
+
* carry a date. Components without a date (a full-time) return NaN, as
|
|
353
|
+
* do out-of-range instants.
|
|
354
|
+
*
|
|
355
|
+
* A bare full-date has no offset and is read as UTC midnight. Note JS
|
|
356
|
+
* has no leap seconds: a `:60` second rolls into the following minute.
|
|
357
|
+
*
|
|
358
|
+
* @param {{ year: number, month: number, day: number, hours: number,
|
|
359
|
+
* minutes: number, seconds: number, offset: number | null }} parts -
|
|
360
|
+
* components from {@link parseRFC3339Parts}
|
|
361
|
+
* @returns {number} milliseconds since the epoch, or NaN
|
|
362
|
+
*/
|
|
363
|
+
export function epochOfRFC3339Parts(parts) {
|
|
364
|
+
if (parts.year < 0)
|
|
365
|
+
return NaN;
|
|
366
|
+
const seconds = parts.seconds < 0 ? 0 : parts.seconds;
|
|
367
|
+
const whole = Math.floor(seconds);
|
|
368
|
+
const ms = Date.UTC(
|
|
369
|
+
parts.year, parts.month - 1, parts.day,
|
|
370
|
+
parts.hours < 0 ? 0 : parts.hours,
|
|
371
|
+
parts.minutes < 0 ? 0 : parts.minutes,
|
|
372
|
+
whole, Math.round((seconds - whole) * 1000));
|
|
373
|
+
if (ms !== ms)
|
|
374
|
+
return NaN;
|
|
375
|
+
// Date.UTC maps years 0-99 into the 1900s; restore the real year
|
|
376
|
+
const utc = new Date(ms);
|
|
377
|
+
if (parts.year >= 0 && parts.year < 100)
|
|
378
|
+
utc.setUTCFullYear(parts.year);
|
|
379
|
+
return utc.getTime() - (parts.offset === null ? 0 : parts.offset) * 60000;
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
|
|
383
|
+
//#region Duration Validation (RFC 3339)
|
|
384
|
+
// Duration format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W
|
|
385
|
+
// Examples: P1Y2M3DT4H5M6S, P1W, PT1H, P1Y
|
|
386
|
+
// https://tools.ietf.org/html/rfc3339#appendix-A
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Validates a duration string per RFC 3339.
|
|
390
|
+
* Duration format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W
|
|
391
|
+
*
|
|
392
|
+
* @param {string} str - The duration string to validate
|
|
393
|
+
* @returns {boolean} - True if the string is a valid duration
|
|
394
|
+
* @example
|
|
395
|
+
* isValidDuration('P1Y2M3DT4H5M6S'); // true (1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds)
|
|
396
|
+
* isValidDuration('P1W'); // true (1 week)
|
|
397
|
+
* isValidDuration('PT1H'); // true (1 hour)
|
|
398
|
+
* isValidDuration('P1Y'); // true (1 year)
|
|
399
|
+
* isValidDuration('P'); // false (empty duration)
|
|
400
|
+
* isValidDuration('1Y'); // false (missing P)
|
|
401
|
+
*/
|
|
402
|
+
export function isValidDuration(str) {
|
|
403
|
+
if (!isStringType(str))
|
|
404
|
+
return false;
|
|
405
|
+
|
|
406
|
+
// Must start with P
|
|
407
|
+
if (!str.startsWith('P'))
|
|
408
|
+
return false;
|
|
409
|
+
|
|
410
|
+
// Cannot be just "P"
|
|
411
|
+
if (str.length < 2)
|
|
412
|
+
return false;
|
|
413
|
+
|
|
414
|
+
// Parse and validate components
|
|
415
|
+
// Remove the 'P' prefix
|
|
416
|
+
const rest = str.slice(1);
|
|
417
|
+
|
|
418
|
+
// Check for week format: P[n]W (cannot be combined with other components)
|
|
419
|
+
if (rest.endsWith('W')) {
|
|
420
|
+
const weekPart = rest.slice(0, -1);
|
|
421
|
+
return weekPart.length > 0 && /^\d+$/.test(weekPart);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Split into date and time parts
|
|
425
|
+
const tIndex = rest.indexOf('T');
|
|
426
|
+
const datePart = tIndex >= 0 ? rest.slice(0, tIndex) : rest;
|
|
427
|
+
const timePart = tIndex >= 0 ? rest.slice(tIndex + 1) : '';
|
|
428
|
+
|
|
429
|
+
// Must have at least one component
|
|
430
|
+
if (!datePart && !timePart)
|
|
431
|
+
return false;
|
|
432
|
+
|
|
433
|
+
// If there's a T, there must be at least one time component
|
|
434
|
+
if (tIndex >= 0 && !timePart)
|
|
435
|
+
return false;
|
|
436
|
+
|
|
437
|
+
// Validate date part components (Y, M, D)
|
|
438
|
+
if (datePart) {
|
|
439
|
+
// Must match pattern: optional number+Y, optional number+M, optional number+D
|
|
440
|
+
// in that order, at least one must be present
|
|
441
|
+
const dateRegex = /^(\d+Y)?(\d+M)?(\d+D)?$/;
|
|
442
|
+
if (!dateRegex.test(datePart))
|
|
443
|
+
return false;
|
|
444
|
+
// Must have at least one component
|
|
445
|
+
if (!/\d+[YMD]/.test(datePart))
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Validate time part components (H, M, S)
|
|
450
|
+
if (timePart) {
|
|
451
|
+
const timeRegex = /^(\d+H)?(\d+M)?(\d+(?:\.\d+)?S)?$/;
|
|
452
|
+
if (!timeRegex.test(timePart))
|
|
453
|
+
return false;
|
|
454
|
+
// Must have at least one component
|
|
455
|
+
if (!/\d+[HMS]/.test(timePart))
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
|
|
463
|
+
//#region ISO Date-Time and ISO Time (with optional timezone)
|
|
464
|
+
// ISO 8601 date-time with optional timezone (like 2024-01-15T12:30:00)
|
|
465
|
+
// ISO 8601 time with optional timezone (like 12:30:00)
|
|
466
|
+
|
|
467
|
+
// Regex for iso-date-time: allows with or without timezone
|
|
468
|
+
const CONST_ISO_REGEX_DATETIME = /^(\d{4})-([0-1]\d)-([0-3]\d)[T\s](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:(Z)|([+-])(\d{2}):(\d{2}))?$/i;
|
|
469
|
+
|
|
470
|
+
// Regex for iso-time: allows with or without timezone
|
|
471
|
+
const CONST_ISO_REGEX_TIME = /^(\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:(Z)|([+-])(\d{2}):(\d{2}))?$/i;
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Validates an ISO 8601 date-time string with optional timezone.
|
|
475
|
+
* Unlike RFC 3339, the timezone is optional.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} str - The date-time string to validate
|
|
478
|
+
* @returns {boolean} - True if the string is a valid ISO date-time
|
|
479
|
+
* @example
|
|
480
|
+
* isValidISODateTime('2024-01-15T12:30:00Z'); // true
|
|
481
|
+
* isValidISODateTime('2024-01-15T12:30:00+01:00'); // true
|
|
482
|
+
* isValidISODateTime('2024-01-15T12:30:00'); // true (no timezone)
|
|
483
|
+
* isValidISODateTime('2024-01-15 12:30:00'); // true (space separator)
|
|
484
|
+
* isValidISODateTime('2024-13-15T12:30:00'); // false (invalid month)
|
|
485
|
+
*/
|
|
486
|
+
export function isValidISODateTime(str) {
|
|
487
|
+
if (!isStringType(str))
|
|
488
|
+
return false;
|
|
489
|
+
|
|
490
|
+
const r = str.match(CONST_ISO_REGEX_DATETIME);
|
|
491
|
+
if (r == null)
|
|
492
|
+
return false;
|
|
493
|
+
|
|
494
|
+
const y = parseInt(r[1], 10) | 0;
|
|
495
|
+
const m = parseInt(r[2], 10) | 0;
|
|
496
|
+
const d = parseInt(r[3], 10) | 0;
|
|
497
|
+
const h = parseInt(r[4], 10) | 0;
|
|
498
|
+
const min = parseInt(r[5], 10) | 0;
|
|
499
|
+
const s = parseInt(r[6], 10) | 0;
|
|
500
|
+
|
|
501
|
+
// Validate date portion
|
|
502
|
+
if (!isDateOnlyInRange(y, m, d))
|
|
503
|
+
return false;
|
|
504
|
+
|
|
505
|
+
// Validate time portion (no leap seconds for ISO date-time without explicit timezone)
|
|
506
|
+
if (h < 0 || h > 23 || min < 0 || min > 59 || s < 0 || s > 59)
|
|
507
|
+
return false;
|
|
508
|
+
|
|
509
|
+
// Validate timezone if present
|
|
510
|
+
if (r[9] != null) {
|
|
511
|
+
const tzh = parseInt(r[10], 10) | 0;
|
|
512
|
+
const tzm = parseInt(r[11], 10) | 0;
|
|
513
|
+
if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Validates an ISO 8601 time string with optional timezone.
|
|
522
|
+
* Unlike RFC 3339, the timezone is optional.
|
|
523
|
+
*
|
|
524
|
+
* @param {string} str - The time string to validate
|
|
525
|
+
* @returns {boolean} - True if the string is a valid ISO time
|
|
526
|
+
* @example
|
|
527
|
+
* isValidISOTime('12:30:00Z'); // true
|
|
528
|
+
* isValidISOTime('12:30:00+01:00'); // true
|
|
529
|
+
* isValidISOTime('12:30:00'); // true (no timezone)
|
|
530
|
+
* isValidISOTime('25:00:00'); // false (invalid hour)
|
|
531
|
+
*/
|
|
532
|
+
export function isValidISOTime(str) {
|
|
533
|
+
if (!isStringType(str))
|
|
534
|
+
return false;
|
|
535
|
+
|
|
536
|
+
const r = str.match(CONST_ISO_REGEX_TIME);
|
|
537
|
+
if (r == null)
|
|
538
|
+
return false;
|
|
539
|
+
|
|
540
|
+
const h = parseInt(r[1], 10) | 0;
|
|
541
|
+
const m = parseInt(r[2], 10) | 0;
|
|
542
|
+
const s = parseInt(r[3], 10) | 0;
|
|
543
|
+
|
|
544
|
+
// Validate time portion (no leap seconds for ISO time without explicit timezone)
|
|
545
|
+
if (h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59)
|
|
546
|
+
return false;
|
|
547
|
+
|
|
548
|
+
// Validate timezone if present
|
|
549
|
+
if (r[7] != null) {
|
|
550
|
+
const tzh = parseInt(r[8], 10) | 0;
|
|
551
|
+
const tzm = parseInt(r[9], 10) | 0;
|
|
552
|
+
if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Parses an ISO 8601 date-time string with optional timezone.
|
|
561
|
+
* Returns a Date object if valid, otherwise undefined.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} str - The date-time string to parse
|
|
564
|
+
* @param {any} [def=undefined] - Default value to return if invalid
|
|
565
|
+
* @returns {Date|undefined} - The parsed Date or default value
|
|
566
|
+
*/
|
|
567
|
+
export function getDateTypeOfISODateTime(str, def = undefined) {
|
|
568
|
+
if (!isValidISODateTime(str))
|
|
569
|
+
return def;
|
|
570
|
+
|
|
571
|
+
// If no timezone specified, treat as local time by appending Z
|
|
572
|
+
// (ISO 8601 without timezone is local time, but for consistency we treat as UTC)
|
|
573
|
+
if (!/[Z+-]\d{2}:\d{2}$/i.test(str) && !str.endsWith('Z')) {
|
|
574
|
+
// Try parsing as-is (Date.parse handles both formats)
|
|
575
|
+
const date = new Date(Date.parse(str.replace(' ', 'T')));
|
|
576
|
+
return isNaN(date.getTime()) ? def : date;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return new Date(Date.parse(str));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Parses an ISO 8601 time string with optional timezone.
|
|
584
|
+
* Returns a Date object (with 1970-01-01 as date) if valid, otherwise undefined.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} str - The time string to parse
|
|
587
|
+
* @param {any} [def=undefined] - Default value to return if invalid
|
|
588
|
+
* @returns {Date|undefined} - The parsed Date or default value
|
|
589
|
+
*/
|
|
590
|
+
export function getDateTypeOfISOTime(str, def = undefined) {
|
|
591
|
+
if (!isValidISOTime(str))
|
|
592
|
+
return def;
|
|
593
|
+
|
|
594
|
+
// Prepend a dummy date for parsing
|
|
595
|
+
const dateTimeStr = CONST_TIME_INSERTDATE + str;
|
|
596
|
+
const date = new Date(Date.parse(dateTimeStr));
|
|
597
|
+
return isNaN(date.getTime()) ? def : date;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The epoch twins of the two ISO getters, mirroring their branch
|
|
601
|
+
// structure exactly (including which branches fold a failed parse into
|
|
602
|
+
// `def` and which surface it as NaN) so a comparison against either
|
|
603
|
+
// twin's result answers the same.
|
|
604
|
+
|
|
605
|
+
export function getEpochOfISODateTime(str, def = undefined) {
|
|
606
|
+
if (!isValidISODateTime(str))
|
|
607
|
+
return def;
|
|
608
|
+
if (!/[Z+-]\d{2}:\d{2}$/i.test(str) && !str.endsWith('Z')) {
|
|
609
|
+
const ms = Date.parse(str.replace(' ', 'T'));
|
|
610
|
+
return ms !== ms ? def : ms;
|
|
611
|
+
}
|
|
612
|
+
return Date.parse(str);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function getEpochOfISOTime(str, def = undefined) {
|
|
616
|
+
if (!isValidISOTime(str))
|
|
617
|
+
return def;
|
|
618
|
+
const ms = Date.parse(CONST_TIME_INSERTDATE + str);
|
|
619
|
+
return ms !== ms ? def : ms;
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|