@pbkware/dot-net-date-number-formatting 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,718 @@
1
+ import { Err, Ok, Result } from "@pbkware/js-utils";
2
+ import {
3
+ DotNetDateTimeStyleId,
4
+ DotNetDateTimeStyleSet,
5
+ DotNetDateTimeStyles,
6
+ } from "./datetime-style.js";
7
+ import { DotNetLocaleSettings } from "./locale-settings.js";
8
+
9
+ type ElementType =
10
+ | "standard"
11
+ | "day"
12
+ | "day2"
13
+ | "dayNameShort"
14
+ | "dayNameLong"
15
+ | "month"
16
+ | "month2"
17
+ | "monthNameShort"
18
+ | "monthNameLong"
19
+ | "year2"
20
+ | "year3"
21
+ | "year4"
22
+ | "year5"
23
+ | "hour12"
24
+ | "hour12_2"
25
+ | "hour24"
26
+ | "hour24_2"
27
+ | "minute"
28
+ | "minute2"
29
+ | "second"
30
+ | "second2"
31
+ | "fraction"
32
+ | "fractionTrim"
33
+ | "amPm1"
34
+ | "amPm2"
35
+ | "dateSep"
36
+ | "timeSep"
37
+ | "literal";
38
+
39
+ interface Element {
40
+ type: ElementType;
41
+ len?: number;
42
+ text?: string;
43
+ }
44
+
45
+ const dayNamesShort = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
46
+ const dayNamesLong = [
47
+ "Sunday",
48
+ "Monday",
49
+ "Tuesday",
50
+ "Wednesday",
51
+ "Thursday",
52
+ "Friday",
53
+ "Saturday",
54
+ ];
55
+ const monthNamesShort = [
56
+ "Jan",
57
+ "Feb",
58
+ "Mar",
59
+ "Apr",
60
+ "May",
61
+ "Jun",
62
+ "Jul",
63
+ "Aug",
64
+ "Sep",
65
+ "Oct",
66
+ "Nov",
67
+ "Dec",
68
+ ];
69
+ const monthNamesLong = [
70
+ "January",
71
+ "February",
72
+ "March",
73
+ "April",
74
+ "May",
75
+ "June",
76
+ "July",
77
+ "August",
78
+ "September",
79
+ "October",
80
+ "November",
81
+ "December",
82
+ ];
83
+
84
+ function pad(value: number, len: number): string {
85
+ return value.toString().padStart(len, "0");
86
+ }
87
+
88
+ function formatStandard(token: string, date: Date, locale: string): string {
89
+ switch (token) {
90
+ case "d":
91
+ return new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(
92
+ date,
93
+ );
94
+ case "D":
95
+ return new Intl.DateTimeFormat(locale, { dateStyle: "full" }).format(
96
+ date,
97
+ );
98
+ case "f":
99
+ return new Intl.DateTimeFormat(locale, {
100
+ dateStyle: "full",
101
+ timeStyle: "short",
102
+ }).format(date);
103
+ case "F":
104
+ return new Intl.DateTimeFormat(locale, {
105
+ dateStyle: "full",
106
+ timeStyle: "medium",
107
+ }).format(date);
108
+ case "g":
109
+ return new Intl.DateTimeFormat(locale, {
110
+ dateStyle: "short",
111
+ timeStyle: "short",
112
+ }).format(date);
113
+ case "G":
114
+ return new Intl.DateTimeFormat(locale, {
115
+ dateStyle: "short",
116
+ timeStyle: "medium",
117
+ }).format(date);
118
+ case "M":
119
+ case "m":
120
+ return `${monthNamesLong[date.getMonth()]} ${date.getDate()}`;
121
+ case "O":
122
+ case "o":
123
+ return date.toISOString();
124
+ case "s":
125
+ return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}T${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
126
+ case "t":
127
+ return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(
128
+ date,
129
+ );
130
+ case "T":
131
+ return new Intl.DateTimeFormat(locale, { timeStyle: "medium" }).format(
132
+ date,
133
+ );
134
+ case "u":
135
+ return date.toISOString().replace("T", " ").replace("Z", "Z");
136
+ case "Y":
137
+ case "y":
138
+ return `${pad(date.getFullYear(), 4)} ${monthNamesLong[date.getMonth()]}`;
139
+ default:
140
+ return "?";
141
+ }
142
+ }
143
+
144
+ function tokenizeCustom(format: string): Result<Element[]> {
145
+ const elements: Element[] = [];
146
+ let index = 0;
147
+
148
+ const pushLiteral = (text: string) => {
149
+ if (text.length > 0) {
150
+ elements.push({ type: "literal", text });
151
+ }
152
+ };
153
+
154
+ while (index < format.length) {
155
+ const current = format[index];
156
+
157
+ if (current === "'" || current === '"') {
158
+ const quote = current;
159
+ index += 1;
160
+ let literal = "";
161
+ while (index < format.length && format[index] !== quote) {
162
+ literal += format[index];
163
+ index += 1;
164
+ }
165
+ if (index >= format.length) {
166
+ return new Err("Unterminated quoted literal in format");
167
+ }
168
+ index += 1;
169
+ pushLiteral(literal);
170
+ continue;
171
+ }
172
+
173
+ if (current === "\\") {
174
+ index += 1;
175
+ if (index >= format.length) {
176
+ return new Err("Single-char literal escape at end of format");
177
+ }
178
+ pushLiteral(format[index]);
179
+ index += 1;
180
+ continue;
181
+ }
182
+
183
+ if (current === "%") {
184
+ index += 1;
185
+ continue;
186
+ }
187
+
188
+ let runLength = 1;
189
+ while (
190
+ index + runLength < format.length &&
191
+ format[index + runLength] === current
192
+ ) {
193
+ runLength += 1;
194
+ }
195
+
196
+ const consume = () => {
197
+ index += runLength;
198
+ };
199
+
200
+ switch (current) {
201
+ case "d":
202
+ if (runLength === 1) elements.push({ type: "day" });
203
+ else if (runLength === 2) elements.push({ type: "day2" });
204
+ else if (runLength === 3) elements.push({ type: "dayNameShort" });
205
+ else if (runLength === 4) elements.push({ type: "dayNameLong" });
206
+ else return new Err("Too many repeated d characters");
207
+ consume();
208
+ break;
209
+ case "M":
210
+ if (runLength === 1) elements.push({ type: "month" });
211
+ else if (runLength === 2) elements.push({ type: "month2" });
212
+ else if (runLength === 3) elements.push({ type: "monthNameShort" });
213
+ else if (runLength === 4) elements.push({ type: "monthNameLong" });
214
+ else return new Err("Too many repeated M characters");
215
+ consume();
216
+ break;
217
+ case "y":
218
+ if (runLength === 1 || runLength === 2)
219
+ elements.push({ type: "year2" });
220
+ else if (runLength === 3) elements.push({ type: "year3" });
221
+ else if (runLength === 4) elements.push({ type: "year4" });
222
+ else if (runLength === 5) elements.push({ type: "year5" });
223
+ else return new Err("Too many repeated y characters");
224
+ consume();
225
+ break;
226
+ case "h":
227
+ elements.push({ type: runLength === 1 ? "hour12" : "hour12_2" });
228
+ consume();
229
+ break;
230
+ case "H":
231
+ elements.push({ type: runLength === 1 ? "hour24" : "hour24_2" });
232
+ consume();
233
+ break;
234
+ case "m":
235
+ elements.push({ type: runLength === 1 ? "minute" : "minute2" });
236
+ consume();
237
+ break;
238
+ case "s":
239
+ elements.push({ type: runLength === 1 ? "second" : "second2" });
240
+ consume();
241
+ break;
242
+ case "f":
243
+ elements.push({ type: "fraction", len: Math.min(runLength, 7) });
244
+ consume();
245
+ break;
246
+ case "F":
247
+ elements.push({ type: "fractionTrim", len: Math.min(runLength, 7) });
248
+ consume();
249
+ break;
250
+ case "t":
251
+ elements.push({ type: runLength === 1 ? "amPm1" : "amPm2" });
252
+ consume();
253
+ break;
254
+ case "/":
255
+ elements.push({ type: "dateSep" });
256
+ consume();
257
+ break;
258
+ case ":":
259
+ elements.push({ type: "timeSep" });
260
+ consume();
261
+ break;
262
+ default:
263
+ pushLiteral(current.repeat(runLength));
264
+ consume();
265
+ break;
266
+ }
267
+ }
268
+
269
+ return new Ok(elements);
270
+ }
271
+
272
+ /**
273
+ * Formatter for dates and times using .NET-compatible format strings.
274
+ *
275
+ * Supports both standard format strings (d, D, f, F, g, G, M, O, s, t, T, u, Y) and
276
+ * custom format strings with specific date/time components (yyyy, MM, dd, HH, mm, ss, etc.).
277
+ *
278
+ * @example
279
+ * ```typescript
280
+ * const formatter = new DotNetDateTimeFormatter();
281
+ * formatter.localeSettings = DotNetLocaleSettings.createInvariant();
282
+ *
283
+ * // Standard format strings
284
+ * formatter.trySetFormat('d'); // Short date
285
+ * console.log(formatter.toString(new Date(2024, 6, 5))); // "7/5/2024"
286
+ *
287
+ * formatter.trySetFormat('F'); // Full date/time
288
+ * console.log(formatter.toString(new Date(2024, 6, 5, 14, 30, 0)));
289
+ * // "Friday, July 5, 2024 2:30:00 PM"
290
+ *
291
+ * // Custom format strings
292
+ * formatter.trySetFormat('yyyy-MM-dd');
293
+ * console.log(formatter.toString(new Date(2024, 6, 5))); // "2024-07-05"
294
+ *
295
+ * formatter.trySetFormat("dddd, MMMM d 'at' h:mm tt");
296
+ * console.log(formatter.toString(new Date(2024, 6, 5, 14, 30, 0)));
297
+ * // "Friday, July 5 at 2:30 PM"
298
+ * ```
299
+ *
300
+ * @public
301
+ * @category DateTime Formatting
302
+ */
303
+ export class DotNetDateTimeFormatter {
304
+ /**
305
+ * Set of date/time style flags that are not currently supported by this implementation.
306
+ * Operations using these styles will fail.
307
+ */
308
+ static readonly unsupportedStyles = new Set<DotNetDateTimeStyleId>([
309
+ DotNetDateTimeStyleId.AdjustToUniversal,
310
+ DotNetDateTimeStyleId.AssumeLocal,
311
+ DotNetDateTimeStyleId.AssumeUniversal,
312
+ DotNetDateTimeStyleId.RoundTripKind,
313
+ ]);
314
+
315
+ private format = "";
316
+
317
+ /**
318
+ * The set of {@link DotNetDateTimeStyleId} flags that control date/time parsing behavior.
319
+ * Currently only used for future parsing functionality.
320
+ */
321
+ styles: DotNetDateTimeStyleSet = new Set(DotNetDateTimeStyles.none);
322
+
323
+ /**
324
+ * The locale settings that determine date/time separators and formatting conventions.
325
+ */
326
+ localeSettings = DotNetLocaleSettings.current;
327
+
328
+ private formatIsStandard = false;
329
+ private elements: Element[] = [];
330
+
331
+ /**
332
+ * Contains the error message from the last failed operation.
333
+ * Check this property if {@link DotNetDateTimeFormatter.trySetFormat} returns an error.
334
+ */
335
+ parseErrorText = "";
336
+
337
+ /**
338
+ * Sets the format string to use for formatting dates and times.
339
+ *
340
+ * The format string is tokenized to confirm validity and to optimize formatting/parsing operations.
341
+ *
342
+ * @param value - A standard format string (single character like 'd', 'F', 'o') or
343
+ * custom format string (e.g., "yyyy-MM-dd HH:mm:ss").
344
+ * @returns A Result indicating success or containing an error message if the format string is invalid.
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * // Standard format
349
+ * formatter.trySetFormat('d'); // Short date
350
+ *
351
+ * // Custom format
352
+ * formatter.trySetFormat('yyyy-MM-dd HH:mm:ss');
353
+ *
354
+ * // Check for errors
355
+ * const result = formatter.trySetFormat('');
356
+ * if (result.isErr()) {
357
+ * console.error(result.error); // "Format text cannot be empty"
358
+ * }
359
+ * ```
360
+ */
361
+ trySetFormat(value: string): Result<void> {
362
+ if (value.length === 0) {
363
+ return new Err("Format text cannot be empty");
364
+ }
365
+
366
+ this.format = value;
367
+ this.formatIsStandard = value.length === 1;
368
+
369
+ if (this.formatIsStandard) {
370
+ this.elements = [{ type: "standard", text: value }];
371
+ return new Ok(undefined);
372
+ }
373
+
374
+ const parsed = tokenizeCustom(value);
375
+ if (parsed.isErr()) {
376
+ return parsed.createType<void>();
377
+ }
378
+
379
+ this.elements = parsed.value;
380
+ return new Ok(undefined);
381
+ }
382
+
383
+ /**
384
+ * Formats a Date using the current format string.
385
+ *
386
+ * @param value - The Date to format.
387
+ * @returns The formatted date/time string.
388
+ *
389
+ * @example
390
+ * ```typescript
391
+ * const date = new Date(2024, 6, 5, 14, 30, 45);
392
+ *
393
+ * formatter.trySetFormat('d');
394
+ * console.log(formatter.toString(date)); // "7/5/2024"
395
+ *
396
+ * formatter.trySetFormat('yyyy-MM-dd HH:mm:ss');
397
+ * console.log(formatter.toString(date)); // "2024-07-05 14:30:45"
398
+ * ```
399
+ */
400
+ toString(value: Date): string {
401
+ if (this.formatIsStandard) {
402
+ return formatStandard(this.format, value, this.localeSettings.name);
403
+ }
404
+
405
+ let result = "";
406
+ const hour = value.getHours();
407
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
408
+
409
+ for (const element of this.elements) {
410
+ switch (element.type) {
411
+ case "day":
412
+ result += value.getDate().toString();
413
+ break;
414
+ case "day2":
415
+ result += pad(value.getDate(), 2);
416
+ break;
417
+ case "dayNameShort":
418
+ result += dayNamesShort[value.getDay()];
419
+ break;
420
+ case "dayNameLong":
421
+ result += dayNamesLong[value.getDay()];
422
+ break;
423
+ case "month":
424
+ result += (value.getMonth() + 1).toString();
425
+ break;
426
+ case "month2":
427
+ result += pad(value.getMonth() + 1, 2);
428
+ break;
429
+ case "monthNameShort":
430
+ result += monthNamesShort[value.getMonth()];
431
+ break;
432
+ case "monthNameLong":
433
+ result += monthNamesLong[value.getMonth()];
434
+ break;
435
+ case "year2":
436
+ result += (value.getFullYear() % 100).toString();
437
+ break;
438
+ case "year3":
439
+ result += pad(value.getFullYear(), 3);
440
+ break;
441
+ case "year4":
442
+ result += pad(value.getFullYear(), 4);
443
+ break;
444
+ case "year5":
445
+ result += pad(value.getFullYear(), 5);
446
+ break;
447
+ case "hour12":
448
+ result += hour12.toString();
449
+ break;
450
+ case "hour12_2":
451
+ result += pad(hour12, 2);
452
+ break;
453
+ case "hour24":
454
+ result += hour.toString();
455
+ break;
456
+ case "hour24_2":
457
+ result += pad(hour, 2);
458
+ break;
459
+ case "minute":
460
+ result += value.getMinutes().toString();
461
+ break;
462
+ case "minute2":
463
+ result += pad(value.getMinutes(), 2);
464
+ break;
465
+ case "second":
466
+ result += value.getSeconds().toString();
467
+ break;
468
+ case "second2":
469
+ result += pad(value.getSeconds(), 2);
470
+ break;
471
+ case "fraction": {
472
+ const ms = pad(value.getMilliseconds(), 3);
473
+ result += (ms + "0000000").slice(0, element.len ?? 3);
474
+ break;
475
+ }
476
+ case "fractionTrim": {
477
+ const ms = pad(value.getMilliseconds(), 3);
478
+ result += (ms + "0000000")
479
+ .slice(0, element.len ?? 3)
480
+ .replace(/0+$/, "");
481
+ break;
482
+ }
483
+ case "amPm1":
484
+ result += hour < 12 ? "A" : "P";
485
+ break;
486
+ case "amPm2":
487
+ result += hour < 12 ? "AM" : "PM";
488
+ break;
489
+ case "dateSep":
490
+ result += this.localeSettings.dateSeparator;
491
+ break;
492
+ case "timeSep":
493
+ result += this.localeSettings.timeSeparator;
494
+ break;
495
+ case "literal":
496
+ result += element.text ?? "";
497
+ break;
498
+ case "standard":
499
+ result += formatStandard(
500
+ element.text ?? "",
501
+ value,
502
+ this.localeSettings.name,
503
+ );
504
+ break;
505
+ }
506
+ }
507
+
508
+ return result;
509
+ }
510
+
511
+ tryFromString(strValue: string): Result<Date> {
512
+ let parseText = strValue;
513
+
514
+ if (
515
+ this.styles.has(DotNetDateTimeStyleId.AllowLeadingWhite) &&
516
+ this.styles.has(DotNetDateTimeStyleId.AllowTrailingWhite)
517
+ ) {
518
+ parseText = parseText.trim();
519
+ } else if (this.styles.has(DotNetDateTimeStyleId.AllowLeadingWhite)) {
520
+ parseText = parseText.trimStart();
521
+ } else if (this.styles.has(DotNetDateTimeStyleId.AllowTrailingWhite)) {
522
+ parseText = parseText.trimEnd();
523
+ }
524
+
525
+ if (parseText.length === 0) {
526
+ return new Err("DateTime string is empty");
527
+ }
528
+
529
+ if (this.formatIsStandard) {
530
+ const parsed = new Date(parseText);
531
+ if (Number.isNaN(parsed.getTime())) {
532
+ return new Err("Invalid Date string");
533
+ }
534
+ return new Ok(parsed);
535
+ }
536
+
537
+ const regexParts: string[] = ["^"];
538
+ const setters: Array<
539
+ (
540
+ match: string,
541
+ state: {
542
+ y: number;
543
+ m: number;
544
+ d: number;
545
+ hh: number;
546
+ mm: number;
547
+ ss: number;
548
+ ms: number;
549
+ ampm: number;
550
+ },
551
+ ) => void
552
+ > = [];
553
+
554
+ for (const element of this.elements) {
555
+ switch (element.type) {
556
+ case "day":
557
+ case "day2":
558
+ regexParts.push("(\\d{1,2})");
559
+ setters.push((v, s) => {
560
+ s.d = Number.parseInt(v, 10);
561
+ });
562
+ break;
563
+ case "month":
564
+ case "month2":
565
+ regexParts.push("(\\d{1,2})");
566
+ setters.push((v, s) => {
567
+ s.m = Number.parseInt(v, 10);
568
+ });
569
+ break;
570
+ case "year2":
571
+ regexParts.push("(\\d{1,2})");
572
+ setters.push((v, s) => {
573
+ const yr = Number.parseInt(v, 10);
574
+ s.y = yr < 50 ? 2000 + yr : 1900 + yr;
575
+ });
576
+ break;
577
+ case "year3":
578
+ case "year4":
579
+ case "year5":
580
+ regexParts.push("(\\d{3,5})");
581
+ setters.push((v, s) => {
582
+ s.y = Number.parseInt(v, 10);
583
+ });
584
+ break;
585
+ case "hour12":
586
+ case "hour12_2":
587
+ regexParts.push("(\\d{1,2})");
588
+ setters.push((v, s) => {
589
+ s.hh = Number.parseInt(v, 10);
590
+ });
591
+ break;
592
+ case "hour24":
593
+ case "hour24_2":
594
+ regexParts.push("(\\d{1,2})");
595
+ setters.push((v, s) => {
596
+ s.hh = Number.parseInt(v, 10);
597
+ });
598
+ break;
599
+ case "minute":
600
+ case "minute2":
601
+ regexParts.push("(\\d{1,2})");
602
+ setters.push((v, s) => {
603
+ s.mm = Number.parseInt(v, 10);
604
+ });
605
+ break;
606
+ case "second":
607
+ case "second2":
608
+ regexParts.push("(\\d{1,2})");
609
+ setters.push((v, s) => {
610
+ s.ss = Number.parseInt(v, 10);
611
+ });
612
+ break;
613
+ case "fraction":
614
+ case "fractionTrim":
615
+ regexParts.push("(\\d{1,7})");
616
+ setters.push((v, s) => {
617
+ s.ms = Number.parseInt((v + "000").slice(0, 3), 10);
618
+ });
619
+ break;
620
+ case "amPm1":
621
+ regexParts.push("([APap])");
622
+ setters.push((v, s) => {
623
+ s.ampm = v.toUpperCase() === "P" ? 2 : 1;
624
+ });
625
+ break;
626
+ case "amPm2":
627
+ regexParts.push("(AM|PM|am|pm)");
628
+ setters.push((v, s) => {
629
+ s.ampm = v.toUpperCase() === "PM" ? 2 : 1;
630
+ });
631
+ break;
632
+ case "dayNameShort":
633
+ regexParts.push(`(${dayNamesShort.join("|")})`);
634
+ setters.push(() => undefined);
635
+ break;
636
+ case "dayNameLong":
637
+ regexParts.push(`(${dayNamesLong.join("|")})`);
638
+ setters.push(() => undefined);
639
+ break;
640
+ case "monthNameShort":
641
+ regexParts.push(`(${monthNamesShort.join("|")})`);
642
+ setters.push((v, s) => {
643
+ s.m =
644
+ monthNamesShort.findIndex(
645
+ (x) => x.toLowerCase() === v.toLowerCase(),
646
+ ) + 1;
647
+ });
648
+ break;
649
+ case "monthNameLong":
650
+ regexParts.push(`(${monthNamesLong.join("|")})`);
651
+ setters.push((v, s) => {
652
+ s.m =
653
+ monthNamesLong.findIndex(
654
+ (x) => x.toLowerCase() === v.toLowerCase(),
655
+ ) + 1;
656
+ });
657
+ break;
658
+ case "dateSep":
659
+ regexParts.push(
660
+ this.localeSettings.dateSeparator.replace(
661
+ /[.*+?^${}()|[\]\\]/g,
662
+ "\\$&",
663
+ ),
664
+ );
665
+ break;
666
+ case "timeSep":
667
+ regexParts.push(
668
+ this.localeSettings.timeSeparator.replace(
669
+ /[.*+?^${}()|[\]\\]/g,
670
+ "\\$&",
671
+ ),
672
+ );
673
+ break;
674
+ case "literal":
675
+ regexParts.push(
676
+ (element.text ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
677
+ );
678
+ break;
679
+ case "standard":
680
+ break;
681
+ }
682
+ }
683
+
684
+ regexParts.push("$");
685
+ const re = new RegExp(regexParts.join(""));
686
+ const match = re.exec(parseText);
687
+ if (match === null) {
688
+ return new Err("DateTime string does not match all Format specifiers");
689
+ }
690
+
691
+ const state = { y: 1, m: 1, d: 1, hh: 0, mm: 0, ss: 0, ms: 0, ampm: 0 };
692
+ let group = 1;
693
+ for (const setter of setters) {
694
+ setter(match[group], state);
695
+ group += 1;
696
+ }
697
+
698
+ if (state.ampm > 0) {
699
+ if (state.hh === 12) state.hh = 0;
700
+ if (state.ampm === 2) state.hh += 12;
701
+ }
702
+
703
+ const value = new Date(
704
+ state.y,
705
+ state.m - 1,
706
+ state.d,
707
+ state.hh,
708
+ state.mm,
709
+ state.ss,
710
+ state.ms,
711
+ );
712
+ if (Number.isNaN(value.getTime())) {
713
+ return new Err("Year, Month or Day is invalid");
714
+ }
715
+
716
+ return new Ok(value);
717
+ }
718
+ }