180 lines
4.9 KiB
Dart
180 lines
4.9 KiB
Dart
enum TextEntityKind { mention, phone, card }
|
|
|
|
class TextEntity {
|
|
final TextEntityKind kind;
|
|
final int start;
|
|
final int end;
|
|
final String value;
|
|
|
|
const TextEntity({
|
|
required this.kind,
|
|
required this.start,
|
|
required this.end,
|
|
required this.value,
|
|
});
|
|
|
|
int get length => end - start;
|
|
}
|
|
|
|
typedef TextSpanRange = ({int start, int end});
|
|
|
|
final RegExp _cardPattern = RegExp(
|
|
r'(?<![\d.,])\d(?:[ -]?\d){12,18}(?![\d.,])',
|
|
);
|
|
final RegExp _phonePattern = RegExp(
|
|
r'(?<![\d.,])(?:\+\d(?:[ ()-]{0,3}\d){9,14}|[78](?:[ ()-]{0,3}\d){10})(?![\d.,])',
|
|
);
|
|
final RegExp _mentionPattern = RegExp(r'(?<![\w@/])@([A-Za-z0-9_]{2,32})');
|
|
|
|
const Map<String, String> _cardBrands = {
|
|
'MIR': 'МИР',
|
|
'VISA': 'Visa',
|
|
'MASTERCARD': 'Mastercard',
|
|
'MAESTRO': 'Maestro',
|
|
'AMEX': 'American Express',
|
|
'UNIONPAY': 'UnionPay',
|
|
'JCB': 'JCB',
|
|
'DINERS': 'Diners Club',
|
|
'DISCOVER': 'Discover',
|
|
};
|
|
|
|
String? cardBrand(String digits) {
|
|
if (digits.length < 13) return null;
|
|
int prefix(int length) => int.parse(digits.substring(0, length));
|
|
|
|
final p1 = prefix(1);
|
|
final p2 = prefix(2);
|
|
final p3 = prefix(3);
|
|
final p4 = prefix(4);
|
|
final p6 = digits.length >= 6 ? prefix(6) : 0;
|
|
|
|
if (p4 >= 2200 && p4 <= 2204) return 'MIR';
|
|
if (p1 == 4) return 'VISA';
|
|
if (p2 >= 51 && p2 <= 55) return 'MASTERCARD';
|
|
if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD';
|
|
if (p2 == 34 || p2 == 37) return 'AMEX';
|
|
if (p2 == 62) return 'UNIONPAY';
|
|
if (p4 >= 3528 && p4 <= 3589) return 'JCB';
|
|
if (p3 >= 300 && p3 <= 305) return 'DINERS';
|
|
if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS';
|
|
if (p4 == 6011 || p2 == 65) return 'DISCOVER';
|
|
if (p3 >= 644 && p3 <= 649) return 'DISCOVER';
|
|
if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER';
|
|
if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO';
|
|
if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO';
|
|
return null;
|
|
}
|
|
|
|
String? cardBrandTitle(String digits) {
|
|
final brand = cardBrand(digits);
|
|
return brand == null ? null : _cardBrands[brand];
|
|
}
|
|
|
|
String cardMask(String digits) {
|
|
final brand = cardBrand(digits) ?? 'CARD';
|
|
final tail = digits.length >= 4
|
|
? digits.substring(digits.length - 4)
|
|
: digits;
|
|
return '$brand*$tail';
|
|
}
|
|
|
|
String formatCardNumber(String digits) {
|
|
final buffer = StringBuffer();
|
|
for (var i = 0; i < digits.length; i++) {
|
|
if (i > 0 && i % 4 == 0) buffer.write(' ');
|
|
buffer.write(digits[i]);
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
bool isLuhnValid(String digits) {
|
|
if (digits.length < 12) return false;
|
|
var sum = 0;
|
|
var double = false;
|
|
for (var i = digits.length - 1; i >= 0; i--) {
|
|
var value = digits.codeUnitAt(i) - 0x30;
|
|
if (value < 0 || value > 9) return false;
|
|
if (double) {
|
|
value *= 2;
|
|
if (value > 9) value -= 9;
|
|
}
|
|
sum += value;
|
|
double = !double;
|
|
}
|
|
return sum % 10 == 0;
|
|
}
|
|
|
|
String _digitsOf(String raw) {
|
|
final buffer = StringBuffer();
|
|
for (var i = 0; i < raw.length; i++) {
|
|
final code = raw.codeUnitAt(i);
|
|
if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code);
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
bool _mayContainEntities(String text) {
|
|
for (var i = 0; i < text.length; i++) {
|
|
final code = text.codeUnitAt(i);
|
|
if (code == 0x40) return true;
|
|
if (code >= 0x30 && code <= 0x39) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<TextEntity> detectTextEntities(
|
|
String text, {
|
|
Iterable<TextSpanRange> skip = const [],
|
|
}) {
|
|
if (text.isEmpty || !_mayContainEntities(text)) return const [];
|
|
|
|
final taken = <TextSpanRange>[...skip];
|
|
bool free(int start, int end) =>
|
|
!taken.any((r) => start < r.end && end > r.start);
|
|
|
|
final found = <TextEntity>[];
|
|
|
|
void collect(
|
|
RegExp pattern,
|
|
TextEntityKind kind,
|
|
String? Function(RegExpMatch match) valueOf,
|
|
) {
|
|
for (final match in pattern.allMatches(text)) {
|
|
if (!free(match.start, match.end)) continue;
|
|
final value = valueOf(match);
|
|
if (value == null) continue;
|
|
taken.add((start: match.start, end: match.end));
|
|
found.add(
|
|
TextEntity(
|
|
kind: kind,
|
|
start: match.start,
|
|
end: match.end,
|
|
value: value,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
collect(_cardPattern, TextEntityKind.card, (match) {
|
|
final digits = _digitsOf(match.group(0)!);
|
|
if (digits.length < 13 || digits.length > 19) return null;
|
|
if (cardBrand(digits) == null) return null;
|
|
if (!isLuhnValid(digits)) return null;
|
|
return digits;
|
|
});
|
|
|
|
collect(_phonePattern, TextEntityKind.phone, (match) {
|
|
final digits = _digitsOf(match.group(0)!);
|
|
if (digits.length < 10 || digits.length > 15) return null;
|
|
return '+$digits';
|
|
});
|
|
|
|
collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1));
|
|
|
|
found.sort((a, b) => a.start.compareTo(b.start));
|
|
return found;
|
|
}
|
|
|
|
bool hasTextEntities(String text, {Iterable<TextSpanRange> skip = const []}) =>
|
|
detectTextEntities(text, skip: skip).isNotEmpty;
|