@jarenjs/core 0.46.4 → 0.49.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 +104 -0
- package/README.md +46 -2
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/docs/DATES.md +77 -1
- package/docs/SERIES.md +342 -0
- package/package.json +9 -1
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
- package/src/string.js +1 -1
package/src/dates/civil.js
CHANGED
|
@@ -205,7 +205,26 @@ export function fixedUnitMs(unit) {
|
|
|
205
205
|
|
|
206
206
|
//#endregion
|
|
207
207
|
|
|
208
|
-
//#region
|
|
208
|
+
//#region lexical precision
|
|
209
|
+
|
|
210
|
+
// A parts record spells one of three lexical families (rfc3339.js): a
|
|
211
|
+
// full-date with no clock, a full-time with no calendar, or both. An
|
|
212
|
+
// operation that reads a half the value has not got has no answer, and
|
|
213
|
+
// the alternative to refusing is to guess one - which is how `end-of`
|
|
214
|
+
// hour on a full-date used to answer the day BEFORE it, and how adding
|
|
215
|
+
// a day to a full-time used to be a silent no-op.
|
|
216
|
+
//
|
|
217
|
+
// Which half an operation reads follows from its unit:
|
|
218
|
+
//
|
|
219
|
+
// year, quarter, month the calendar; a full-time has none
|
|
220
|
+
// week, day the calendar, plus the clock when the
|
|
221
|
+
// amount carries a fraction of a day
|
|
222
|
+
// hour .. millisecond the clock; a full-date has none
|
|
223
|
+
//
|
|
224
|
+
// `day` and coarser truncate to a boundary a calendar has, so
|
|
225
|
+
// `start-of` day of a full-time is midnight and needs no date. A
|
|
226
|
+
// sub-day truncation names a boundary INSIDE a day, which a value with
|
|
227
|
+
// no clock does not have.
|
|
209
228
|
|
|
210
229
|
// A parts record with no time half reads -1 for hours/minutes/seconds;
|
|
211
230
|
// arithmetic treats that as midnight but must not *introduce* a time, so
|
|
@@ -214,6 +233,48 @@ function hasTime(parts) {
|
|
|
214
233
|
return parts.hours >= 0;
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
/** Whether a parts record carries a calendar half. */
|
|
237
|
+
function hasDate(parts) {
|
|
238
|
+
return parts.year >= 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function requireDate(parts, unit) {
|
|
242
|
+
if (!hasDate(parts))
|
|
243
|
+
throw new TypeError(`'${unit}' needs a date half, and this value has none`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function requireTime(parts, unit) {
|
|
247
|
+
if (!hasTime(parts))
|
|
248
|
+
throw new TypeError(`'${unit}' needs a time half, and this value has none`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Split a millisecond offset into a day into clock fields. `seconds`
|
|
253
|
+
* carries the fraction, as everywhere else in this module.
|
|
254
|
+
* @param {number} rest - milliseconds since midnight, [0, 86400000)
|
|
255
|
+
* @returns {{ hours: number, minutes: number, seconds: number }}
|
|
256
|
+
*/
|
|
257
|
+
function clockFromDayMs(rest) {
|
|
258
|
+
const hours = Math.floor(rest / 3600000);
|
|
259
|
+
rest -= hours * 3600000;
|
|
260
|
+
const minutes = Math.floor(rest / 60000);
|
|
261
|
+
rest -= minutes * 60000;
|
|
262
|
+
return { hours, minutes, seconds: rest / 1000 };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Milliseconds since midnight for a parts record that carries a clock. */
|
|
266
|
+
function dayMsOf(parts) {
|
|
267
|
+
return parts.hours * 3600000 + parts.minutes * 60000 + Math.round(parts.seconds * 1000);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//#endregion
|
|
271
|
+
|
|
272
|
+
//#region arithmetic over parts
|
|
273
|
+
|
|
274
|
+
// how many months each calendar unit is worth; a fraction of one of
|
|
275
|
+
// these is only a quantity when it converts to a whole month
|
|
276
|
+
const CALENDAR_MONTHS = Object.freeze({ year: 12, quarter: 3, month: 1 });
|
|
277
|
+
|
|
217
278
|
function withTime(out, parts, hours, minutes, seconds) {
|
|
218
279
|
if (hasTime(parts)) {
|
|
219
280
|
out.hours = hours;
|
|
@@ -233,68 +294,91 @@ function withTime(out, parts, hours, minutes, seconds) {
|
|
|
233
294
|
/**
|
|
234
295
|
* Add a signed amount of calendar units to a parts record, returning a
|
|
235
296
|
* new one. The input is never mutated and its lexical shape is kept: a
|
|
236
|
-
* full-date stays a full-date,
|
|
237
|
-
* rather than being normalized.
|
|
297
|
+
* full-date stays a full-date, a full-time stays a full-time, and a
|
|
298
|
+
* value keeps its own UTC offset rather than being normalized.
|
|
238
299
|
*
|
|
239
300
|
* Month and year arithmetic **clamps** to the end of the target month —
|
|
240
301
|
* 2026-01-31 plus one month is 2026-02-28 — which is the rule every
|
|
241
302
|
* mainstream date library uses, because the alternative (overflowing
|
|
242
303
|
* into March) makes `add(1, 'month')` non-monotonic.
|
|
243
304
|
*
|
|
305
|
+
* A fraction is a quantity only where the unit has an exact conversion.
|
|
306
|
+
* A fixed-width fraction becomes whole milliseconds, so `1.5 day` is
|
|
307
|
+
* thirty-six hours; a calendar fraction is refused unless it lands on a
|
|
308
|
+
* whole month, because half of January is not a length. The clock those
|
|
309
|
+
* milliseconds land on has to exist: a full-date can be moved by whole
|
|
310
|
+
* days but not by half of one.
|
|
311
|
+
*
|
|
244
312
|
* @param {object} parts - a parts record from `parseRFC3339Parts`
|
|
245
|
-
* @param {number} amount - signed count,
|
|
246
|
-
*
|
|
313
|
+
* @param {number} amount - signed count, fractional only where the unit
|
|
314
|
+
* converts exactly and the value has the half to carry it
|
|
247
315
|
* @param {string} unit - a {@link DATE_UNITS} member
|
|
248
316
|
* @returns {object} a new parts record
|
|
317
|
+
* @throws {TypeError} for an unknown unit, a fraction with no exact
|
|
318
|
+
* conversion, or a value missing a half the operation reads
|
|
249
319
|
*/
|
|
250
320
|
export function addToParts(parts, amount, unit) {
|
|
251
321
|
if (amount === 0)
|
|
252
322
|
return { ...parts };
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
323
|
+
const perMonth = CALENDAR_MONTHS[unit];
|
|
324
|
+
if (perMonth !== undefined) {
|
|
325
|
+
const months = amount * perMonth;
|
|
326
|
+
if (!Number.isInteger(months))
|
|
327
|
+
throw new TypeError(`a fraction of a '${unit}' has no exact calendar length`);
|
|
328
|
+
requireDate(parts, unit);
|
|
329
|
+
const total = (parts.year * 12 + (parts.month - 1)) + months;
|
|
256
330
|
const year = Math.floor(total / 12);
|
|
257
331
|
const month = total - year * 12 + 1;
|
|
258
332
|
const day = Math.min(parts.day, daysInMonth(year, month)); // clamp
|
|
259
333
|
return withTime({ year, month, day }, parts, parts.hours, parts.minutes, parts.seconds);
|
|
260
334
|
}
|
|
335
|
+
const width = FIXED_MS[unit];
|
|
336
|
+
if (width === undefined)
|
|
337
|
+
throw new TypeError(`'${unit}' is not a calendar unit`);
|
|
261
338
|
if (unit === 'day' || unit === 'week') {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
339
|
+
requireDate(parts, unit);
|
|
340
|
+
if (Number.isInteger(amount)) {
|
|
341
|
+
// whole days move the day number rather than a span of
|
|
342
|
+
// milliseconds: that keeps a full-date a full-date, and stays
|
|
343
|
+
// exact past the range milliseconds can count
|
|
344
|
+
const z = daysFromCivil(parts.year, parts.month, parts.day)
|
|
345
|
+
+ amount * (unit === 'week' ? 7 : 1);
|
|
346
|
+
return withTime(civilFromDays(z), parts, parts.hours, parts.minutes, parts.seconds);
|
|
347
|
+
}
|
|
266
348
|
}
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
349
|
+
// a span of milliseconds: the sub-day units, and the fraction of a day
|
|
350
|
+
// or week that only a clock can hold
|
|
351
|
+
requireTime(parts, unit);
|
|
352
|
+
const ms = Math.round(amount * width);
|
|
353
|
+
if (!hasDate(parts)) {
|
|
354
|
+
// no calendar to carry into, so the clock wraps inside its own day
|
|
355
|
+
const dayMs = ((dayMsOf(parts) + ms) % 86400000 + 86400000) % 86400000;
|
|
356
|
+
return { year: -1, month: -1, day: -1, ...clockFromDayMs(dayMs), offset: parts.offset };
|
|
357
|
+
}
|
|
358
|
+
// carry through the day number, so a time crossing midnight moves the
|
|
359
|
+
// date with it
|
|
360
|
+
const moved = daysFromCivil(parts.year, parts.month, parts.day) * 86400000
|
|
361
|
+
+ dayMsOf(parts) + ms;
|
|
277
362
|
const dz = Math.floor(moved / 86400000);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
rest -= hours * 3600000;
|
|
282
|
-
const minutes = Math.floor(rest / 60000);
|
|
283
|
-
rest -= minutes * 60000;
|
|
284
|
-
// a value with no time half acquires one as soon as a sub-day unit
|
|
285
|
-
// moves it - there is nowhere else for the result to live
|
|
286
|
-
const out = { ...civil, hours, minutes, seconds: rest / 1000, offset: parts.offset };
|
|
287
|
-
if (out.offset === null && !hasTime(parts))
|
|
288
|
-
out.offset = 0; // a bare full-date is UTC midnight by rfc3339.js's rule
|
|
289
|
-
return out;
|
|
363
|
+
return {
|
|
364
|
+
...civilFromDays(dz), ...clockFromDayMs(moved - dz * 86400000), offset: parts.offset,
|
|
365
|
+
};
|
|
290
366
|
}
|
|
291
367
|
|
|
292
368
|
/**
|
|
293
369
|
* Truncate a parts record to the start of a calendar unit, returning a
|
|
294
370
|
* new one. `week` starts on Monday (ISO 8601).
|
|
371
|
+
*
|
|
372
|
+
* `day` and coarser name a boundary the calendar has, so the start of
|
|
373
|
+
* the day of a full-time is midnight. A sub-day unit names a boundary
|
|
374
|
+
* inside a day, which a value with no clock does not have, and is
|
|
375
|
+
* refused rather than answered from a clock that is not there.
|
|
376
|
+
*
|
|
295
377
|
* @param {object} parts - a parts record
|
|
296
378
|
* @param {string} unit - a {@link DATE_UNITS} member
|
|
297
379
|
* @returns {object} a new parts record
|
|
380
|
+
* @throws {TypeError} for an unknown unit, or a value missing a half
|
|
381
|
+
* the truncation reads
|
|
298
382
|
*/
|
|
299
383
|
export function startOfParts(parts, unit) {
|
|
300
384
|
let { year, month, day } = parts;
|
|
@@ -303,12 +387,14 @@ export function startOfParts(parts, unit) {
|
|
|
303
387
|
let seconds = parts.seconds;
|
|
304
388
|
switch (unit) {
|
|
305
389
|
case 'year':
|
|
306
|
-
month = 1; day = 1; break;
|
|
390
|
+
requireDate(parts, unit); month = 1; day = 1; break;
|
|
307
391
|
case 'quarter':
|
|
392
|
+
requireDate(parts, unit);
|
|
308
393
|
month = (quarterOfYear(parts) - 1) * 3 + 1; day = 1; break;
|
|
309
394
|
case 'month':
|
|
310
|
-
day = 1; break;
|
|
395
|
+
requireDate(parts, unit); day = 1; break;
|
|
311
396
|
case 'week': {
|
|
397
|
+
requireDate(parts, unit);
|
|
312
398
|
const z = daysFromCivil(year, month, day);
|
|
313
399
|
({ year, month, day } = civilFromDays(z - (isoWeekdayFromDays(z) - 1)));
|
|
314
400
|
break;
|
|
@@ -316,12 +402,13 @@ export function startOfParts(parts, unit) {
|
|
|
316
402
|
case 'day':
|
|
317
403
|
break;
|
|
318
404
|
case 'hour':
|
|
319
|
-
minutes = 0; seconds = 0; break;
|
|
405
|
+
requireTime(parts, unit); minutes = 0; seconds = 0; break;
|
|
320
406
|
case 'minute':
|
|
321
|
-
seconds = 0; break;
|
|
407
|
+
requireTime(parts, unit); seconds = 0; break;
|
|
322
408
|
case 'second':
|
|
323
|
-
seconds = Math.trunc(seconds); break;
|
|
409
|
+
requireTime(parts, unit); seconds = Math.trunc(seconds); break;
|
|
324
410
|
case 'millisecond':
|
|
411
|
+
requireTime(parts, unit);
|
|
325
412
|
return { ...parts };
|
|
326
413
|
default:
|
|
327
414
|
throw new TypeError(`'${unit}' is not a calendar unit`);
|
|
@@ -339,16 +426,24 @@ export function startOfParts(parts, unit) {
|
|
|
339
426
|
* The last representable instant inside a calendar unit: the start of
|
|
340
427
|
* the next unit less one millisecond. A value with no time half is
|
|
341
428
|
* truncated to the unit's last *day* instead, so a full-date stays a
|
|
342
|
-
* full-date.
|
|
429
|
+
* full-date, and it refuses the same sub-day units `startOfParts` does.
|
|
343
430
|
* @param {object} parts - a parts record
|
|
344
431
|
* @param {string} unit - a {@link DATE_UNITS} member
|
|
345
432
|
* @returns {object} a new parts record
|
|
433
|
+
* @throws {TypeError} for an unknown unit, or a value missing a half
|
|
434
|
+
* the truncation reads
|
|
346
435
|
*/
|
|
347
436
|
export function endOfParts(parts, unit) {
|
|
348
437
|
const start = startOfParts(parts, unit);
|
|
349
438
|
if (unit === 'millisecond')
|
|
350
439
|
return start;
|
|
351
|
-
|
|
440
|
+
if (!hasDate(parts)) {
|
|
441
|
+
// a clock has no next day to step back from, so the end is the
|
|
442
|
+
// unit's own width less a millisecond, inside the day it lives in
|
|
443
|
+
const rest = dayMsOf(start) + FIXED_MS[unit] - 1;
|
|
444
|
+
return { year: -1, month: -1, day: -1, ...clockFromDayMs(rest), offset: parts.offset };
|
|
445
|
+
}
|
|
446
|
+
const next = addToParts(start, 1, unit);
|
|
352
447
|
if (!hasTime(parts)) {
|
|
353
448
|
// date-only: step back one whole day rather than one millisecond
|
|
354
449
|
const z = daysFromCivil(next.year, next.month, next.day) - 1;
|
package/src/dates/index.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// rfc3339.js validation, lexical decomposition, epoch conversion
|
|
17
17
|
// civil.js proleptic Gregorian arithmetic over integers
|
|
18
18
|
// format.js LDML pattern -> compiled formatter
|
|
19
|
+
// parse.js LDML pattern -> compiled strict parser
|
|
19
20
|
// duration.js ISO 8601 duration decomposition and conversion
|
|
21
|
+
// ticks.js the time-axis step ladder and its boundaries
|
|
20
22
|
//
|
|
21
23
|
// Locale-dependent presentation (month and weekday names, relative
|
|
22
24
|
// phrasing) is NOT here: it belongs to @jarenjs/locales, so this module
|
|
@@ -25,6 +27,8 @@
|
|
|
25
27
|
export * from './rfc3339.js';
|
|
26
28
|
export * from './civil.js';
|
|
27
29
|
export * from './format.js';
|
|
30
|
+
export * from './parse.js';
|
|
28
31
|
export * from './duration.js';
|
|
32
|
+
export * from './ticks.js';
|
|
29
33
|
|
|
30
34
|
//#endregion
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region Date parsing
|
|
4
|
+
// The inverse of `compileDateFormat`: a pattern is scanned once into a
|
|
5
|
+
// chain of *readers*, and calling the result only walks the chain. The
|
|
6
|
+
// shape is deliberately the same two-stage compiler the formatter uses,
|
|
7
|
+
// for the same reason — a `parse(text, pattern)` helper re-scans its
|
|
8
|
+
// pattern on every row, and a Gantt chart with ten thousand tasks reads
|
|
9
|
+
// ten thousand dates through one pattern.
|
|
10
|
+
//
|
|
11
|
+
// No regular expression is built here, per parse or per compile: every
|
|
12
|
+
// reader is a closure over integers, and digits are read by char code.
|
|
13
|
+
//
|
|
14
|
+
// The vocabulary is the formatter's (Unicode LDML, `yyyy-MM-dd`), minus
|
|
15
|
+
// the tokens that are *derived* from a date rather than part of one.
|
|
16
|
+
// `EEEE` (weekday name), `DDD` (day of year), `ww` (ISO week) and `Q`
|
|
17
|
+
// (quarter) all format, and none of them is an independent field, so
|
|
18
|
+
// asking this compiler for one is an error rather than a token that
|
|
19
|
+
// silently reads nothing.
|
|
20
|
+
//
|
|
21
|
+
// Consumers whose patterns are NOT LDML (Mermaid's Gantt directives use
|
|
22
|
+
// moment's grammar, a d3 axis uses strftime) adapt their tokens to this
|
|
23
|
+
// vocabulary; they do not hand their own spelling to this compiler.
|
|
24
|
+
|
|
25
|
+
import { daysInMonth } from './civil.js';
|
|
26
|
+
|
|
27
|
+
// `DateNames` is format.js's typedef and stays there: re-declaring it
|
|
28
|
+
// here would export the same name from two modules of one barrel, which
|
|
29
|
+
// is an ambiguous re-export in the generated declarations — a defect
|
|
30
|
+
// only the packed-consumer gate sees.
|
|
31
|
+
|
|
32
|
+
// char codes
|
|
33
|
+
const C_0 = 48;
|
|
34
|
+
const C_9 = 57;
|
|
35
|
+
const C_QUOTE = 39;
|
|
36
|
+
const C_PLUS = 43;
|
|
37
|
+
const C_MINUS = 45;
|
|
38
|
+
const C_COLON = 58;
|
|
39
|
+
const C_Z_UPPER = 90;
|
|
40
|
+
const C_Z_LOWER = 122;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read between `min` and `max` ASCII digits at `i`.
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @param {number} i
|
|
46
|
+
* @param {number} min
|
|
47
|
+
* @param {number} max
|
|
48
|
+
* @returns {[number, number]} `[value, next]`, or `[-1, -1]` on failure
|
|
49
|
+
*/
|
|
50
|
+
function readDigits(text, i, min, max) {
|
|
51
|
+
let value = 0;
|
|
52
|
+
let n = 0;
|
|
53
|
+
while (n < max) {
|
|
54
|
+
const c = text.charCodeAt(i + n);
|
|
55
|
+
// past the end `charCodeAt` is NaN, and NaN fails BOTH comparisons —
|
|
56
|
+
// so this has to be written as the positive test or the loop reads
|
|
57
|
+
// six characters of nothing and returns NaN
|
|
58
|
+
if (!(c >= C_0 && c <= C_9)) break;
|
|
59
|
+
value = value * 10 + (c - C_0);
|
|
60
|
+
n++;
|
|
61
|
+
}
|
|
62
|
+
if (n < min) return [-1, -1];
|
|
63
|
+
return [value, i + n];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A field reader: `(text, i, out) => next index`, or -1.
|
|
68
|
+
* @typedef {(text: string, i: number, out: any) => number} Reader
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a reader for a numeric field.
|
|
73
|
+
* @param {string} field - the `out` member to assign
|
|
74
|
+
* @param {number} min - minimum digits
|
|
75
|
+
* @param {number} max - maximum digits
|
|
76
|
+
* @returns {Reader}
|
|
77
|
+
*/
|
|
78
|
+
function numeric(field, min, max) {
|
|
79
|
+
return (text, i, out) => {
|
|
80
|
+
const [value, next] = readDigits(text, i, min, max);
|
|
81
|
+
if (next < 0) return -1;
|
|
82
|
+
out[field] = value;
|
|
83
|
+
return next;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The two-digit-year pivot: 00–68 is 2000–2068, 69–99 is 1969–1999. */
|
|
88
|
+
const YY_PIVOT = 69;
|
|
89
|
+
|
|
90
|
+
/** @type {Reader} */
|
|
91
|
+
const readYY = (text, i, out) => {
|
|
92
|
+
const [value, next] = readDigits(text, i, 2, 2);
|
|
93
|
+
if (next < 0) return -1;
|
|
94
|
+
out.year = value < YY_PIVOT ? 2000 + value : 1900 + value;
|
|
95
|
+
return next;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** @type {Reader} */
|
|
99
|
+
const readSignedYear = (text, i, out) => {
|
|
100
|
+
let j = i;
|
|
101
|
+
let sign = 1;
|
|
102
|
+
if (text.charCodeAt(j) === C_MINUS) { sign = -1; j++; }
|
|
103
|
+
const [value, next] = readDigits(text, j, 1, 6);
|
|
104
|
+
if (next < 0) return -1;
|
|
105
|
+
out.year = sign * value;
|
|
106
|
+
return next;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A name reader: longest candidate first, so a name that is a prefix of
|
|
111
|
+
* another (`Ju` in a hypothetical catalog) cannot shadow it.
|
|
112
|
+
* @param {string} field
|
|
113
|
+
* @param {string[]} names
|
|
114
|
+
* @param {number} base - the value of `names[0]`
|
|
115
|
+
* @returns {Reader}
|
|
116
|
+
*/
|
|
117
|
+
function nameOf(field, names, base) {
|
|
118
|
+
// index by descending length once, at compile time
|
|
119
|
+
const order = names.map((name, index) => ({ name, value: index + base }))
|
|
120
|
+
.sort((a, b) => b.name.length - a.name.length);
|
|
121
|
+
const count = order.length;
|
|
122
|
+
return (text, i, out) => {
|
|
123
|
+
for (let k = 0; k < count; k++) {
|
|
124
|
+
const { name, value } = order[k];
|
|
125
|
+
if (name.length !== 0 && text.startsWith(name, i)) {
|
|
126
|
+
out[field] = value;
|
|
127
|
+
return i + name.length;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return -1;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {boolean} colon - require `+HH:MM` rather than `+HHMM`
|
|
136
|
+
* @param {boolean} zForUtc - accept a bare `Z`
|
|
137
|
+
* @returns {Reader}
|
|
138
|
+
*/
|
|
139
|
+
function offsetOf(colon, zForUtc) {
|
|
140
|
+
return (text, i, out) => {
|
|
141
|
+
const c = text.charCodeAt(i);
|
|
142
|
+
if (zForUtc && (c === C_Z_UPPER || c === C_Z_LOWER)) {
|
|
143
|
+
out.offset = 0;
|
|
144
|
+
return i + 1;
|
|
145
|
+
}
|
|
146
|
+
if (c !== C_PLUS && c !== C_MINUS) return -1;
|
|
147
|
+
const [hh, afterH] = readDigits(text, i + 1, 2, 2);
|
|
148
|
+
if (afterH < 0) return -1;
|
|
149
|
+
let j = afterH;
|
|
150
|
+
if (colon) {
|
|
151
|
+
if (text.charCodeAt(j) !== C_COLON) return -1;
|
|
152
|
+
j++;
|
|
153
|
+
}
|
|
154
|
+
const [mm, afterM] = readDigits(text, j, 2, 2);
|
|
155
|
+
if (afterM < 0) return -1;
|
|
156
|
+
if (hh > 23 || mm > 59) return -1;
|
|
157
|
+
out.offset = (c === C_MINUS ? -1 : 1) * (hh * 60 + mm);
|
|
158
|
+
return afterM;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @type {Reader} */
|
|
163
|
+
const readMillis = (text, i, out) => {
|
|
164
|
+
const [value, next] = readDigits(text, i, 3, 3);
|
|
165
|
+
if (next < 0) return -1;
|
|
166
|
+
out.millis = value;
|
|
167
|
+
return next;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** @type {Reader} */
|
|
171
|
+
const readTenths = (text, i, out) => {
|
|
172
|
+
const [value, next] = readDigits(text, i, 1, 1);
|
|
173
|
+
if (next < 0) return -1;
|
|
174
|
+
out.millis = value * 100;
|
|
175
|
+
return next;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Which half of a value each token contributes to, and how to read it.
|
|
180
|
+
* `names` is the `DateNames` member a token needs, if any.
|
|
181
|
+
*/
|
|
182
|
+
const TOKENS = Object.freeze({
|
|
183
|
+
yyyy: { half: 'date', reader: numeric('year', 4, 4) },
|
|
184
|
+
yy: { half: 'date', reader: readYY },
|
|
185
|
+
y: { half: 'date', reader: readSignedYear },
|
|
186
|
+
MMMM: { half: 'date', names: 'months' },
|
|
187
|
+
MMM: { half: 'date', names: 'monthsShort' },
|
|
188
|
+
MM: { half: 'date', reader: numeric('month', 2, 2) },
|
|
189
|
+
M: { half: 'date', reader: numeric('month', 1, 2) },
|
|
190
|
+
dd: { half: 'date', reader: numeric('day', 2, 2) },
|
|
191
|
+
d: { half: 'date', reader: numeric('day', 1, 2) },
|
|
192
|
+
HH: { half: 'time', reader: numeric('hours', 2, 2) },
|
|
193
|
+
H: { half: 'time', reader: numeric('hours', 1, 2) },
|
|
194
|
+
hh: { half: 'time', reader: numeric('hour12', 2, 2) },
|
|
195
|
+
h: { half: 'time', reader: numeric('hour12', 1, 2) },
|
|
196
|
+
mm: { half: 'time', reader: numeric('minutes', 2, 2) },
|
|
197
|
+
m: { half: 'time', reader: numeric('minutes', 1, 2) },
|
|
198
|
+
ss: { half: 'time', reader: numeric('seconds', 2, 2) },
|
|
199
|
+
s: { half: 'time', reader: numeric('seconds', 1, 2) },
|
|
200
|
+
SSS: { half: 'time', reader: readMillis },
|
|
201
|
+
S: { half: 'time', reader: readTenths },
|
|
202
|
+
a: { half: 'time', names: 'meridiem' },
|
|
203
|
+
XXX: { half: 'offset', reader: offsetOf(true, true) },
|
|
204
|
+
XX: { half: 'offset', reader: offsetOf(false, true) },
|
|
205
|
+
X: { half: 'offset', reader: offsetOf(true, false) },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Tokens `compileDateFormat` writes that no value carries as a field —
|
|
210
|
+
* they are computed FROM a date, so reading one back tells the parser
|
|
211
|
+
* nothing it did not already have. Named individually so the error says
|
|
212
|
+
* which token and why, rather than "unknown token".
|
|
213
|
+
*/
|
|
214
|
+
const DERIVED = Object.freeze({
|
|
215
|
+
EEEE: 'the weekday name', EEE: 'the abbreviated weekday name',
|
|
216
|
+
E: 'the ISO weekday number', DDD: 'the day of the year',
|
|
217
|
+
D: 'the day of the year', ww: 'the ISO week number',
|
|
218
|
+
w: 'the ISO week number', Q: 'the quarter',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const MAX_TOKEN = 4;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The value a name token reads into, and the base of its numbering.
|
|
225
|
+
* @param {string} token
|
|
226
|
+
* @returns {[string, number]}
|
|
227
|
+
*/
|
|
228
|
+
function nameTarget(token) {
|
|
229
|
+
if (token === 'a') return ['meridiem', 0];
|
|
230
|
+
return ['month', 1];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compile an LDML date pattern into a strict parser.
|
|
235
|
+
*
|
|
236
|
+
* The returned function takes a string and returns a parts record in
|
|
237
|
+
* exactly the shape `parseRFC3339Parts` produces — so
|
|
238
|
+
* `epochOfRFC3339Parts`, `formatRFC3339Parts`, `addToParts` and every
|
|
239
|
+
* other calendar function accept it unchanged — or `null` when the text
|
|
240
|
+
* does not match. Failure is `null`, never a throw and never a partial
|
|
241
|
+
* record: a malformed *pattern* is the programmer's error and throws at
|
|
242
|
+
* compile time; malformed *text* is data and is reported as no match.
|
|
243
|
+
*
|
|
244
|
+
* Strict means three things:
|
|
245
|
+
*
|
|
246
|
+
* 1. **Full consumption.** Trailing text fails; `yyyy-MM-dd` does not
|
|
247
|
+
* read `2026-01-02T03:04`.
|
|
248
|
+
* 2. **Impossible civil dates fail.** 31 February and hour 24 are not
|
|
249
|
+
* silently rolled forward into March and the next day.
|
|
250
|
+
* 3. **Fixed-width tokens are fixed width.** `MM` reads exactly two
|
|
251
|
+
* digits, `M` one or two, and neither reads three.
|
|
252
|
+
*
|
|
253
|
+
* The lexical family of the result follows the pattern: a pattern with
|
|
254
|
+
* only date tokens yields a full-date (`hours`/`minutes`/`seconds` are
|
|
255
|
+
* the contract's `-1`), one with only time tokens a full-time
|
|
256
|
+
* (`year`/`month`/`day` are `-1`), and one with both a date-time. A
|
|
257
|
+
* pattern with no field token at all is a compile error — it could only
|
|
258
|
+
* ever return the same empty record.
|
|
259
|
+
*
|
|
260
|
+
* Two tokens are not strict inverses of an arbitrary value and are
|
|
261
|
+
* documented rather than refused: `yy` reads 00–68 as 2000–2068 and
|
|
262
|
+
* 69–99 as 1969–1999 (the POSIX pivot moment uses), and `h`/`hh`
|
|
263
|
+
* without an `a` in the same pattern read as the morning, so 12 is
|
|
264
|
+
* midnight. Both round-trip their own spelling exactly.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} pattern - an LDML pattern, e.g. `'yyyy-MM-dd'`
|
|
267
|
+
* @param {import('./format.js').DateNames} [names] - locale names, required only if the
|
|
268
|
+
* pattern uses `MMM`/`MMMM`/`a`
|
|
269
|
+
* @returns {(text: string) => object | null} the compiled parser
|
|
270
|
+
* @throws {TypeError} on an unterminated quote, a derived token, a name
|
|
271
|
+
* token with no provider, or a pattern that reads no field
|
|
272
|
+
* @example
|
|
273
|
+
* const read = compileDateParser('dd-MM-yyyy');
|
|
274
|
+
* read('06-01-2014'); // { year: 2014, month: 1, day: 6, hours: -1, … }
|
|
275
|
+
* read('06-01-2014x'); // null — trailing input
|
|
276
|
+
* read('31-02-2014'); // null — February has no 31st
|
|
277
|
+
*/
|
|
278
|
+
export function compileDateParser(pattern, names = undefined) {
|
|
279
|
+
if (typeof pattern !== 'string')
|
|
280
|
+
throw new TypeError('a date pattern must be a string');
|
|
281
|
+
|
|
282
|
+
/** @type {Reader[]} */
|
|
283
|
+
const steps = [];
|
|
284
|
+
let literal = '';
|
|
285
|
+
let hasDate = false;
|
|
286
|
+
let hasTime = false;
|
|
287
|
+
let hasOffset = false;
|
|
288
|
+
let hasMeridiem = false;
|
|
289
|
+
|
|
290
|
+
const flushLiteral = () => {
|
|
291
|
+
if (literal !== '') {
|
|
292
|
+
const text = literal;
|
|
293
|
+
const width = text.length;
|
|
294
|
+
steps.push((source, i) => (source.startsWith(text, i) ? i + width : -1));
|
|
295
|
+
literal = '';
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
for (let i = 0; i < pattern.length;) {
|
|
300
|
+
const ch = pattern[i];
|
|
301
|
+
if (ch === "'") { // quoted literal, '' is one quote
|
|
302
|
+
if (pattern.charCodeAt(i + 1) === C_QUOTE) {
|
|
303
|
+
literal += "'";
|
|
304
|
+
i += 2;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const end = pattern.indexOf("'", i + 1);
|
|
308
|
+
if (end < 0)
|
|
309
|
+
throw new TypeError(`unterminated quoted literal in date pattern '${pattern}'`);
|
|
310
|
+
literal += pattern.slice(i + 1, end);
|
|
311
|
+
i = end + 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
let matched = null;
|
|
315
|
+
for (let len = MAX_TOKEN; len >= 1; len--) {
|
|
316
|
+
const candidate = pattern.slice(i, i + len);
|
|
317
|
+
if (candidate.length !== len) continue;
|
|
318
|
+
if (TOKENS[candidate] !== undefined || DERIVED[candidate] !== undefined) {
|
|
319
|
+
matched = candidate;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (matched === null) {
|
|
324
|
+
literal += ch;
|
|
325
|
+
i += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (DERIVED[matched] !== undefined) {
|
|
329
|
+
throw new TypeError(`the '${matched}' token writes ${DERIVED[matched]},`
|
|
330
|
+
+ ' which is derived from a date rather than part of one, so it cannot be parsed');
|
|
331
|
+
}
|
|
332
|
+
const spec = TOKENS[matched];
|
|
333
|
+
let reader = spec.reader;
|
|
334
|
+
if (spec.names !== undefined) {
|
|
335
|
+
const table = names === undefined ? undefined : names[spec.names];
|
|
336
|
+
if (table === undefined)
|
|
337
|
+
throw new TypeError(`the '${matched}' token needs a '${spec.names}' names provider`
|
|
338
|
+
+ ' (@jarenjs/core/dates is locale-free by design)');
|
|
339
|
+
const [field, base] = nameTarget(matched);
|
|
340
|
+
reader = nameOf(field, table, base);
|
|
341
|
+
if (matched === 'a') hasMeridiem = true;
|
|
342
|
+
}
|
|
343
|
+
flushLiteral();
|
|
344
|
+
steps.push(reader);
|
|
345
|
+
if (spec.half === 'date') hasDate = true;
|
|
346
|
+
else if (spec.half === 'time') hasTime = true;
|
|
347
|
+
else hasOffset = true;
|
|
348
|
+
i += matched.length;
|
|
349
|
+
}
|
|
350
|
+
flushLiteral();
|
|
351
|
+
|
|
352
|
+
if (!hasDate && !hasTime)
|
|
353
|
+
throw new TypeError(`the date pattern '${pattern}' reads no date or time field`);
|
|
354
|
+
|
|
355
|
+
const count = steps.length;
|
|
356
|
+
return (text) => {
|
|
357
|
+
if (typeof text !== 'string') return null;
|
|
358
|
+
/** @type {any} */
|
|
359
|
+
const out = {
|
|
360
|
+
year: 1970, month: 1, day: 1,
|
|
361
|
+
hours: 0, minutes: 0, seconds: 0, millis: 0,
|
|
362
|
+
hour12: -1, meridiem: -1, offset: null,
|
|
363
|
+
};
|
|
364
|
+
let i = 0;
|
|
365
|
+
for (let k = 0; k < count; k++) {
|
|
366
|
+
i = steps[k](text, i, out);
|
|
367
|
+
if (i < 0) return null;
|
|
368
|
+
}
|
|
369
|
+
if (i !== text.length) return null;
|
|
370
|
+
return assemble(out, hasDate, hasTime, hasOffset, hasMeridiem);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Turn the reader's scratch record into a validated parts record.
|
|
376
|
+
* @param {any} out
|
|
377
|
+
* @param {boolean} hasDate
|
|
378
|
+
* @param {boolean} hasTime
|
|
379
|
+
* @param {boolean} hasOffset
|
|
380
|
+
* @param {boolean} hasMeridiem
|
|
381
|
+
* @returns {object | null}
|
|
382
|
+
*/
|
|
383
|
+
function assemble(out, hasDate, hasTime, hasOffset, hasMeridiem) {
|
|
384
|
+
if (hasDate) {
|
|
385
|
+
if (out.month < 1 || out.month > 12) return null;
|
|
386
|
+
if (out.day < 1 || out.day > daysInMonth(out.year, out.month)) return null;
|
|
387
|
+
}
|
|
388
|
+
let hours = out.hours;
|
|
389
|
+
if (hasTime) {
|
|
390
|
+
if (out.hour12 >= 0) {
|
|
391
|
+
if (out.hour12 < 1 || out.hour12 > 12) return null;
|
|
392
|
+
hours = out.hour12 % 12;
|
|
393
|
+
if (hasMeridiem && out.meridiem === 1) hours += 12;
|
|
394
|
+
}
|
|
395
|
+
if (hours < 0 || hours > 23) return null;
|
|
396
|
+
if (out.minutes < 0 || out.minutes > 59) return null;
|
|
397
|
+
if (out.seconds < 0 || out.seconds > 59) return null;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
year: hasDate ? out.year : -1,
|
|
401
|
+
month: hasDate ? out.month : -1,
|
|
402
|
+
day: hasDate ? out.day : -1,
|
|
403
|
+
hours: hasTime ? hours : -1,
|
|
404
|
+
minutes: hasTime ? out.minutes : -1,
|
|
405
|
+
seconds: hasTime ? out.seconds + out.millis / 1000 : -1,
|
|
406
|
+
offset: hasOffset ? out.offset : null,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
//#endregion
|