@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,128 @@
1
+ import { CommaText, Err, Ok, Result } from "@pbkware/js-utils";
2
+
3
+ /**
4
+ * Individual style flags that control date/time parsing behavior.
5
+ *
6
+ * These flags can be combined to create custom parsing rules.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // Combine individual flags
11
+ * formatter.styles = new Set([
12
+ * DotNetDateTimeStyleId.AllowLeadingWhite,
13
+ * DotNetDateTimeStyleId.AllowTrailingWhite
14
+ * ]);
15
+ * ```
16
+ *
17
+ * @public
18
+ * @category DateTime Styles
19
+ */
20
+ export enum DotNetDateTimeStyleId {
21
+ /** Allow leading whitespace characters. */
22
+ AllowLeadingWhite = "AllowLeadingWhite",
23
+
24
+ /** Allow trailing whitespace characters. */
25
+ AllowTrailingWhite = "AllowTrailingWhite",
26
+
27
+ /** Allow whitespace within the date/time string. */
28
+ AllowInnerWhite = "AllowInnerWhite",
29
+
30
+ /** Do not use current date for missing date components. */
31
+ NoCurrentDateDefault = "NoCurrentDateDefault",
32
+
33
+ /** Adjust date/time to UTC (not implemented). */
34
+ AdjustToUniversal = "AdjustToUniversal",
35
+
36
+ /** Assume local time zone if not specified (not implemented). */
37
+ AssumeLocal = "AssumeLocal",
38
+
39
+ /** Assume UTC time zone if not specified (not implemented). */
40
+ AssumeUniversal = "AssumeUniversal",
41
+
42
+ /** Preserve DateTimeKind when parsing (not implemented). */
43
+ RoundTripKind = "RoundTripKind",
44
+ }
45
+
46
+ /**
47
+ * A set of {@link DotNetDateTimeStyleId} flags.
48
+ *
49
+ * @public
50
+ * @category DateTime Styles
51
+ */
52
+ export type DotNetDateTimeStyleSet = Set<DotNetDateTimeStyleId>;
53
+
54
+ /**
55
+ * Predefined date/time style combinations for common parsing scenarios.
56
+ *
57
+ * @internal
58
+ * @category DateTime Styles
59
+ */
60
+ export const DotNetDateTimeStyles = {
61
+ /** No styles - strict parsing. */
62
+ none: new Set<DotNetDateTimeStyleId>(),
63
+
64
+ /**
65
+ * Allow whitespace in date/time strings.
66
+ * Includes: AllowLeadingWhite, AllowTrailingWhite, AllowInnerWhite.
67
+ */
68
+ allowWhiteSpaces: new Set<DotNetDateTimeStyleId>([
69
+ DotNetDateTimeStyleId.AllowLeadingWhite,
70
+ DotNetDateTimeStyleId.AllowTrailingWhite,
71
+ DotNetDateTimeStyleId.AllowInnerWhite,
72
+ ]),
73
+ };
74
+
75
+ const isSameSet = (
76
+ left: DotNetDateTimeStyleSet,
77
+ right: DotNetDateTimeStyleSet,
78
+ ): boolean => {
79
+ if (left.size !== right.size) return false;
80
+ for (const value of left) {
81
+ if (!right.has(value)) return false;
82
+ }
83
+ return true;
84
+ };
85
+
86
+ /** @internal */
87
+ export class DotNetDateTimeStylesInfo {
88
+ static toString(styles: DotNetDateTimeStyleSet): string {
89
+ return this.toXmlValue(styles);
90
+ }
91
+
92
+ static toXmlValue(styles: DotNetDateTimeStyleSet): string {
93
+ if (isSameSet(styles, DotNetDateTimeStyles.allowWhiteSpaces)) {
94
+ return "AllowWhiteSpaces";
95
+ }
96
+ return CommaText.fromStringArray(Array.from(styles.values()));
97
+ }
98
+
99
+ static tryFromString(value: string): Result<DotNetDateTimeStyleSet> {
100
+ return this.tryFromXmlValue(value);
101
+ }
102
+
103
+ static tryFromXmlValue(value: string): Result<DotNetDateTimeStyleSet> {
104
+ const normalized = value.trim();
105
+ if (normalized.length === 0 || normalized.toLowerCase() === "none") {
106
+ return new Ok(new Set(DotNetDateTimeStyles.none));
107
+ }
108
+ if (normalized.toLowerCase() === "allowwhitespaces") {
109
+ return new Ok(new Set(DotNetDateTimeStyles.allowWhiteSpaces));
110
+ }
111
+
112
+ const commaTextResult = CommaText.tryToStringArray(normalized);
113
+ if (commaTextResult.isErr())
114
+ return commaTextResult.createOuter(
115
+ "Invalid comma-separated styles string",
116
+ );
117
+
118
+ const styles = new Set<DotNetDateTimeStyleId>();
119
+ for (const item of commaTextResult.value) {
120
+ const match = Object.values(DotNetDateTimeStyleId).find(
121
+ (x) => x.toLowerCase() === item.toLowerCase(),
122
+ );
123
+ if (match === undefined) return new Err(`Invalid style: ${item}`);
124
+ styles.add(match);
125
+ }
126
+ return new Ok(styles);
127
+ }
128
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./datetime-formatter.js";
2
+ export * from "./datetime-style.js";
3
+ export * from "./locale-settings.js";
4
+ export * from "./number-formatter.js";
5
+ export * from "./number-style.js";
@@ -0,0 +1,453 @@
1
+ import { Err, Ok, Result } from "@pbkware/js-utils";
2
+
3
+ const INVARIANT_LOCALE = "en-US";
4
+
5
+ function parseDate(value: string): Date | undefined {
6
+ const date = new Date(value);
7
+ if (Number.isNaN(date.getTime())) {
8
+ return undefined;
9
+ }
10
+ return date;
11
+ }
12
+
13
+ function getSeparators(locale: string): {
14
+ decimalSeparator: string;
15
+ thousandSeparator: string;
16
+ currencySymbol: string;
17
+ dateSeparator: string;
18
+ timeSeparator: string;
19
+ } {
20
+ const numberParts = new Intl.NumberFormat(locale, {
21
+ style: "decimal",
22
+ useGrouping: true,
23
+ }).formatToParts(12345.6);
24
+ const currencyParts = new Intl.NumberFormat(locale, {
25
+ style: "currency",
26
+ currency: "USD",
27
+ }).formatToParts(1);
28
+ const dateParts = new Intl.DateTimeFormat(locale, {
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit",
32
+ }).formatToParts(new Date(2024, 6, 5));
33
+ const timeParts = new Intl.DateTimeFormat(locale, {
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ second: "2-digit",
37
+ hour12: false,
38
+ }).formatToParts(new Date(2024, 6, 5, 13, 24, 35));
39
+
40
+ const decimalSeparator =
41
+ numberParts.find((x) => x.type === "decimal")?.value ?? ".";
42
+ const thousandSeparator =
43
+ numberParts.find((x) => x.type === "group")?.value ?? ",";
44
+ const currencySymbol =
45
+ currencyParts.find((x) => x.type === "currency")?.value ?? "$";
46
+ const dateSeparator =
47
+ dateParts.find((x) => x.type === "literal")?.value ?? "/";
48
+ const timeSeparator =
49
+ timeParts.find((x) => x.type === "literal")?.value ?? ":";
50
+
51
+ return {
52
+ decimalSeparator,
53
+ thousandSeparator,
54
+ currencySymbol,
55
+ dateSeparator,
56
+ timeSeparator,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Represents locale-specific formatting settings for dates, times, and numbers.
62
+ *
63
+ * Provides culture-specific separators and formatting options that determine how
64
+ * numbers, currencies, dates, and times are formatted and parsed.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * // Use invariant culture (en-US)
69
+ * const invariant = DotNetLocaleSettings.createInvariant();
70
+ *
71
+ * // Use specific locale
72
+ * const french = DotNetLocaleSettings.create('fr-FR');
73
+ * console.log(french.decimalSeparator); // ","
74
+ * console.log(french.thousandSeparator); // " "
75
+ *
76
+ * // Use current system locale
77
+ * const current = DotNetLocaleSettings.current;
78
+ * ```
79
+ *
80
+ * @public
81
+ * @category Locale Settings
82
+ */
83
+ export class DotNetLocaleSettings {
84
+ /**
85
+ * Singleton instance for invariant culture (en-US).
86
+ * Provides consistent formatting across all systems.
87
+ */
88
+ static readonly invariant = DotNetLocaleSettings.createInvariant();
89
+
90
+ /**
91
+ * Singleton instance for current system locale.
92
+ * Uses the locale settings from the runtime environment.
93
+ */
94
+ static readonly current = new DotNetLocaleSettings(undefined);
95
+
96
+ /** The locale identifier (same as name). */
97
+ readonly id: string;
98
+
99
+ /** The locale name (e.g., "en-US", "fr-FR"). */
100
+ readonly name: string;
101
+
102
+ /** Character used for decimal point (e.g., "." or ","). */
103
+ readonly decimalSeparator: string;
104
+
105
+ /** Character used for thousands grouping (e.g., "," or "." or " "). */
106
+ readonly thousandSeparator: string;
107
+
108
+ /** Currency symbol for this locale (e.g., "$", "€", "£"). */
109
+ readonly currencyString: string;
110
+
111
+ /** Character used between date components (e.g., "/", "-", "."). */
112
+ readonly dateSeparator: string;
113
+
114
+ /** Character used between time components (typically ":"). */
115
+ readonly timeSeparator: string;
116
+
117
+ /** Pre-configured number formatter for floating-point values. */
118
+ readonly defaultFloat: Intl.NumberFormat;
119
+
120
+ /** Pre-configured number formatter for high-precision decimal values. */
121
+ readonly defaultDecimal: Intl.NumberFormat;
122
+
123
+ /** Pre-configured number formatter for currency values. */
124
+ readonly defaultCurrency: Intl.NumberFormat;
125
+
126
+ /**
127
+ * Creates a new DotNetLocaleSettings instance.
128
+ *
129
+ * @param localeName - Optional locale name (e.g., "en-US", "fr-FR").
130
+ * If undefined, uses the system's current locale.
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * // System locale
135
+ * const settings = new DotNetLocaleSettings(undefined);
136
+ *
137
+ * // Specific locale
138
+ * const french = new DotNetLocaleSettings('fr-FR');
139
+ * ```
140
+ */
141
+ constructor(localeName?: string) {
142
+ this.name = localeName ?? Intl.DateTimeFormat().resolvedOptions().locale;
143
+ this.id = this.name;
144
+ const separators = getSeparators(this.name || INVARIANT_LOCALE);
145
+ this.decimalSeparator = separators.decimalSeparator;
146
+ this.thousandSeparator = separators.thousandSeparator;
147
+ this.currencyString = separators.currencySymbol;
148
+ this.dateSeparator = separators.dateSeparator;
149
+ this.timeSeparator = separators.timeSeparator;
150
+
151
+ this.defaultFloat = new Intl.NumberFormat(this.name, {
152
+ useGrouping: false,
153
+ maximumFractionDigits: 20,
154
+ });
155
+ this.defaultCurrency = new Intl.NumberFormat(this.name, {
156
+ style: "currency",
157
+ currency: "USD",
158
+ });
159
+ this.defaultDecimal = new Intl.NumberFormat(this.name, {
160
+ useGrouping: false,
161
+ minimumFractionDigits: 18,
162
+ maximumFractionDigits: 18,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Creates a DotNetLocaleSettings instance for the specified locale.
168
+ *
169
+ * @param localeName - The locale name (e.g., "en-US", "fr-FR", "de-DE").
170
+ * @returns A new DotNetLocaleSettings instance configured for the specified locale.
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * const usSettings = DotNetLocaleSettings.create('en-US');
175
+ * const frSettings = DotNetLocaleSettings.create('fr-FR');
176
+ * ```
177
+ */
178
+ static create(localeName: string): DotNetLocaleSettings {
179
+ return new DotNetLocaleSettings(localeName);
180
+ }
181
+
182
+ /**
183
+ * Creates a DotNetLocaleSettings instance for the invariant culture (en-US).
184
+ * The invariant culture provides consistent formatting across all systems.
185
+ *
186
+ * @returns A new DotNetLocaleSettings instance configured for invariant culture.
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * const settings = DotNetLocaleSettings.createInvariant();
191
+ * console.log(settings.decimalSeparator); // "."
192
+ * console.log(settings.thousandSeparator); // ","
193
+ * ```
194
+ */
195
+ static createInvariant(): DotNetLocaleSettings {
196
+ return new DotNetLocaleSettings(INVARIANT_LOCALE);
197
+ }
198
+
199
+ /**
200
+ * Converts a number or bigint to a string using locale-specific formatting.
201
+ *
202
+ * @param value - The number or bigint to format.
203
+ * @param options - Optional Intl.NumberFormatOptions for custom formatting.
204
+ * @returns The formatted number string.
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * const settings = DotNetLocaleSettings.create('en-US');
209
+ * settings.numberToStr(1234.56); // "1,234.56"
210
+ * settings.numberToStr(1234.56, { minimumFractionDigits: 2 }); // "1,234.56"
211
+ * ```
212
+ */
213
+ numberToStr(
214
+ value: number | bigint,
215
+ options?: Intl.NumberFormatOptions,
216
+ ): string {
217
+ return new Intl.NumberFormat(this.name, options).format(value);
218
+ }
219
+
220
+ /**
221
+ * Converts a Date to a string using locale-specific formatting.
222
+ *
223
+ * @param value - The Date to format.
224
+ * @param options - Optional Intl.DateTimeFormatOptions for custom formatting.
225
+ * @returns The formatted date string.
226
+ *
227
+ * @example
228
+ * ```typescript
229
+ * const settings = DotNetLocaleSettings.create('en-US');
230
+ * settings.dateToStr(new Date(2024, 6, 5), { dateStyle: 'short' }); // "7/5/2024"
231
+ * ```
232
+ */
233
+ dateToStr(value: Date, options?: Intl.DateTimeFormatOptions): string {
234
+ return new Intl.DateTimeFormat(this.name, options).format(value);
235
+ }
236
+
237
+ /**
238
+ * Converts a boolean to a string.
239
+ *
240
+ * @param value - The boolean value.
241
+ * @returns "true" or "false".
242
+ *
243
+ * @example
244
+ * ```typescript
245
+ * settings.boolToStr(true); // "true"
246
+ * settings.boolToStr(false); // "false"
247
+ * ```
248
+ */
249
+ boolToStr(value: boolean): string {
250
+ return value ? "true" : "false";
251
+ }
252
+
253
+ /**
254
+ * Attempts to parse a boolean value from a string.
255
+ *
256
+ * @param value - The string to parse. Accepts: "true", "1", "yes" (case-insensitive) for true,
257
+ * and "false", "0", "no" for false.
258
+ * @returns A Result containing the boolean value if successful, or an error if parsing fails.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * settings.tryStrToBool('yes'); // Ok(true)
263
+ * settings.tryStrToBool('false'); // Ok(false)
264
+ * settings.tryStrToBool('invalid'); // Err("Invalid boolean string")
265
+ * ```
266
+ */
267
+ tryStrToBool(value: string): Result<boolean> {
268
+ const normalized = value.trim().toLowerCase();
269
+ if (["true", "1", "yes"].includes(normalized)) {
270
+ return new Ok(true);
271
+ }
272
+ if (["false", "0", "no"].includes(normalized)) {
273
+ return new Ok(false);
274
+ }
275
+ return new Err("Invalid boolean string");
276
+ }
277
+
278
+ /**
279
+ * Attempts to parse an integer from a string.
280
+ *
281
+ * @param value - The string to parse. Must contain only digits and an optional leading +/- sign.
282
+ * @returns A Result containing the parsed integer if successful, or an error if parsing fails.
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * settings.tryStrToInt('123'); // Ok(123)
287
+ * settings.tryStrToInt('-456'); // Ok(-456)
288
+ * settings.tryStrToInt('12.34'); // Err("Invalid integer string")
289
+ * ```
290
+ */
291
+ tryStrToInt(value: string): Result<number> {
292
+ if (!/^[+-]?\d+$/.test(value.trim())) {
293
+ return new Err("Invalid integer string");
294
+ }
295
+ const parsed = Number.parseInt(value, 10);
296
+ return Number.isNaN(parsed)
297
+ ? new Err("Invalid integer string")
298
+ : new Ok(parsed);
299
+ }
300
+
301
+ /**
302
+ * Attempts to parse a BigInt from a string.
303
+ *
304
+ * @param value - The string to parse. Must contain only digits and an optional leading +/- sign.
305
+ * @returns A Result containing the parsed BigInt if successful, or an error if parsing fails.
306
+ *
307
+ * @example
308
+ * ```typescript
309
+ * settings.tryStrToBigInt('12345678901234567890'); // Ok(12345678901234567890n)
310
+ * settings.tryStrToBigInt('-999'); // Ok(-999n)
311
+ * ```
312
+ */
313
+ tryStrToBigInt(value: string): Result<bigint> {
314
+ if (!/^[+-]?\d+$/.test(value.trim())) {
315
+ return new Err("Invalid integer string");
316
+ }
317
+ try {
318
+ return new Ok(BigInt(value.trim()));
319
+ } catch {
320
+ return new Err("Invalid integer string");
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Attempts to parse a number from a string using locale-specific formatting.
326
+ * Handles currency symbols, thousands separators, and locale-specific decimal separators.
327
+ *
328
+ * @param value - The string to parse.
329
+ * @returns A Result containing the parsed number if successful, or an error if parsing fails.
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * const usSettings = DotNetLocaleSettings.create('en-US');
334
+ * usSettings.tryStrToNumber('1,234.56'); // Ok(1234.56)
335
+ * usSettings.tryStrToNumber('$1,234.56'); // Ok(1234.56)
336
+ *
337
+ * const frSettings = DotNetLocaleSettings.create('fr-FR');
338
+ * frSettings.tryStrToNumber('1 234,56'); // Ok(1234.56)
339
+ * ```
340
+ */
341
+ tryStrToNumber(value: string): Result<number> {
342
+ const normalized = value
343
+ .trim()
344
+ .replaceAll(this.currencyString, "")
345
+ .replaceAll(this.thousandSeparator, "")
346
+ .replace(this.decimalSeparator, ".");
347
+ const parsed = Number.parseFloat(normalized);
348
+ return Number.isNaN(parsed)
349
+ ? new Err("Invalid float string")
350
+ : new Ok(parsed);
351
+ }
352
+
353
+ /**
354
+ * Attempts to parse a Date from a string.
355
+ *
356
+ * @param value - The string to parse. Accepts various date formats understood by JavaScript Date constructor.
357
+ * @returns A Result containing the parsed Date if successful, or an error if parsing fails.
358
+ *
359
+ * @example
360
+ * ```typescript
361
+ * settings.tryStrToDate('2024-07-05'); // Ok(Date)
362
+ * settings.tryStrToDate('July 5, 2024'); // Ok(Date)
363
+ * settings.tryStrToDate('invalid'); // Err("Invalid date string")
364
+ * ```
365
+ */
366
+ tryStrToDate(value: string): Result<Date> {
367
+ const parsed = parseDate(value.trim());
368
+ return parsed === undefined
369
+ ? new Err("Invalid date string")
370
+ : new Ok(parsed);
371
+ }
372
+
373
+ /**
374
+ * Convert a single character to uppercase using culture-specific rules.
375
+ * Handles special cases like Turkish 'i' → 'İ' vs standard 'i' → 'I'.
376
+ *
377
+ * @param char - The character to convert to uppercase.
378
+ * @returns The uppercased character.
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * const usSettings = DotNetLocaleSettings.create('en-US');
383
+ * usSettings.toUpperChar('i'); // "I"
384
+ *
385
+ * const trSettings = DotNetLocaleSettings.create('tr-TR');
386
+ * trSettings.toUpperChar('i'); // "İ" (Turkish dotted I)
387
+ * ```
388
+ */
389
+ toUpperChar(char: string): string {
390
+ if (char.length === 0) {
391
+ return char;
392
+ }
393
+
394
+ // For single character conversion, use Intl.Collator with locale-specific rules
395
+ // Most locales follow standard Unicode case mapping
396
+ const single = char[0];
397
+
398
+ // Turkish locale has special rules for 'i' and 'ı'
399
+ if (this.name.startsWith("tr")) {
400
+ // Turkish lowercase: i (U+0069) -> Turkish uppercase: İ (U+0130)
401
+ // Turkish lowercase: ı (U+0131) -> Turkish uppercase: I (U+0049)
402
+ if (single === "i") {
403
+ return "İ";
404
+ }
405
+ if (single === "ı") {
406
+ return "I";
407
+ }
408
+ }
409
+
410
+ // For other locales, use standard toUpperCase
411
+ return single.toUpperCase();
412
+ }
413
+
414
+ /**
415
+ * Convert a single character to lowercase using culture-specific rules.
416
+ * Handles special cases like Turkish 'I' → 'ı' vs Turkish 'İ' → 'i'.
417
+ *
418
+ * @param char - The character to convert to lowercase.
419
+ * @returns The lowercased character.
420
+ *
421
+ * @example
422
+ * ```typescript
423
+ * const usSettings = DotNetLocaleSettings.create('en-US');
424
+ * usSettings.toLowerChar('I'); // "i"
425
+ *
426
+ * const trSettings = DotNetLocaleSettings.create('tr-TR');
427
+ * trSettings.toLowerChar('I'); // "ı" (Turkish dotless i)
428
+ * trSettings.toLowerChar('İ'); // "i" (Turkish dotted i)
429
+ * ```
430
+ */
431
+ toLowerChar(char: string): string {
432
+ if (char.length === 0) {
433
+ return char;
434
+ }
435
+
436
+ const single = char[0];
437
+
438
+ // Turkish locale has special rules for 'I' and 'İ'
439
+ if (this.name.startsWith("tr")) {
440
+ // Turkish uppercase: I (U+0049) -> Turkish lowercase: ı (U+0131)
441
+ // Turkish uppercase: İ (U+0130) -> Turkish lowercase: i (U+0069)
442
+ if (single === "I") {
443
+ return "ı";
444
+ }
445
+ if (single === "İ") {
446
+ return "i";
447
+ }
448
+ }
449
+
450
+ // For other locales, use standard toLowerCase
451
+ return single.toLowerCase();
452
+ }
453
+ }