539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
import type { TypeAndName, TypeAndDisplayName } from '@/core/base.ts';
|
||
|
||
export type HiddenAmount = '***';
|
||
|
||
export interface NumberFormatOptions {
|
||
readonly numeralSystem: NumeralSystem;
|
||
readonly digitGrouping: DigitGroupingType;
|
||
readonly digitGroupingSymbol: string;
|
||
readonly decimalSeparator: string;
|
||
readonly decimalNumberCount?: number;
|
||
readonly trimTailZero?: boolean;
|
||
}
|
||
|
||
export interface NumberWithSuffix {
|
||
readonly value: number;
|
||
readonly suffix: string;
|
||
}
|
||
|
||
export interface NumeralSymbolType {
|
||
readonly type: number;
|
||
readonly name: string;
|
||
readonly symbol: string;
|
||
}
|
||
|
||
export interface LocalizedNumeralSymbolType extends TypeAndDisplayName {
|
||
readonly type: number;
|
||
readonly symbol: string;
|
||
readonly displayName: string;
|
||
}
|
||
|
||
export interface LocalizedDigitGroupingType extends TypeAndDisplayName {
|
||
readonly type: number;
|
||
readonly enabled: boolean;
|
||
readonly displayName: string;
|
||
}
|
||
|
||
export class NumeralSystem implements TypeAndName {
|
||
private static readonly allInstances: NumeralSystem[] = [];
|
||
private static readonly allInstancesByType: Record<number, NumeralSystem> = {};
|
||
private static readonly allInstancesByTypeName: Record<string, NumeralSystem> = {};
|
||
private static readonly allDigitsToWesternArabic: Record<string, number> = {};
|
||
private static readonly allDigitsToNumeralSystem: Record<string, NumeralSystem> = {};
|
||
|
||
public static readonly LanguageDefaultType: number = 0;
|
||
public static readonly WesternArabicNumerals = new NumeralSystem(1, 'WesternArabicNumerals', 'Western Arabic Numerals', '\u0030');
|
||
public static readonly EasternArabicNumerals = new NumeralSystem(2, 'EasternArabicNumerals', 'Eastern Arabic Numerals', '\u0660');
|
||
public static readonly PersianDigits = new NumeralSystem(3, 'PersianDigits', 'Persian Digits', '\u06F0');
|
||
public static readonly BurmeseNumerals = new NumeralSystem(4, 'BurmeseNumerals', 'Burmese Numerals', '\u1040');
|
||
public static readonly DevanagariNumerals = new NumeralSystem(5, 'DevanagariNumerals', 'Devanagari Numerals', '\u0966');
|
||
|
||
public static readonly Default = NumeralSystem.WesternArabicNumerals;
|
||
|
||
public readonly type: number;
|
||
public readonly typeName: string;
|
||
public readonly name: string;
|
||
public readonly digitZero: string;
|
||
public readonly doubleDigitZero: string;
|
||
public readonly textualAllDigits: string;
|
||
private readonly allDigits: string[];
|
||
private readonly digitsToWesternArabic: Record<string, number> = {};
|
||
|
||
private constructor(type: number, typeName: string, name: string, digitZero: string) {
|
||
this.type = type;
|
||
this.typeName = typeName;
|
||
this.name = name;
|
||
this.digitZero = digitZero;
|
||
this.doubleDigitZero = digitZero + digitZero;
|
||
this.allDigits = [];
|
||
this.digitsToWesternArabic = {};
|
||
|
||
for (let i = 0; i < 10; i++) {
|
||
const digit = String.fromCharCode(this.digitZero.charCodeAt(0) + i);
|
||
this.allDigits.push(digit);
|
||
this.digitsToWesternArabic[digit] = i;
|
||
NumeralSystem.allDigitsToWesternArabic[digit] = i;
|
||
NumeralSystem.allDigitsToNumeralSystem[digit] = this;
|
||
}
|
||
|
||
this.textualAllDigits = this.allDigits.join('');
|
||
|
||
NumeralSystem.allInstances.push(this);
|
||
NumeralSystem.allInstancesByType[type] = this;
|
||
NumeralSystem.allInstancesByTypeName[typeName] = this;
|
||
}
|
||
|
||
public getAllDigits(): string[] {
|
||
return this.allDigits.slice();
|
||
}
|
||
|
||
public isDigit(digit: string): boolean {
|
||
return this.digitsToWesternArabic.hasOwnProperty(digit);
|
||
}
|
||
|
||
public getLocalizedDigit(digit: number): string {
|
||
if (digit < 0 || digit > 9) {
|
||
return '';
|
||
}
|
||
|
||
return this.allDigits[digit] as string;
|
||
}
|
||
|
||
public parseInt(value: string): number {
|
||
if (!value) {
|
||
return Number.NaN;
|
||
}
|
||
|
||
if (this.type === NumeralSystem.WesternArabicNumerals.type) {
|
||
return parseInt(value, 10);
|
||
} else {
|
||
const westernArabicValue = this.replaceLocalizedDigitsToWesternArabicDigits(value);
|
||
return parseInt(westernArabicValue, 10);
|
||
}
|
||
}
|
||
|
||
public formatNumber(value: number): string {
|
||
if (Number.isNaN(value) || !Number.isFinite(value)) {
|
||
return value.toString();
|
||
}
|
||
|
||
if (this.type === NumeralSystem.WesternArabicNumerals.type) {
|
||
return value.toString(10);
|
||
}
|
||
|
||
if (value === 0) {
|
||
return this.digitZero;
|
||
}
|
||
|
||
return this.replaceWesternArabicDigitsToLocalizedDigits(value.toString(10));
|
||
}
|
||
|
||
public replaceWesternArabicDigitsToLocalizedDigits(value: string): string {
|
||
if (!value) {
|
||
return '';
|
||
}
|
||
|
||
let result = '';
|
||
|
||
for (let i = 0; i < value.length; i++) {
|
||
const ch = value.charAt(i);
|
||
|
||
if (NumeralSystem.WesternArabicNumerals.isDigit(ch)) {
|
||
const digit = NumeralSystem.WesternArabicNumerals.digitsToWesternArabic[ch] as number;
|
||
result += this.allDigits[digit] as string;
|
||
} else {
|
||
result += ch;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public replaceLocalizedDigitsToWesternArabicDigits(value: string): string {
|
||
if (!value) {
|
||
return '';
|
||
}
|
||
|
||
let result = '';
|
||
|
||
for (let i = 0; i < value.length; i++) {
|
||
const ch = value.charAt(i);
|
||
|
||
if (this.isDigit(ch)) {
|
||
const digit = this.digitsToWesternArabic[ch] as number;
|
||
result += NumeralSystem.WesternArabicNumerals.allDigits[digit] as string;
|
||
} else {
|
||
result += ch;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public static values(): NumeralSystem[] {
|
||
return NumeralSystem.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: number): NumeralSystem | undefined {
|
||
return NumeralSystem.allInstancesByType[type];
|
||
}
|
||
|
||
public static parse(typeName: string): NumeralSystem | undefined {
|
||
return NumeralSystem.allInstancesByTypeName[typeName];
|
||
}
|
||
|
||
public static detect(digit: string): NumeralSystem | undefined {
|
||
return NumeralSystem.allDigitsToNumeralSystem[digit];
|
||
}
|
||
|
||
public static toNumber(digit: string): number | undefined {
|
||
return NumeralSystem.allDigitsToWesternArabic[digit];
|
||
}
|
||
}
|
||
|
||
export class DecimalSeparator implements TypeAndName, NumeralSymbolType {
|
||
private static readonly allInstances: DecimalSeparator[] = [];
|
||
private static readonly allInstancesByType: Record<number, DecimalSeparator> = {};
|
||
private static readonly allInstancesByTypeName: Record<string, DecimalSeparator> = {};
|
||
|
||
public static readonly LanguageDefaultType: number = 0;
|
||
public static readonly Dot = new DecimalSeparator(1, 'Dot', '.');
|
||
public static readonly Comma = new DecimalSeparator(2, 'Comma', ',');
|
||
|
||
public static readonly Default = DecimalSeparator.Dot;
|
||
|
||
public readonly type: number;
|
||
public readonly name: string;
|
||
public readonly symbol: string;
|
||
|
||
private constructor(type: number, name: string, symbol: string) {
|
||
this.type = type;
|
||
this.name = name;
|
||
this.symbol = symbol;
|
||
|
||
DecimalSeparator.allInstances.push(this);
|
||
DecimalSeparator.allInstancesByType[type] = this;
|
||
DecimalSeparator.allInstancesByTypeName[name] = this;
|
||
}
|
||
|
||
public static values(): DecimalSeparator[] {
|
||
return DecimalSeparator.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: number): DecimalSeparator | undefined {
|
||
return DecimalSeparator.allInstancesByType[type];
|
||
}
|
||
|
||
public static parse(typeName: string): DecimalSeparator | undefined {
|
||
return DecimalSeparator.allInstancesByTypeName[typeName];
|
||
}
|
||
}
|
||
|
||
export class DigitGroupingSymbol implements TypeAndName, NumeralSymbolType {
|
||
private static readonly allInstances: DigitGroupingSymbol[] = [];
|
||
private static readonly allInstancesByType: Record<number, DigitGroupingSymbol> = {};
|
||
private static readonly allInstancesByTypeName: Record<string, DigitGroupingSymbol> = {};
|
||
|
||
public static readonly LanguageDefaultType: number = 0;
|
||
public static readonly Dot = new DigitGroupingSymbol(1, 'Dot', '.');
|
||
public static readonly Comma = new DigitGroupingSymbol(2, 'Comma', ',');
|
||
public static readonly Space = new DigitGroupingSymbol(3, 'Space', ' ');
|
||
public static readonly Apostrophe = new DigitGroupingSymbol(4, 'Apostrophe', '\'');
|
||
|
||
public static readonly Default = DigitGroupingSymbol.Comma;
|
||
|
||
public readonly type: number;
|
||
public readonly name: string;
|
||
public readonly symbol: string;
|
||
|
||
private constructor(type: number, name: string, symbol: string) {
|
||
this.type = type;
|
||
this.name = name;
|
||
this.symbol = symbol;
|
||
|
||
DigitGroupingSymbol.allInstances.push(this);
|
||
DigitGroupingSymbol.allInstancesByType[type] = this;
|
||
DigitGroupingSymbol.allInstancesByTypeName[name] = this;
|
||
}
|
||
|
||
public static values(): DigitGroupingSymbol[] {
|
||
return DigitGroupingSymbol.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: number): DigitGroupingSymbol | undefined {
|
||
return DigitGroupingSymbol.allInstancesByType[type];
|
||
}
|
||
|
||
public static parse(typeName: string): DigitGroupingSymbol | undefined {
|
||
return DigitGroupingSymbol.allInstancesByTypeName[typeName];
|
||
}
|
||
}
|
||
|
||
export class DigitGroupingType implements TypeAndName {
|
||
private static readonly allInstances: DigitGroupingType[] = [];
|
||
private static readonly allInstancesByType: Record<number, DigitGroupingType> = {};
|
||
private static readonly allInstancesByTypeName: Record<string, DigitGroupingType> = {};
|
||
|
||
public static readonly LanguageDefaultType: number = 0;
|
||
public static readonly None = new DigitGroupingType(1, 'None', 'None', false,
|
||
(numericChars: string[]) => {
|
||
return numericChars.join('');
|
||
}
|
||
);
|
||
public static readonly ThousandsSeparator = new DigitGroupingType(2, 'ThousandsSeparator', 'Thousands Separator', true,
|
||
(numericChars: string[], digitGroupingSymbol: string) => {
|
||
if (numericChars.length <= 3) {
|
||
return numericChars.join('');
|
||
}
|
||
|
||
let ret = '';
|
||
|
||
for (let i = numericChars.length - 1, j = 0; i >= 0; i--, j++) {
|
||
if (j > 0 && j % 3 === 0) {
|
||
ret = digitGroupingSymbol + ret;
|
||
}
|
||
|
||
ret = numericChars[i] + ret;
|
||
}
|
||
|
||
return ret;
|
||
}
|
||
);
|
||
public static readonly IndianNumberGrouping = new DigitGroupingType(3, 'IndianNumberGrouping', 'Indian Number Grouping', true,
|
||
(numericChars: string[], digitGroupingSymbol: string) => {
|
||
if (numericChars.length <= 3) {
|
||
return numericChars.join('');
|
||
}
|
||
|
||
let ret = '';
|
||
const length = numericChars.length;
|
||
|
||
for (let i = length - 1, j = 0; i >= 0; i--, j++) {
|
||
if (j === 3) {
|
||
ret = digitGroupingSymbol + ret;
|
||
} else if (j > 3 && (j - 3) % 2 === 0) {
|
||
ret = digitGroupingSymbol + ret;
|
||
}
|
||
|
||
ret = numericChars[i] + ret;
|
||
}
|
||
|
||
return ret;
|
||
}
|
||
);
|
||
|
||
public static readonly Default = DigitGroupingType.ThousandsSeparator;
|
||
|
||
public readonly type: number;
|
||
public readonly typeName: string;
|
||
public readonly name: string;
|
||
public readonly enabled: boolean;
|
||
public readonly format: (numericChars: string[], digitGroupingSymbol: string) => string;
|
||
|
||
private constructor(type: number, typeName: string, name: string, enabled: boolean, format: (numericChars: string[], digitGroupingSymbol: string) => string) {
|
||
this.type = type;
|
||
this.typeName = typeName;
|
||
this.name = name;
|
||
this.enabled = enabled;
|
||
this.format = format;
|
||
|
||
DigitGroupingType.allInstances.push(this);
|
||
DigitGroupingType.allInstancesByType[type] = this;
|
||
DigitGroupingType.allInstancesByTypeName[typeName] = this;
|
||
}
|
||
|
||
public static values(): DigitGroupingType[] {
|
||
return DigitGroupingType.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: number): DigitGroupingType | undefined {
|
||
return DigitGroupingType.allInstancesByType[type];
|
||
}
|
||
|
||
public static parse(typeName: string): DigitGroupingType | undefined {
|
||
return DigitGroupingType.allInstancesByTypeName[typeName];
|
||
}
|
||
}
|
||
|
||
export class KnownAmountFormat {
|
||
private static readonly allInstances: KnownAmountFormat[] = [];
|
||
private static readonly allInstancesByType: Record<string, KnownAmountFormat> = {};
|
||
|
||
public static readonly DotDecimalSeparator = new KnownAmountFormat('1234.56', DecimalSeparator.Dot, undefined, /^-?[0-9]+(\.[0-9]+)?$/);
|
||
public static readonly CommaDecimalSeparator = new KnownAmountFormat('1234,56', DecimalSeparator.Comma, undefined, /^-?[0-9]+(,[0-9]+)?$/);
|
||
public static readonly DotDecimalSeparatorWithCommaGroupingSymbol = new KnownAmountFormat('1,234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Comma, /^-?([0-9]+,)*[0-9]+(\.[0-9]+)?$/);
|
||
public static readonly CommaDecimalSeparatorWithDotGroupingSymbol = new KnownAmountFormat('1.234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Dot, /^-?([0-9]+\.)*[0-9]+(,[0-9]+)?$/);
|
||
public static readonly DotDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Space, /^-?([0-9]+([ ]))*[0-9]+(\.[0-9]+)?$/);
|
||
public static readonly CommaDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Space, /^-?([0-9]+([ ]))*[0-9]+(,[0-9]+)?$/);
|
||
public static readonly DotDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(\.[0-9]+)?$/);
|
||
public static readonly CommaDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(,[0-9]+)?$/);
|
||
|
||
public readonly format: string;
|
||
public readonly decimalSeparator: DecimalSeparator;
|
||
public readonly digitGroupingSymbol?: DigitGroupingSymbol;
|
||
public readonly type: string;
|
||
private readonly regex: RegExp;
|
||
|
||
private constructor(format: string, decimalSeparator: DecimalSeparator, digitGroupingSymbol: DigitGroupingSymbol | undefined, regex: RegExp) {
|
||
this.format = format;
|
||
this.decimalSeparator = decimalSeparator;
|
||
this.digitGroupingSymbol = digitGroupingSymbol;
|
||
this.type = this.decimalSeparator.type + '-' + (this.digitGroupingSymbol ? this.digitGroupingSymbol.type : 0).toString();
|
||
this.regex = regex;
|
||
|
||
KnownAmountFormat.allInstances.push(this);
|
||
KnownAmountFormat.allInstancesByType[this.type] = this;
|
||
}
|
||
|
||
public isValid(amount: string): boolean {
|
||
return this.regex.test(amount);
|
||
}
|
||
|
||
public static values(): KnownAmountFormat[] {
|
||
return KnownAmountFormat.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: string): KnownAmountFormat | undefined {
|
||
return KnownAmountFormat.allInstancesByType[type];
|
||
}
|
||
|
||
public static detect(amount: string): KnownAmountFormat[] | undefined {
|
||
const result: KnownAmountFormat[] = [];
|
||
|
||
for (const format of KnownAmountFormat.allInstances) {
|
||
if (format.isValid(amount)) {
|
||
result.push(format);
|
||
}
|
||
}
|
||
|
||
return result.length > 0 ? result : undefined;
|
||
}
|
||
|
||
public static detectMulti(amounts: string[]): KnownAmountFormat[] | undefined {
|
||
const detectedCounts: Record<string, number> = {};
|
||
|
||
for (const amount of amounts) {
|
||
const detectedFormats = KnownAmountFormat.detect(amount);
|
||
|
||
if (detectedFormats) {
|
||
for (const format of detectedFormats) {
|
||
detectedCounts[format.type] = (detectedCounts[format.type] || 0) + 1;
|
||
}
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const result: KnownAmountFormat[] = [];
|
||
|
||
for (const format of KnownAmountFormat.allInstances) {
|
||
if (detectedCounts[format.type] === amounts.length) {
|
||
result.push(format);
|
||
}
|
||
}
|
||
|
||
return result.length > 0 ? result : undefined;
|
||
}
|
||
}
|
||
|
||
export class AmountFilterType {
|
||
private static readonly allInstances: AmountFilterType[] = [];
|
||
private static readonly allInstancesByType: Record<string, AmountFilterType> = {};
|
||
|
||
public static readonly GreaterThan = new AmountFilterType('gt', 'Greater than', 1,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 0 && amount > (params[0] as number);
|
||
}
|
||
);
|
||
public static readonly LessThan = new AmountFilterType('lt', 'Less than', 1,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 0 && amount < (params[0] as number);
|
||
}
|
||
);
|
||
public static readonly EqualTo = new AmountFilterType('eq', 'Equal to', 1,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 0 && amount === (params[0] as number);
|
||
}
|
||
);
|
||
public static readonly NotEqualTo = new AmountFilterType('ne', 'Not equal to', 1,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 0 && amount !== (params[0] as number);
|
||
}
|
||
);
|
||
public static readonly Between = new AmountFilterType('bt', 'Between', 2,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 1 && amount >= (params[0] as number) && amount <= (params[1] as number);
|
||
}
|
||
);
|
||
public static readonly NotBetween = new AmountFilterType('nb', 'Not between', 2,
|
||
(amount: number, ...params: number[]) => {
|
||
return params && params.length > 1 && (amount < (params[0] as number) || amount > (params[1] as number));
|
||
}
|
||
);
|
||
|
||
public readonly type: string;
|
||
public readonly name: string;
|
||
public readonly paramCount: number;
|
||
private readonly matchFn: (amount: number, ...params: number[]) => boolean;
|
||
|
||
private constructor(type: string, name: string, paramCount: number, matchFn: (amount: number, ...params: number[]) => boolean) {
|
||
this.type = type;
|
||
this.name = name;
|
||
this.paramCount = paramCount;
|
||
this.matchFn = matchFn;
|
||
|
||
AmountFilterType.allInstances.push(this);
|
||
AmountFilterType.allInstancesByType[type] = this;
|
||
}
|
||
|
||
public toTextualFilter(...params: number[]): string {
|
||
if (this.paramCount === 1) {
|
||
return `${this.type}:${params[0] ?? ''}`;
|
||
} else if (this.paramCount === 2) {
|
||
return `${this.type}:${params[0] ?? ''}:${params[1] ?? ''}`;
|
||
} else {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
public static match(filter: string, amount: number): boolean {
|
||
const parts = filter.split(':');
|
||
|
||
if (parts.length < 2) {
|
||
return false;
|
||
}
|
||
|
||
const filterType = AmountFilterType.valueOf(parts[0] as string);
|
||
|
||
if (!filterType) {
|
||
return false;
|
||
}
|
||
|
||
if (parts.length - 1 !== filterType.paramCount) {
|
||
return false;
|
||
}
|
||
|
||
const params: number[] = [];
|
||
|
||
for (let i = 1; i < parts.length; i++) {
|
||
const param = parseInt(parts[i] as string);
|
||
|
||
if (Number.isNaN(param)) {
|
||
return false;
|
||
}
|
||
|
||
params.push(param);
|
||
}
|
||
|
||
return filterType.matchFn(amount, ...params);
|
||
}
|
||
|
||
public static values(): AmountFilterType[] {
|
||
return AmountFilterType.allInstances;
|
||
}
|
||
|
||
public static valueOf(type: string): AmountFilterType | undefined {
|
||
return AmountFilterType.allInstancesByType[type];
|
||
}
|
||
}
|