Commit b038b409 authored by Shi-Hao Hong's avatar Shi-Hao Hong Committed by Flutter GitHub Bot

gen_l10n Number Format Handling (#47706)

parent 846dce92
...@@ -136,7 +136,7 @@ const String getterMethodTemplate = ''' ...@@ -136,7 +136,7 @@ const String getterMethodTemplate = '''
'''; ''';
const String simpleMethodTemplate = ''' const String simpleMethodTemplate = '''
String @methodName(@methodParameters) {@dateFormatting String @methodName(@methodParameters) {@dateFormatting@numberFormatting
return Intl.message( return Intl.message(
@message, @message,
locale: _localeName, locale: _localeName,
...@@ -146,7 +146,7 @@ const String simpleMethodTemplate = ''' ...@@ -146,7 +146,7 @@ const String simpleMethodTemplate = '''
'''; ''';
const String pluralMethodTemplate = ''' const String pluralMethodTemplate = '''
String @methodName(@methodParameters) {@dateFormatting String @methodName(@methodParameters) {@dateFormatting@numberFormatting
return Intl.plural( return Intl.plural(
@intlMethodArgs @intlMethodArgs
); );
...@@ -214,34 +214,70 @@ const Set<String> allowableDateFormats = <String>{ ...@@ -214,34 +214,70 @@ const Set<String> allowableDateFormats = <String>{
's', 's',
}; };
// The set of number formats that can be automatically localized.
//
// The localizations generation tool makes use of the intl library's
// NumberFormat class to properly format numbers based on the locale, the
// desired format, as well as the passed in number. For example, using
// DateFormat.compactLong("en_US").format(1200000) results
// in the string "1.2 million".
//
// Since the tool generates code that uses NumberFormat's constructor, it is
// necessary to verify that the constructor exists, or the
// tool will generate code that may cause a compile-time error.
//
// See also:
//
// * <https://pub.dev/packages/intl>
// * <https://pub.dev/documentation/intl/latest/intl/NumberFormat-class.html>
const Set<String> allowableNumberFormats = <String>{
'compact',
'compactLong',
'decimalPattern',
'decimalPercentPattern',
'percentPattern',
'scientificPattern',
};
bool _isDateParameter(Map<String, dynamic> placeholderValue) => placeholderValue['type'] == 'DateTime'; bool _isDateParameter(Map<String, dynamic> placeholderValue) => placeholderValue['type'] == 'DateTime';
bool _isNumberParameter(Map<String, dynamic> placeholderValue) => placeholderValue['type'] == 'Number';
bool _containsFormatKey(Map<String, dynamic> placeholderValue, String placeholder) {
if (placeholderValue.containsKey('format'))
return true;
throw L10nException(
'The placeholder, $placeholder, has its "type" resource attribute set to '
'the "${placeholderValue['type']}" type. To properly resolve for the right '
'${placeholderValue['type']} format, the "format" attribute needs to be set '
'to determine which DateFormat to use. \n'
'Check the intl library\'s DateFormat class constructors for allowed '
'date formats.'
);
}
bool _dateParameterIsValid(Map<String, dynamic> placeholderValue, String placeholder) { bool _isValidDateParameter(Map<String, dynamic> placeholderValue, String placeholder) {
if (allowableDateFormats.contains(placeholderValue['format'])) if (allowableDateFormats.contains(placeholderValue['format']))
return true; return true;
throw L10nException( throw L10nException(
'Date format ${placeholderValue['format']} for the $placeholder \n' 'Date format ${placeholderValue['format']} for $placeholder \n'
'placeholder does not have a corresponding DateFormat \n' 'placeholder does not have a corresponding DateFormat \n'
'constructor. Check the intl library\'s DateFormat class \n' 'constructor. Check the intl library\'s DateFormat class \n'
'constructors for allowed date formats.' 'constructors for allowed date formats.'
); );
} }
bool _containsFormatKey(Map<String, dynamic> placeholderValue, String placeholder) { bool _isValidNumberParameter(Map<String, dynamic> placeholderValue, String placeholder) {
if (placeholderValue.containsKey('format')) if (allowableNumberFormats.contains(placeholderValue['format']))
return true; return true;
throw L10nException( throw L10nException(
'The placeholder, $placeholder, has its "type" resource attribute set to ' 'Number format ${placeholderValue['format']} for the $placeholder \n'
'the "DateTime" type. To properly resolve for the right DateTime format, ' 'placeholder does not have a corresponding NumberFormat \n'
'the "format" attribute needs to be set to determine which DateFormat to ' 'constructor. Check the intl library\'s NumberFormat class \n'
'use. \n' 'constructors for allowed number formats.'
'Check the intl library\'s DateFormat class constructors for allowed '
'date formats.'
); );
} }
List<String> genMethodParameters(Map<String, dynamic> bundle, String key, String type) { List<String> genMethodParameters(Map<String, dynamic> bundle, String resourceId, String type) {
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>; final Map<String, dynamic> attributesMap = bundle['@$resourceId'] as Map<String, dynamic>;
if (attributesMap != null && attributesMap.containsKey('placeholders')) { if (attributesMap != null && attributesMap.containsKey('placeholders')) {
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>; final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
return placeholders.keys.map((String parameter) => '$type $parameter').toList(); return placeholders.keys.map((String parameter) => '$type $parameter').toList();
...@@ -265,34 +301,78 @@ List<String> genPluralMethodParameters(Iterable<String> placeholderKeys, String ...@@ -265,34 +301,78 @@ List<String> genPluralMethodParameters(Iterable<String> placeholderKeys, String
}).toList(); }).toList();
} }
String generateDateFormattingLogic(Map<String, dynamic> bundle, String key) { String generateDateFormattingLogic(Map<String, dynamic> arbBundle, String resourceId) {
String result = ''; final StringBuffer result = StringBuffer();
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>; final Map<String, dynamic> attributesMap = arbBundle['@$resourceId'] as Map<String, dynamic>;
if (attributesMap != null && attributesMap.containsKey('placeholders')) { if (attributesMap != null && attributesMap.containsKey('placeholders')) {
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>; final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
for (final String placeholder in placeholders.keys) { for (final String placeholder in placeholders.keys) {
final dynamic value = placeholders[placeholder]; final dynamic value = placeholders[placeholder];
if ( if (value is Map<String, dynamic> && _isValidDateFormat(value, placeholder)) {
value is Map<String, dynamic> && result.write('''
_isDateParameter(value) &&
_containsFormatKey(value, placeholder) &&
_dateParameterIsValid(value, placeholder)
) {
result += '''
final DateFormat ${placeholder}DateFormat = DateFormat.${value['format']}(_localeName); final DateFormat ${placeholder}DateFormat = DateFormat.${value['format']}(_localeName);
final String ${placeholder}String = ${placeholder}DateFormat.format($placeholder); final String ${placeholder}String = ${placeholder}DateFormat.format($placeholder);
'''; ''');
}
} }
} }
return result.toString();
}
String generateNumberFormattingLogic(Map<String, dynamic> arbBundle, String resourceId) {
final Map<String, dynamic> attributesMap = arbBundle['@$resourceId'] as Map<String, dynamic>;
if (attributesMap != null && attributesMap.containsKey('placeholders')) {
final StringBuffer result = StringBuffer();
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
final StringBuffer optionalParametersString = StringBuffer();
for (final String placeholder in placeholders.keys) {
final dynamic value = placeholders[placeholder];
if (value is Map<String, dynamic> && _isValidNumberFormat(value, placeholder)) {
if (value.containsKey('optionalParameters')) {
final Map<String, dynamic> optionalParameters = value['optionalParameters'] as Map<String, dynamic>;
for (final String parameter in optionalParameters.keys)
optionalParametersString.write('\n $parameter: ${optionalParameters[parameter]},');
}
result.write('''
final NumberFormat ${placeholder}NumberFormat = NumberFormat.${value['format']}(
locale: _localeName,@optionalParameters
);
final String ${placeholder}String = ${placeholder}NumberFormat.format($placeholder);
''');
}
}
return result
.toString()
.replaceAll('@optionalParameters', optionalParametersString.toString());
} }
return result; return '';
}
bool _isValidDateFormat(Map<String, dynamic> value, String placeholder) {
return _isDateParameter(value)
&& _containsFormatKey(value, placeholder)
&& _isValidDateParameter(value, placeholder);
}
bool _isValidNumberFormat(Map<String, dynamic> value, String placeholder) {
return _isNumberParameter(value)
&& _containsFormatKey(value, placeholder)
&& _isValidNumberParameter(value, placeholder);
}
bool _isValidPlaceholder(Map<String, dynamic> value, String placeholder) {
return _isValidDateFormat(value, placeholder) || _isValidNumberFormat(value, placeholder);
} }
List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) { List<String> genIntlMethodArgs(Map<String, dynamic> arbBundle, String resourceId) {
final List<String> attributes = <String>['name: \'$key\'']; final List<String> attributes = <String>['name: \'$resourceId\''];
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>; final Map<String, dynamic> attributesMap = arbBundle['@$resourceId'] as Map<String, dynamic>;
if (attributesMap != null) { if (attributesMap != null) {
if (attributesMap.containsKey('description')) { if (attributesMap.containsKey('description')) {
final String description = attributesMap['description'] as String; final String description = attributesMap['description'] as String;
...@@ -304,12 +384,7 @@ List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) { ...@@ -304,12 +384,7 @@ List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) {
final List<String> argumentList = <String>[]; final List<String> argumentList = <String>[];
for (final String placeholder in placeholders.keys) { for (final String placeholder in placeholders.keys) {
final dynamic value = placeholders[placeholder]; final dynamic value = placeholders[placeholder];
if ( if (value is Map<String, dynamic> && _isValidPlaceholder(value, placeholder)) {
value is Map<String, dynamic> &&
_isDateParameter(value) &&
_containsFormatKey(value, placeholder) &&
_dateParameterIsValid(value, placeholder)
) {
argumentList.add('${placeholder}String'); argumentList.add('${placeholder}String');
} else { } else {
argumentList.add(placeholder); argumentList.add(placeholder);
...@@ -323,14 +398,14 @@ List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) { ...@@ -323,14 +398,14 @@ List<String> genIntlMethodArgs(Map<String, dynamic> bundle, String key) {
return attributes; return attributes;
} }
String genSimpleMethod(Map<String, dynamic> bundle, String key) { String genSimpleMethod(Map<String, dynamic> arbBundle, String resourceId) {
String genSimpleMethodMessage(Map<String, dynamic> bundle, String key) { String genSimpleMethodMessage(Map<String, dynamic> arbBundle, String resourceId) {
String message = bundle[key] as String; String message = arbBundle[resourceId] as String;
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>; final Map<String, dynamic> attributesMap = arbBundle['@$resourceId'] as Map<String, dynamic>;
final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>; final Map<String, dynamic> placeholders = attributesMap['placeholders'] as Map<String, dynamic>;
for (final String placeholder in placeholders.keys) { for (final String placeholder in placeholders.keys) {
final dynamic value = placeholders[placeholder]; final dynamic value = placeholders[placeholder];
if (value is Map<String, dynamic> && _isDateParameter(value)) { if (value is Map<String, dynamic> && (_isDateParameter(value) || _isNumberParameter(value))) {
message = message.replaceAll('{$placeholder}', '\$${placeholder}String'); message = message.replaceAll('{$placeholder}', '\$${placeholder}String');
} else { } else {
message = message.replaceAll('{$placeholder}', '\$$placeholder'); message = message.replaceAll('{$placeholder}', '\$$placeholder');
...@@ -339,26 +414,27 @@ String genSimpleMethod(Map<String, dynamic> bundle, String key) { ...@@ -339,26 +414,27 @@ String genSimpleMethod(Map<String, dynamic> bundle, String key) {
return generateString(message); return generateString(message);
} }
final Map<String, dynamic> attributesMap = bundle['@$key'] as Map<String, dynamic>; final Map<String, dynamic> attributesMap = arbBundle['@$resourceId'] as Map<String, dynamic>;
if (attributesMap == null) if (attributesMap == null)
throw L10nException( throw L10nException(
'Resource attribute "@$key" was not found. Please ensure that each ' 'Resource attribute "@$resourceId" was not found. Please ensure that each '
'resource id has a corresponding resource attribute.' 'resource id has a corresponding resource attribute.'
); );
if (attributesMap.containsKey('placeholders')) { if (attributesMap.containsKey('placeholders')) {
return simpleMethodTemplate return simpleMethodTemplate
.replaceAll('@methodName', key) .replaceAll('@methodName', resourceId)
.replaceAll('@methodParameters', genMethodParameters(bundle, key, 'Object').join(', ')) .replaceAll('@methodParameters', genMethodParameters(arbBundle, resourceId, 'Object').join(', '))
.replaceAll('@dateFormatting', generateDateFormattingLogic(bundle, key)) .replaceAll('@dateFormatting', generateDateFormattingLogic(arbBundle, resourceId))
.replaceAll('@message', '${genSimpleMethodMessage(bundle, key)}') .replaceAll('@numberFormatting', generateNumberFormattingLogic(arbBundle, resourceId))
.replaceAll('@intlMethodArgs', genIntlMethodArgs(bundle, key).join(',\n ')); .replaceAll('@message', '${genSimpleMethodMessage(arbBundle, resourceId)}')
.replaceAll('@intlMethodArgs', genIntlMethodArgs(arbBundle, resourceId).join(',\n '));
} }
return getterMethodTemplate return getterMethodTemplate
.replaceAll('@methodName', key) .replaceAll('@methodName', resourceId)
.replaceAll('@message', '${generateString(bundle[key] as String)}') .replaceAll('@message', '${generateString(arbBundle[resourceId] as String)}')
.replaceAll('@intlMethodArgs', genIntlMethodArgs(bundle, key).join(',\n ')); .replaceAll('@intlMethodArgs', genIntlMethodArgs(arbBundle, resourceId).join(',\n '));
} }
String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) { String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) {
...@@ -413,7 +489,7 @@ String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) { ...@@ -413,7 +489,7 @@ String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) {
String argValue = match.group(2); String argValue = match.group(2);
for (final String placeholder in placeholders) { for (final String placeholder in placeholders) {
final dynamic value = placeholdersMap[placeholder]; final dynamic value = placeholdersMap[placeholder];
if (value is Map<String, dynamic> && _isDateParameter(value)) { if (value is Map<String, dynamic> && (_isDateParameter(value) || _isNumberParameter(value))) {
argValue = argValue.replaceAll('#$placeholder#', '\$${placeholder}String'); argValue = argValue.replaceAll('#$placeholder#', '\$${placeholder}String');
} else { } else {
argValue = argValue.replaceAll('#$placeholder#', '\$$placeholder'); argValue = argValue.replaceAll('#$placeholder#', '\$$placeholder');
...@@ -427,6 +503,7 @@ String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) { ...@@ -427,6 +503,7 @@ String genPluralMethod(Map<String, dynamic> arbBundle, String resourceId) {
.replaceAll('@methodName', resourceId) .replaceAll('@methodName', resourceId)
.replaceAll('@methodParameters', genPluralMethodParameters(placeholders, countPlaceholder, resourceId).join(', ')) .replaceAll('@methodParameters', genPluralMethodParameters(placeholders, countPlaceholder, resourceId).join(', '))
.replaceAll('@dateFormatting', generateDateFormattingLogic(arbBundle, resourceId)) .replaceAll('@dateFormatting', generateDateFormattingLogic(arbBundle, resourceId))
.replaceAll('@numberFormatting', generateNumberFormattingLogic(arbBundle, resourceId))
.replaceAll('@intlMethodArgs', methodArgs.join(',\n ')); .replaceAll('@intlMethodArgs', methodArgs.join(',\n '));
} }
......
...@@ -946,6 +946,97 @@ void main() { ...@@ -946,6 +946,97 @@ void main() {
); );
}); });
test('correctly generates simple message with numbers', () {
const String singleNumberMessage = '''{
"courseCompletion": "You have completed {progress} of the course.",
"@courseCompletion": {
"description": "The amount of progress the student has made in their class.",
"placeholders": {
"progress": {
"type": "Number",
"format": "percentPattern"
}
}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(singleNumberMessage);
final LocalizationsGenerator generator = LocalizationsGenerator(fs);
try {
generator.initialize(
l10nDirectoryPath: defaultArbPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
);
generator.parseArbFiles();
generator.generateClassMethods();
} on Exception catch (e) {
fail('Parsing template arb file should succeed: \n$e');
}
expect(generator.classMethods, isNotEmpty);
expect(
generator.classMethods.first,
''' String courseCompletion(Object progress) {
final NumberFormat progressNumberFormat = NumberFormat.percentPattern(
locale: _localeName,
);
final String progressString = progressNumberFormat.format(progress);
return Intl.message(
r'You have completed \$progressString of the course.',
locale: _localeName,
name: 'courseCompletion',
desc: r'The amount of progress the student has made in their class.',
args: <Object>[progressString]
);
}
''');
});
test('throws an exception when improperly formatted number is passed in', () {
const String singleDateMessageArbFileString = '''{
"courseCompletion": "You have completed {progress} of the course.",
"@courseCompletion": {
"description": "The amount of progress the student has made in their class.",
"placeholders": {
"progress": {
"type": "Number",
"format": "asdf"
}
}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(singleDateMessageArbFileString);
final LocalizationsGenerator generator = LocalizationsGenerator(fs);
try {
generator.initialize(
l10nDirectoryPath: defaultArbPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
);
generator.parseArbFiles();
generator.generateClassMethods();
} on L10nException catch (e) {
expect(e.message, contains('asdf'));
expect(e.message, contains('progress'));
expect(e.message, contains('does not have a corresponding NumberFormat'));
return;
}
fail('Improper date formatting should throw an exception');
});
test('correctly generates a plural message with placeholders:', () { test('correctly generates a plural message with placeholders:', () {
const String pluralMessageWithMultiplePlaceholders = '''{ const String pluralMessageWithMultiplePlaceholders = '''{
"helloWorlds": "{count,plural, =0{Hello}=1{Hello {adjective} World}=2{Hello two {adjective} worlds}few{Hello {count} {adjective} worlds}many{Hello all {count} {adjective} worlds}other{Hello other {count} {adjective} worlds}}", "helloWorlds": "{count,plural, =0{Hello}=1{Hello {adjective} World}=2{Hello two {adjective} worlds}few{Hello {count} {adjective} worlds}many{Hello all {count} {adjective} worlds}other{Hello other {count} {adjective} worlds}}",
...@@ -1050,6 +1141,118 @@ void main() { ...@@ -1050,6 +1141,118 @@ void main() {
); );
}); });
test('correctly generates a plural message with number placeholders:', () {
const String pluralMessageWithDateTimePlaceholder = '''{
"helloWorlds": "{count,plural, =1{Hello World of {population} citizens}=2{Hello two worlds with {population} total citizens}many{Hello all {count} worlds, with a total of {population} citizens}other{Hello other {count} worlds, with a total of {population} citizens}}",
"@helloWorlds": {
"placeholders": {
"count": {},
"population": {
"type": "Number",
"format": "compactLong"
}
}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(pluralMessageWithDateTimePlaceholder);
final LocalizationsGenerator generator = LocalizationsGenerator(fs);
try {
generator.initialize(
l10nDirectoryPath: defaultArbPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
);
generator.parseArbFiles();
generator.generateClassMethods();
} on Exception catch (e) {
fail('Parsing template arb file should succeed: \n$e');
}
expect(generator.classMethods, isNotEmpty);
expect(
generator.classMethods.first,
''' String helloWorlds(int count, Object population) {
final NumberFormat populationNumberFormat = NumberFormat.compactLong(
locale: _localeName,
);
final String populationString = populationNumberFormat.format(population);
return Intl.plural(
count,
locale: _localeName,
name: 'helloWorlds',
args: <Object>[count, populationString],
one: 'Hello World of \$populationString citizens',
two: 'Hello two worlds with \$populationString total citizens',
many: 'Hello all \$count worlds, with a total of \$populationString citizens',
other: 'Hello other \$count worlds, with a total of \$populationString citizens'
);
}
'''
);
});
test('correctly adds optional parameters to numbers:', () {
const String singleNumberMessage = '''{
"courseCompletion": "You have completed {progress} of the course.",
"@courseCompletion": {
"description": "The amount of progress the student has made in their class.",
"placeholders": {
"progress": {
"type": "Number",
"format": "decimalPercentPattern",
"optionalParameters": {
"decimalDigits": 2
}
}
}
}
}''';
final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n')
..createSync(recursive: true);
l10nDirectory.childFile(defaultTemplateArbFileName)
.writeAsStringSync(singleNumberMessage);
final LocalizationsGenerator generator = LocalizationsGenerator(fs);
try {
generator.initialize(
l10nDirectoryPath: defaultArbPathString,
templateArbFileName: defaultTemplateArbFileName,
outputFileString: defaultOutputFileString,
classNameString: defaultClassNameString,
);
generator.parseArbFiles();
generator.generateClassMethods();
} on Exception catch (e) {
fail('Parsing template arb file should succeed: \n$e');
}
expect(generator.classMethods, isNotEmpty);
expect(
generator.classMethods.first,
''' String courseCompletion(Object progress) {
final NumberFormat progressNumberFormat = NumberFormat.decimalPercentPattern(
locale: _localeName,
decimalDigits: 2,
);
final String progressString = progressNumberFormat.format(progress);
return Intl.message(
r'You have completed \$progressString of the course.',
locale: _localeName,
name: 'courseCompletion',
desc: r'The amount of progress the student has made in their class.',
args: <Object>[progressString]
);
}
''');
});
test('should throw attempting to generate a plural message without placeholders:', () { test('should throw attempting to generate a plural message without placeholders:', () {
const String pluralMessageWithoutPlaceholdersAttribute = '''{ const String pluralMessageWithoutPlaceholdersAttribute = '''{
"helloWorlds": "{count,plural, =0{Hello}=1{Hello World}=2{Hello two worlds}few{Hello {count} worlds}many{Hello all {count} worlds}other{Hello other {count} worlds}}", "helloWorlds": "{count,plural, =0{Hello}=1{Hello World}=2{Hello two worlds}few{Hello {count} worlds}many{Hello all {count} worlds}other{Hello other {count} worlds}}",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment