@zelgadis87/utils-core 5.2.9 → 5.2.11
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/CHANGELOG.md +17 -1
- package/dist/time/TimeInstant.d.ts +38 -10
- package/dist/time/TimeInstantBuilder.d.ts +23 -14
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/empties.d.ts +1 -0
- package/esbuild/index.cjs +393 -189
- package/esbuild/index.cjs.map +4 -4
- package/esbuild/index.mjs +393 -202
- package/esbuild/index.mjs.map +4 -4
- package/package.json +1 -4
- package/src/time/TimeInstant.ts +519 -42
- package/src/time/TimeInstantBuilder.ts +19 -9
- package/src/utils/empties.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@zelgadis87/utils-core",
|
|
4
|
-
"version": "5.2.
|
|
4
|
+
"version": "5.2.11",
|
|
5
5
|
"author": "Zelgadis87",
|
|
6
6
|
"license": "ISC",
|
|
7
7
|
"private": false,
|
|
@@ -30,8 +30,5 @@
|
|
|
30
30
|
"typescript": "5.8.3",
|
|
31
31
|
"typescript-eslint": "8.43.0",
|
|
32
32
|
"vitest": "3.2.4"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"small-date": "^2.0.1"
|
|
36
33
|
}
|
|
37
34
|
}
|
package/src/time/TimeInstant.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { format } from "small-date";
|
|
2
1
|
import { ICancelable, ICancelablePromise } from "../async/Deferred.js";
|
|
3
2
|
import { TComparisonResult } from "../sorting/_index.js";
|
|
4
3
|
import TimeBase from "./TimeBase";
|
|
5
4
|
import TimeDuration from "./TimeDuration";
|
|
6
|
-
import { TTimeInstantBuilder, TTimeInstantCreationParameters, createTimeInstantFromParameters, timeInstantBuilder } from "./TimeInstantBuilder.js";
|
|
5
|
+
import { TTimeInstantBuilder, TTimeInstantCreationParameters, createTimeInstantFromParameters, timeInstantBuilder, type TTimeInstantParameters } from "./TimeInstantBuilder.js";
|
|
7
6
|
import { TimeUnit } from "./TimeUnit";
|
|
8
|
-
import { TDayOfMonth, TDayOfWeek, TIso8601DateString, TIso8601DateUtcString, TMonth, TWeekNumber } from "./types";
|
|
7
|
+
import { TDayOfMonth, TDayOfWeek, THourOfDay, TIso8601DateString, TIso8601DateUtcString, TMillisecondOfSecond, TMinuteOfHour, TMonth, TSecondOfMinute, TWeekNumber } from "./types";
|
|
9
8
|
|
|
10
9
|
export class TimeInstant extends TimeBase<TimeInstant> {
|
|
11
10
|
|
|
@@ -32,7 +31,7 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
32
31
|
public distanceFromNow(): TimeDuration {
|
|
33
32
|
return TimeDuration.fromMs( Math.abs( this.ms - Date.now() ) );
|
|
34
33
|
}
|
|
35
|
-
|
|
34
|
+
|
|
36
35
|
public distanceFromUnixTimestamp( timestamp: number ): TimeDuration {
|
|
37
36
|
return TimeDuration.ms( this.ms - timestamp );
|
|
38
37
|
}
|
|
@@ -48,7 +47,10 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
public distanceFromStartOfDay(): TimeDuration {
|
|
51
|
-
|
|
50
|
+
// Calculate milliseconds since start of day directly without creating intermediate TimeInstant
|
|
51
|
+
const params = this.toParameters();
|
|
52
|
+
const msInDay = params.hours * 3600000 + params.minutes * 60000 + params.seconds * 1000 + params.milliseconds;
|
|
53
|
+
return TimeDuration.fromMs( msInDay );
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
public atStartOfDay(): TimeInstant {
|
|
@@ -56,7 +58,11 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
public distanceFromEndOfDay(): TimeDuration {
|
|
59
|
-
|
|
61
|
+
// Calculate milliseconds until end of day directly
|
|
62
|
+
const params = this.toParameters();
|
|
63
|
+
const msInDay = params.hours * 3600000 + params.minutes * 60000 + params.seconds * 1000 + params.milliseconds;
|
|
64
|
+
const msUntilEndOfDay = 86399999 - msInDay; // 23:59:59.999 in ms
|
|
65
|
+
return TimeDuration.fromMs( msUntilEndOfDay );
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
public atEndOfDay(): TimeInstant {
|
|
@@ -90,12 +96,15 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
public isToday(): boolean {
|
|
93
|
-
|
|
99
|
+
// Calculate days directly from timestamps without creating TimeInstant object
|
|
100
|
+
return Math.floor( this.ms / 86400000 ) === Math.floor( Date.now() / 86400000 );
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/**
|
|
97
104
|
* Formats this instant using the given pattern. The pattern can contain the following tokens:
|
|
98
105
|
*
|
|
106
|
+
* Note: Implementation inspired by the small-date library (https://github.com/robinweser/small-date).
|
|
107
|
+
*
|
|
99
108
|
* | Token | Description | Example |
|
|
100
109
|
* |:------|:--------------------------------|:------------------------------|
|
|
101
110
|
* | D | Weekday, 1 letter | W |
|
|
@@ -135,7 +144,34 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
135
144
|
* @returns a string, formatted using the given pattern, at the given timeZone with the given locale.
|
|
136
145
|
*/
|
|
137
146
|
public format( pattern: string, config: { locale?: string, timeZone?: string } = {} ) {
|
|
138
|
-
return
|
|
147
|
+
return formatTimeInstant( this, pattern, config );
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parses a date string using the given pattern and creates a TimeInstant.
|
|
152
|
+
* This method is the inverse of format() - parsing a formatted string should recreate the original instant.
|
|
153
|
+
*
|
|
154
|
+
* For partial patterns (e.g., time-only or date-only), the missing components are taken from the base instant.
|
|
155
|
+
* For example, parsing "14:30" with base set to yesterday at midnight will result in yesterday at 14:30.
|
|
156
|
+
*
|
|
157
|
+
* Note: Currently performs basic validation (e.g., month 1-12, day 1-31) but does not validate
|
|
158
|
+
* calendar-specific constraints (e.g., February 30th, April 31st). Invalid dates may be
|
|
159
|
+
* normalized by the underlying Date constructor.
|
|
160
|
+
*
|
|
161
|
+
* @param dateString The date string to parse
|
|
162
|
+
* @param pattern The pattern used to parse the string (same tokens as format())
|
|
163
|
+
* @param base The base TimeInstant to use for partial patterns (defaults to now)
|
|
164
|
+
* @param config An optional locale and timeZone definition to use during parsing
|
|
165
|
+
* @returns A TimeInstant parsed from the string
|
|
166
|
+
* @throws Error if the string doesn't match the pattern or contains invalid basic values
|
|
167
|
+
* @todo Add calendar-aware validation to reject dates like February 30th, April 31st
|
|
168
|
+
*/
|
|
169
|
+
public static fromString( dateString: string, pattern: string, base: TimeInstant = TimeInstant.now(), config: { locale?: string, timeZone?: string } = {} ): TimeInstant {
|
|
170
|
+
return parseTimeInstant( dateString, pattern, base, config );
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public static parse( dateString: string, pattern: string, config: { locale?: string, timeZone?: string } = {} ): Partial<TTimeInstantCreationParameters> {
|
|
174
|
+
return parseTimeInstantComponents( dateString, pattern, config );
|
|
139
175
|
}
|
|
140
176
|
|
|
141
177
|
/**
|
|
@@ -153,7 +189,7 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
153
189
|
}
|
|
154
190
|
|
|
155
191
|
public toUnixTimestamp(): number {
|
|
156
|
-
//
|
|
192
|
+
// Syntactic sugar for this.ms;
|
|
157
193
|
return this.ms;
|
|
158
194
|
}
|
|
159
195
|
|
|
@@ -161,8 +197,17 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
161
197
|
return new Date( this.ms );
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
public
|
|
165
|
-
|
|
200
|
+
public toParameters(): TTimeInstantParameters {
|
|
201
|
+
const d = this.toDate();
|
|
202
|
+
return {
|
|
203
|
+
year: d.getUTCFullYear(),
|
|
204
|
+
month: d.getUTCMonth() + 1 as TMonth,
|
|
205
|
+
date: d.getUTCDate() as TDayOfMonth,
|
|
206
|
+
hours: d.getUTCHours() as THourOfDay,
|
|
207
|
+
minutes: d.getUTCMinutes() as TMinuteOfHour,
|
|
208
|
+
seconds: d.getUTCSeconds() as TSecondOfMinute,
|
|
209
|
+
milliseconds: d.getUTCMilliseconds() as TMillisecondOfSecond,
|
|
210
|
+
};
|
|
166
211
|
}
|
|
167
212
|
|
|
168
213
|
/**
|
|
@@ -241,16 +286,58 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
241
286
|
return timeInstantBuilder();
|
|
242
287
|
}
|
|
243
288
|
|
|
244
|
-
public static fromIso8601( str:
|
|
245
|
-
|
|
246
|
-
|
|
289
|
+
public static fromIso8601( str: string ) {
|
|
290
|
+
// Regex to capture: YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss.sss±HH:mm
|
|
291
|
+
const iso8601Regex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})(Z|[+-]\d{2}:\d{2})?$/;
|
|
292
|
+
const match = str.match( iso8601Regex );
|
|
293
|
+
|
|
294
|
+
if ( !match ) {
|
|
295
|
+
throw new Error( 'Invalid ISO 8601 date format: ' + str );
|
|
296
|
+
}
|
|
247
297
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
298
|
+
const [ , yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr, millisecondStr ] = match;
|
|
299
|
+
|
|
300
|
+
// Parse strings to numbers
|
|
301
|
+
const year = parseInt( yearStr, 10 );
|
|
302
|
+
const month = parseInt( monthStr, 10 );
|
|
303
|
+
const date = parseInt( dayStr, 10 );
|
|
304
|
+
const hours = parseInt( hourStr, 10 );
|
|
305
|
+
const minutes = parseInt( minuteStr, 10 );
|
|
306
|
+
const seconds = parseInt( secondStr, 10 );
|
|
307
|
+
const milliseconds = parseInt( millisecondStr, 10 );
|
|
308
|
+
|
|
309
|
+
// Validate each component using standalone validation functions
|
|
310
|
+
if ( !isValidYear( year ) )
|
|
311
|
+
throw new Error( 'Invalid year in: ' + str );
|
|
312
|
+
if ( !isValidMonth( month ) )
|
|
313
|
+
throw new Error( 'Invalid month in: ' + str );
|
|
314
|
+
if ( !isValidDayOfMonth( date ) )
|
|
315
|
+
throw new Error( 'Invalid day in: ' + str );
|
|
316
|
+
if ( !isValidHour( hours ) )
|
|
317
|
+
throw new Error( 'Invalid hour in: ' + str );
|
|
318
|
+
if ( !isValidMinute( minutes ) )
|
|
319
|
+
throw new Error( 'Invalid minute in: ' + str );
|
|
320
|
+
if ( !isValidSecond( seconds ) )
|
|
321
|
+
throw new Error( 'Invalid second in: ' + str );
|
|
322
|
+
if ( !isValidMillisecond( milliseconds ) )
|
|
323
|
+
throw new Error( 'Invalid millisecond in: ' + str );
|
|
324
|
+
|
|
325
|
+
return TimeInstant.fromParameters( {
|
|
326
|
+
year,
|
|
327
|
+
month,
|
|
328
|
+
date,
|
|
329
|
+
hours,
|
|
330
|
+
minutes,
|
|
331
|
+
seconds,
|
|
332
|
+
milliseconds,
|
|
333
|
+
} );
|
|
252
334
|
}
|
|
253
335
|
|
|
336
|
+
/**
|
|
337
|
+
* @deprecated [2025.10.19]: Use fromIso8601 instead.
|
|
338
|
+
*/
|
|
339
|
+
public static tryFromIso8601 = this.fromIso8601;
|
|
340
|
+
|
|
254
341
|
public static now(): TimeInstant {
|
|
255
342
|
return TimeInstant.fromUnixTimestamp( Date.now() );
|
|
256
343
|
}
|
|
@@ -285,39 +372,31 @@ export class TimeInstant extends TimeBase<TimeInstant> {
|
|
|
285
372
|
return TimeInstant.fromUnixTimestamp( timestamp );
|
|
286
373
|
}
|
|
287
374
|
|
|
288
|
-
public get dayOfMonth(): TDayOfMonth {
|
|
289
|
-
return this.toDate().getDate() as TDayOfMonth;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
public get dayOfWeek(): TDayOfWeek {
|
|
293
|
-
return this.toDate().getDay() + 1 as TDayOfWeek;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
public get month(): TMonth {
|
|
297
|
-
return this.toDate().getMonth() + 1 as TMonth;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
public get year(): number {
|
|
301
|
-
return this.toDate().getFullYear();
|
|
302
|
-
}
|
|
303
|
-
|
|
304
375
|
/**
|
|
305
376
|
* Returns the week number represented by this instant, according to the ISO 8601 standard.
|
|
306
|
-
|
|
377
|
+
* Please note that the instant and the week number could be of two different years, eg the friday 1st january is actually part of week 52 of the previous year.
|
|
307
378
|
*/
|
|
308
379
|
public get weekNumber(): { weekNumber: TWeekNumber, year: number } {
|
|
309
380
|
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
381
|
+
* According to the ISO 8601 Standard, week number 1 is defined as the week containing the 4th of january (or, equivalently, the week that contains the first Thursday of the year).
|
|
382
|
+
* As such, we basically have to count how many Thursdays there has been in this year.
|
|
383
|
+
* Please note that the thursdayOfThisWeek could be in the previous year.
|
|
313
384
|
*/
|
|
314
385
|
const date = this.toDate();
|
|
315
386
|
const oneDay = 1000 * 60 * 60 * 24;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
387
|
+
|
|
388
|
+
// Get thursday of this week
|
|
389
|
+
const dayOfWeek = date.getUTCDay() || 7; // Sunday = 7 in ISO
|
|
390
|
+
const thursdayMs = this.ms + ( 4 - dayOfWeek ) * oneDay;
|
|
391
|
+
const thursdayDate = new Date( thursdayMs );
|
|
392
|
+
const thursdayYear = thursdayDate.getUTCFullYear();
|
|
393
|
+
|
|
394
|
+
// First of January at noon UTC
|
|
395
|
+
const firstOfJanMs = Date.UTC( thursdayYear, 0, 1, 12, 0, 0 );
|
|
396
|
+
const dayOfTheYear = Math.round( ( thursdayMs - firstOfJanMs ) / oneDay );
|
|
319
397
|
const weekNumber = Math.floor( dayOfTheYear / 7 ) + 1;
|
|
320
|
-
|
|
398
|
+
|
|
399
|
+
return { weekNumber: weekNumber as TWeekNumber, year: thursdayYear };
|
|
321
400
|
}
|
|
322
401
|
|
|
323
402
|
/**
|
|
@@ -344,3 +423,401 @@ export function isTimeInstant( x: unknown ): x is TimeInstant {
|
|
|
344
423
|
}
|
|
345
424
|
|
|
346
425
|
export default TimeInstant;
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
// Utility functions for date formatting and parsing
|
|
429
|
+
|
|
430
|
+
// Standalone validation functions
|
|
431
|
+
// TODO: Add calendar-aware validation to check month-specific day limits
|
|
432
|
+
// (e.g., reject February 30th, April 31st, February 29th in non-leap years)
|
|
433
|
+
function isValidYear( num: number ): boolean {
|
|
434
|
+
return num >= 0 && num <= 9999;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isValidMonth( num: number ): num is TMonth {
|
|
438
|
+
return num >= 1 && num <= 12;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isValidDayOfMonth( num: number ): num is TDayOfMonth {
|
|
442
|
+
return num >= 1 && num <= 31;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isValidHour( num: number ): num is THourOfDay {
|
|
446
|
+
return num >= 0 && num <= 23;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isValidMinute( num: number ): num is TMinuteOfHour {
|
|
450
|
+
return num >= 0 && num <= 59;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isValidSecond( num: number ): num is TSecondOfMinute {
|
|
454
|
+
return num >= 0 && num <= 59;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isValidMillisecond( num: number ): num is TMillisecondOfSecond {
|
|
458
|
+
return num >= 0 && num <= 999;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Month names cache by locale to avoid regenerating on every call
|
|
462
|
+
const monthNamesCache = new Map<string, { short: string[], long: string[] }>();
|
|
463
|
+
|
|
464
|
+
function getMonthNames( locale: string = 'en' ): { short: string[], long: string[] } {
|
|
465
|
+
// Check cache first
|
|
466
|
+
const cached = monthNamesCache.get( locale );
|
|
467
|
+
if ( cached ) {
|
|
468
|
+
return cached;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Use Intl.DateTimeFormat to generate month names for any locale
|
|
472
|
+
const shortFormatter = new Intl.DateTimeFormat( locale, { month: 'short' } );
|
|
473
|
+
const longFormatter = new Intl.DateTimeFormat( locale, { month: 'long' } );
|
|
474
|
+
|
|
475
|
+
const short: string[] = [];
|
|
476
|
+
const long: string[] = [];
|
|
477
|
+
|
|
478
|
+
// Generate names for all 12 months (0-11 in Date constructor)
|
|
479
|
+
for ( let month = 0; month < 12; month++ ) {
|
|
480
|
+
// Use January 1st of year 2000 as a reference date (arbitrary non-leap year)
|
|
481
|
+
const date = new Date( 2000, month, 1 );
|
|
482
|
+
short.push( normalizeMonthName( shortFormatter.format( date ) ) );
|
|
483
|
+
long.push( normalizeMonthName( longFormatter.format( date ) ) );
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const result = { short, long };
|
|
487
|
+
monthNamesCache.set( locale, result );
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Normalize month names by removing accents, periods, and other special characters
|
|
492
|
+
function normalizeMonthName( name: string ): string {
|
|
493
|
+
return name
|
|
494
|
+
// Remove periods and other punctuation
|
|
495
|
+
.replace( /[.,;:!?]/g, '' )
|
|
496
|
+
// Normalize Unicode (decompose accented characters)
|
|
497
|
+
.normalize( 'NFD' )
|
|
498
|
+
// Remove diacritical marks (accents)
|
|
499
|
+
.replace( /[\u0300-\u036f]/g, '' )
|
|
500
|
+
// Trim whitespace
|
|
501
|
+
.trim();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const PATTERN_REGEX = /(M|y|d|D|h|H|m|s|S|G|Z|P|a)+/g;
|
|
505
|
+
const ESCAPE_REGEX = /\\"|"((?:\\"|[^"])*)"/g;
|
|
506
|
+
|
|
507
|
+
// Formatter configuration with optional extraction metadata
|
|
508
|
+
type TFormatterConfig = {
|
|
509
|
+
options: Intl.DateTimeFormatOptions;
|
|
510
|
+
extract?: {
|
|
511
|
+
partType: Intl.DateTimeFormatPartTypes;
|
|
512
|
+
transform?: ( value: string ) => string;
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Formatter configuration mapping: token pattern -> configuration with optional extraction rules
|
|
517
|
+
const formatterConfigs: Record<string, TFormatterConfig> = {
|
|
518
|
+
// Year
|
|
519
|
+
'y': { options: { year: 'numeric' } },
|
|
520
|
+
'yy': { options: { year: '2-digit' } },
|
|
521
|
+
'yyyy': { options: { year: 'numeric' } },
|
|
522
|
+
|
|
523
|
+
// Month
|
|
524
|
+
'M': { options: { month: 'numeric' } },
|
|
525
|
+
'MM': { options: { month: '2-digit' } },
|
|
526
|
+
'MMM': { options: { month: 'short' } },
|
|
527
|
+
'MMMM': { options: { month: 'long' } },
|
|
528
|
+
|
|
529
|
+
// Day
|
|
530
|
+
'd': { options: { day: 'numeric' } },
|
|
531
|
+
'dd': { options: { day: '2-digit' } },
|
|
532
|
+
|
|
533
|
+
// Hours (24-hour) - extract and pad manually due to Intl quirk
|
|
534
|
+
'H': { options: { hour: 'numeric', hour12: false }, extract: { partType: 'hour' } },
|
|
535
|
+
'HH': { options: { hour: '2-digit', hour12: false }, extract: { partType: 'hour', transform: ( v ) => v.padStart( 2, '0' ) } },
|
|
536
|
+
|
|
537
|
+
// Hours (12-hour) - extract and pad manually due to Intl quirk
|
|
538
|
+
'h': { options: { hour: 'numeric', hour12: true }, extract: { partType: 'hour' } },
|
|
539
|
+
'hh': { options: { hour: '2-digit', hour12: true }, extract: { partType: 'hour', transform: ( v ) => v.padStart( 2, '0' ) } },
|
|
540
|
+
|
|
541
|
+
// Minutes - extract and pad manually due to Intl quirk
|
|
542
|
+
'm': { options: { minute: 'numeric' }, extract: { partType: 'minute' } },
|
|
543
|
+
'mm': { options: { minute: '2-digit' }, extract: { partType: 'minute', transform: ( v ) => v.padStart( 2, '0' ) } },
|
|
544
|
+
|
|
545
|
+
// Seconds - extract and pad manually due to Intl quirk
|
|
546
|
+
's': { options: { second: 'numeric' }, extract: { partType: 'second' } },
|
|
547
|
+
'ss': { options: { second: '2-digit' }, extract: { partType: 'second', transform: ( v ) => v.padStart( 2, '0' ) } },
|
|
548
|
+
|
|
549
|
+
// Milliseconds - extract manually
|
|
550
|
+
'S': { options: { fractionalSecondDigits: 1 }, extract: { partType: 'fractionalSecond' } },
|
|
551
|
+
'SS': { options: { fractionalSecondDigits: 2 }, extract: { partType: 'fractionalSecond' } },
|
|
552
|
+
'SSS': { options: { fractionalSecondDigits: 3 }, extract: { partType: 'fractionalSecond' } },
|
|
553
|
+
|
|
554
|
+
// Weekday
|
|
555
|
+
'D': { options: { weekday: 'narrow' } },
|
|
556
|
+
'DD': { options: { weekday: 'short' } },
|
|
557
|
+
'DDD': { options: { weekday: 'long' } },
|
|
558
|
+
|
|
559
|
+
// Era
|
|
560
|
+
'G': { options: { era: 'narrow' } },
|
|
561
|
+
'GG': { options: { era: 'short' } },
|
|
562
|
+
'GGG': { options: { era: 'long' } },
|
|
563
|
+
|
|
564
|
+
// Timezone
|
|
565
|
+
'Z': { options: { timeZoneName: 'short' } },
|
|
566
|
+
'ZZ': { options: { timeZoneName: 'long' } },
|
|
567
|
+
|
|
568
|
+
// Day period
|
|
569
|
+
'P': { options: { dayPeriod: 'narrow' } },
|
|
570
|
+
'PP': { options: { dayPeriod: 'short' } },
|
|
571
|
+
'PPP': { options: { dayPeriod: 'long' } },
|
|
572
|
+
|
|
573
|
+
// Meridiem - extract dayPeriod and lowercase it
|
|
574
|
+
'a': { options: { hour: 'numeric', hour12: true }, extract: { partType: 'dayPeriod', transform: ( v ) => v.toLowerCase() } },
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Format a token using Intl.DateTimeFormat
|
|
578
|
+
function formatType( type: string, length: number, date: Date, locale: string = 'en', timeZone: string = 'UTC' ): string | undefined {
|
|
579
|
+
const tokenKey = type.repeat( length );
|
|
580
|
+
const config = formatterConfigs[ tokenKey ];
|
|
581
|
+
|
|
582
|
+
if ( !config ) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const formatter = new Intl.DateTimeFormat( locale, { ...config.options, timeZone } );
|
|
587
|
+
|
|
588
|
+
// If extraction metadata is specified, use it
|
|
589
|
+
if ( config.extract ) {
|
|
590
|
+
const part = formatter.formatToParts( date ).find( p => p.type === config.extract!.partType );
|
|
591
|
+
const value = part?.value;
|
|
592
|
+
|
|
593
|
+
if ( !value ) {
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Apply transformation if specified
|
|
598
|
+
return config.extract.transform ? config.extract.transform( value ) : value;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Otherwise, format directly
|
|
602
|
+
return formatter.format( date );
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function formatTimeInstant( instant: TimeInstant, pattern: string, config: { locale?: string, timeZone?: string } = {} ): string {
|
|
606
|
+
|
|
607
|
+
const date = instant.toDate();
|
|
608
|
+
const locale = config.locale || 'en';
|
|
609
|
+
const timeZone = config.timeZone || 'UTC';
|
|
610
|
+
|
|
611
|
+
return pattern
|
|
612
|
+
.split( ESCAPE_REGEX )
|
|
613
|
+
.filter( ( sub ) => sub !== undefined )
|
|
614
|
+
.map( ( sub, index ) => {
|
|
615
|
+
// keep escaped strings as is
|
|
616
|
+
if ( index % 2 !== 0 ) {
|
|
617
|
+
return sub;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return sub.replace( PATTERN_REGEX, ( match ) => {
|
|
621
|
+
const type = match.charAt( 0 );
|
|
622
|
+
const length = match.length;
|
|
623
|
+
|
|
624
|
+
return formatType( type, length, date, locale, timeZone ) || match;
|
|
625
|
+
} );
|
|
626
|
+
} )
|
|
627
|
+
.join( '' );
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function parseTimeInstant( dateString: string, pattern: string, base: TimeInstant, config: { locale?: string, timeZone?: string } = {} ): TimeInstant {
|
|
631
|
+
// Parse the components from the date string
|
|
632
|
+
const parsed = parseTimeInstantComponents( dateString, pattern, config );
|
|
633
|
+
|
|
634
|
+
// Fill in missing values from the base instant
|
|
635
|
+
const params = {
|
|
636
|
+
...base.toParameters(),
|
|
637
|
+
...parsed
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Create and return the TimeInstant with complete parameters
|
|
641
|
+
return TimeInstant.fromParameters( params );
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function parseTimeInstantComponents( dateString: string, pattern: string, config: { locale?: string, timeZone?: string } = {} ): Partial<TTimeInstantParameters> {
|
|
645
|
+
// Create a regex pattern from the format pattern
|
|
646
|
+
let regexPattern = pattern;
|
|
647
|
+
const tokens: { type: string; length: number; position: number }[] = [];
|
|
648
|
+
let position = 0;
|
|
649
|
+
|
|
650
|
+
// Split by escape regex first, like the original implementation
|
|
651
|
+
const parts = pattern.split( ESCAPE_REGEX ).filter( ( sub ) => sub !== undefined );
|
|
652
|
+
|
|
653
|
+
// Rebuild pattern and track tokens
|
|
654
|
+
regexPattern = parts.map( ( sub, index ) => {
|
|
655
|
+
// Skip escaped strings (odd indices after split)
|
|
656
|
+
if ( index % 2 !== 0 ) {
|
|
657
|
+
return sub.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape special regex chars
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return sub.replace( PATTERN_REGEX, ( match ) => {
|
|
661
|
+
const type = match.charAt( 0 );
|
|
662
|
+
tokens.push( { type, length: match.length, position: position++ } );
|
|
663
|
+
|
|
664
|
+
// Create appropriate regex for each token type
|
|
665
|
+
switch ( type ) {
|
|
666
|
+
case 'y':
|
|
667
|
+
return match.length === 2 ? '(\\d{2})' : '(\\d{4})';
|
|
668
|
+
case 'M':
|
|
669
|
+
if ( match.length === 1 ) return '(\\d{1,2})';
|
|
670
|
+
if ( match.length === 2 ) return '(\\d{2})';
|
|
671
|
+
if ( match.length === 3 ) return '([A-Za-z.]{1,7})';
|
|
672
|
+
return '([A-Za-z]+)';
|
|
673
|
+
case 'd':
|
|
674
|
+
return match.length === 1 ? '(\\d{1,2})' : '(\\d{2})';
|
|
675
|
+
case 'H':
|
|
676
|
+
case 'h':
|
|
677
|
+
return match.length === 1 ? '(\\d{1,2})' : '(\\d{2})';
|
|
678
|
+
case 'm':
|
|
679
|
+
case 's':
|
|
680
|
+
return match.length === 1 ? '(\\d{1,2})' : '(\\d{2})';
|
|
681
|
+
case 'S':
|
|
682
|
+
return `(\\d{${match.length}})`;
|
|
683
|
+
case 'a':
|
|
684
|
+
return '([aApP][mM])';
|
|
685
|
+
case 'D':
|
|
686
|
+
if ( match.length === 1 ) return '([A-Za-z])';
|
|
687
|
+
if ( match.length === 2 ) return '([A-Za-z]{3})';
|
|
688
|
+
return '([A-Za-z]+)';
|
|
689
|
+
case 'G':
|
|
690
|
+
if ( match.length === 1 ) return '([A-Za-z])';
|
|
691
|
+
if ( match.length === 2 ) return '([A-Za-z]{2})';
|
|
692
|
+
return '([A-Za-z\\s]+)';
|
|
693
|
+
case 'Z':
|
|
694
|
+
return match.length === 1 ? '([A-Za-z0-9+\\-:]+)' : '([A-Za-z\\s]+)';
|
|
695
|
+
case 'P':
|
|
696
|
+
return '([A-Za-z\\s]+)';
|
|
697
|
+
default:
|
|
698
|
+
return match;
|
|
699
|
+
}
|
|
700
|
+
} );
|
|
701
|
+
} ).join( '' );
|
|
702
|
+
|
|
703
|
+
const regex = new RegExp( '^' + regexPattern + '$' );
|
|
704
|
+
const matches = dateString.match( regex );
|
|
705
|
+
|
|
706
|
+
if ( !matches ) {
|
|
707
|
+
throw new Error( `Date string "${dateString}" does not match pattern "${pattern}"` );
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Extract only the values that were actually parsed (no defaults)
|
|
711
|
+
const result: Partial<TTimeInstantParameters> = {};
|
|
712
|
+
const locale = config.locale || 'en';
|
|
713
|
+
let isPM = false;
|
|
714
|
+
let is12Hour = false;
|
|
715
|
+
let hourValue: number | undefined;
|
|
716
|
+
|
|
717
|
+
tokens.forEach( ( token, index ) => {
|
|
718
|
+
const value = matches[ index + 1 ];
|
|
719
|
+
|
|
720
|
+
switch ( token.type ) {
|
|
721
|
+
case 'y':
|
|
722
|
+
if ( token.length === 2 ) {
|
|
723
|
+
const shortYear = parseInt( value, 10 );
|
|
724
|
+
result.year = shortYear < 50 ? 2000 + shortYear : 1900 + shortYear;
|
|
725
|
+
} else {
|
|
726
|
+
result.year = parseInt( value, 10 );
|
|
727
|
+
}
|
|
728
|
+
break;
|
|
729
|
+
case 'M':
|
|
730
|
+
switch ( token.length ) {
|
|
731
|
+
case 1:
|
|
732
|
+
case 2:
|
|
733
|
+
result.month = parseInt( value, 10 ) as TMonth;
|
|
734
|
+
break;
|
|
735
|
+
case 3: {
|
|
736
|
+
const normalizedValue = normalizeMonthName( value );
|
|
737
|
+
const monthIndex = getMonthNames( locale ).short.findIndex( name =>
|
|
738
|
+
name.toLowerCase() === normalizedValue.toLowerCase()
|
|
739
|
+
);
|
|
740
|
+
if ( monthIndex === -1 )
|
|
741
|
+
throw new Error( `Invalid short month name in date string: ${dateString}` );
|
|
742
|
+
result.month = ( monthIndex + 1 ) as TMonth;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case 4: {
|
|
746
|
+
const normalizedValue = normalizeMonthName( value );
|
|
747
|
+
const monthIndex = getMonthNames( locale ).long.findIndex( name =>
|
|
748
|
+
name.toLowerCase() === normalizedValue.toLowerCase()
|
|
749
|
+
);
|
|
750
|
+
if ( monthIndex === -1 )
|
|
751
|
+
throw new Error( `Invalid full month name in date string: ${dateString}` );
|
|
752
|
+
result.month = ( monthIndex + 1 ) as TMonth;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
default:
|
|
756
|
+
throw new Error( `Invalid month pattern: ${token}` );
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
case 'd':
|
|
760
|
+
result.date = parseInt( value, 10 ) as TDayOfMonth;
|
|
761
|
+
break;
|
|
762
|
+
case 'H':
|
|
763
|
+
result.hours = parseInt( value, 10 ) as THourOfDay;
|
|
764
|
+
break;
|
|
765
|
+
case 'h':
|
|
766
|
+
hourValue = parseInt( value, 10 );
|
|
767
|
+
is12Hour = true;
|
|
768
|
+
break;
|
|
769
|
+
case 'm':
|
|
770
|
+
result.minutes = parseInt( value, 10 ) as TMinuteOfHour;
|
|
771
|
+
break;
|
|
772
|
+
case 's':
|
|
773
|
+
result.seconds = parseInt( value, 10 ) as TSecondOfMinute;
|
|
774
|
+
break;
|
|
775
|
+
case 'S':
|
|
776
|
+
let ms = parseInt( value, 10 );
|
|
777
|
+
// Normalize to milliseconds based on length
|
|
778
|
+
if ( token.length === 1 ) ms *= 100;
|
|
779
|
+
else if ( token.length === 2 ) ms *= 10;
|
|
780
|
+
result.milliseconds = ms as TMillisecondOfSecond;
|
|
781
|
+
break;
|
|
782
|
+
case 'a':
|
|
783
|
+
isPM = value.toLowerCase().includes( 'p' );
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
} );
|
|
787
|
+
|
|
788
|
+
// Handle 12-hour format conversion
|
|
789
|
+
if ( is12Hour && hourValue !== undefined ) {
|
|
790
|
+
if ( isPM && hourValue < 12 ) {
|
|
791
|
+
result.hours = ( hourValue + 12 ) as THourOfDay;
|
|
792
|
+
} else if ( !isPM && hourValue === 12 ) {
|
|
793
|
+
result.hours = 0 as THourOfDay;
|
|
794
|
+
} else {
|
|
795
|
+
result.hours = hourValue as THourOfDay;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Validate each extracted component (only validate primitives)
|
|
800
|
+
if ( typeof result.year === 'number' && !isValidYear( result.year ) ) {
|
|
801
|
+
throw new Error( `Invalid year in date string: ${dateString}` );
|
|
802
|
+
}
|
|
803
|
+
if ( typeof result.month === 'number' && !isValidMonth( result.month ) ) {
|
|
804
|
+
throw new Error( `Invalid month in date string: ${dateString}` );
|
|
805
|
+
}
|
|
806
|
+
if ( typeof result.date === 'number' && !isValidDayOfMonth( result.date ) ) {
|
|
807
|
+
throw new Error( `Invalid day in date string: ${dateString}` );
|
|
808
|
+
}
|
|
809
|
+
if ( typeof result.hours === 'number' && !isValidHour( result.hours ) ) {
|
|
810
|
+
throw new Error( `Invalid hour in date string: ${dateString}` );
|
|
811
|
+
}
|
|
812
|
+
if ( typeof result.minutes === 'number' && !isValidMinute( result.minutes ) ) {
|
|
813
|
+
throw new Error( `Invalid minute in date string: ${dateString}` );
|
|
814
|
+
}
|
|
815
|
+
if ( typeof result.seconds === 'number' && !isValidSecond( result.seconds ) ) {
|
|
816
|
+
throw new Error( `Invalid second in date string: ${dateString}` );
|
|
817
|
+
}
|
|
818
|
+
if ( typeof result.milliseconds === 'number' && !isValidMillisecond( result.milliseconds ) ) {
|
|
819
|
+
throw new Error( `Invalid millisecond in date string: ${dateString}` );
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return result;
|
|
823
|
+
}
|