diff --git a/mobile/bin/generate_keys.dart b/mobile/bin/generate_keys.dart index 8353b1c6f4..3c5c284c3e 100644 --- a/mobile/bin/generate_keys.dart +++ b/mobile/bin/generate_keys.dart @@ -3,7 +3,99 @@ import 'dart:convert'; import 'dart:io'; -const _kReservedWords = ['continue']; +const _kReservedWords = [ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +]; + +const _kIntParamNames = [ + 'count', + 'number', + 'amount', + 'total', + 'index', + 'size', + 'length', + 'width', + 'height', + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'page', + 'limit', + 'offset', + 'max', + 'min', + 'id', + 'num', + 'quantity', +]; void main() async { final sourceFile = File('../i18n/en.json'); @@ -15,49 +107,258 @@ void main() async { final outputDir = Directory('lib/generated'); await outputDir.create(recursive: true); - final outputFile = File('lib/generated/intl_keys.g.dart'); - await _generate(sourceFile, outputFile); + final content = await sourceFile.readAsString(); + final translations = json.decode(content) as Map; + + final outputFile = File('lib/generated/translations.g.dart'); + await _generateTranslations(translations, outputFile); print('Generated ${outputFile.path}'); } -Future _generate(File source, File output) async { - final content = await source.readAsString(); - final translations = json.decode(content) as Map; +class TranslationNode { + final String key; + final String? value; + final Map children; + final List params; + + const TranslationNode({ + required this.key, + this.value, + Map? children, + List? params, + }) : children = children ?? const {}, + params = params ?? const []; + + bool get isLeaf => value != null; + bool get hasParams => params.isNotEmpty; +} + +class TranslationParam { + final String name; + final String type; + + const TranslationParam(this.name, this.type); +} + +Future _generateTranslations(Map translations, File output) async { + final root = _buildTranslationTree('', translations); final buffer = StringBuffer(''' // DO NOT EDIT. This is code generated via generate_keys.dart -abstract class IntlKeys { -'''); +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/message_format.dart'; - _writeKeys(buffer, translations); - buffer.writeln('}'); - - await output.writeAsString(buffer.toString()); +extension TranslationsExtension on BuildContext { + Translations get t => Translations.of(this); } -void _writeKeys( - StringBuffer buffer, - Map map, [ - String prefix = '', -]) { - for (final entry in map.entries) { - final key = entry.key; - final value = entry.value; +class StaticTranslations { + StaticTranslations._(); + static final instance = Translations._(null); +} - if (value is Map) { - _writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key'); - } else { - final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key'); - final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.'); - buffer.writeln(' static const $name = \'$path\';'); +abstract class _BaseTranslations { + BuildContext? get _context; + + String _t(String key, [Map? args]) { + if (key.isEmpty) return ''; + try { + final translated = key.tr(context: _context); + return args != null + ? MessageFormat(translated, locale: Intl.defaultLocale ?? 'en').format(args) + : translated; + } catch (e) { + return key; } } } -String _cleanName(String name) { - name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); - if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name'; - if (_kReservedWords.contains(name)) name = '${name}_'; +class Translations extends _BaseTranslations { + @override + final BuildContext? _context; + Translations._(this._context); + + static Translations of(BuildContext context) { + context.locale; + return Translations._(context); + } + +'''); + + _generateClassMembers(buffer, root, ' '); + buffer.writeln('}'); + _generateNestedClasses(buffer, root); + + await output.writeAsString(buffer.toString()); +} + +TranslationNode _buildTranslationTree(String key, dynamic value) { + if (value is Map) { + final children = {}; + for (final entry in value.entries) { + children[entry.key] = _buildTranslationTree(entry.key, entry.value); + } + return TranslationNode(key: key, children: children); + } else { + final stringValue = value.toString(); + final params = _extractParams(stringValue); + return TranslationNode(key: key, value: stringValue, params: params); + } +} + +List _extractParams(String value) { + final params = {}; + + final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}'); + for (final match in icuRegex.allMatches(value)) { + final name = match.group(1)!; + final icuType = match.group(2)!; + final icuContent = match.group(3) ?? ''; + + if (params.containsKey(name)) continue; + + String type; + if (icuType == 'plural' || icuType == 'number') { + type = 'int'; + } else if (icuType == 'select') { + final hasTrueFalse = RegExp(r',\s*(true|false)\s*\{').hasMatch(icuContent); + type = hasTrueFalse ? 'bool' : 'String'; + } else { + type = 'String'; + } + + params[name] = TranslationParam(name, type); + } + + var cleanedValue = value; + var depth = 0; + var icuStart = -1; + + for (var i = 0; i < value.length; i++) { + if (value[i] == '{') { + if (depth == 0) icuStart = i; + depth++; + } else if (value[i] == '}') { + depth--; + if (depth == 0 && icuStart >= 0) { + final block = value.substring(icuStart, i + 1); + if (RegExp(r'^\{\w+,').hasMatch(block)) { + cleanedValue = cleanedValue.replaceFirst(block, ''); + } + icuStart = -1; + } + } + } + + final simpleRegex = RegExp(r'\{(\w+)\}'); + for (final match in simpleRegex.allMatches(cleanedValue)) { + final name = match.group(1)!; + + if (params.containsKey(name)) continue; + + String type; + if (_kIntParamNames.contains(name.toLowerCase())) { + type = 'int'; + } else { + type = 'Object'; + } + + params[name] = TranslationParam(name, type); + } + + return params.values.toList(); +} + +void _generateClassMembers(StringBuffer buffer, TranslationNode node, String indent, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final dartName = _escapeName(childKey); + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (child.isLeaf) { + if (child.hasParams) { + _generateMethod(buffer, dartName, fullKey, child.params, indent); + } else { + _generateGetter(buffer, dartName, fullKey, indent); + } + } else { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln('${indent}late final $dartName = $className._(_context);'); + } + } +} + +void _generateGetter(StringBuffer buffer, String dartName, String translationKey, String indent) { + buffer.writeln('${indent}String get $dartName => _t(\'$translationKey\');'); +} + +void _generateMethod( + StringBuffer buffer, + String dartName, + String translationKey, + List params, + String indent, +) { + final paramList = params.map((p) => 'required ${p.type} ${_escapeName(p.name)}').join(', '); + final argsMap = params.map((p) => '\'${p.name}\': ${_escapeName(p.name)}').join(', '); + buffer.writeln('${indent}String $dartName({$paramList}) => _t(\'$translationKey\', {$argsMap});'); +} + +void _generateNestedClasses(StringBuffer buffer, TranslationNode node, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (!child.isLeaf && child.children.isNotEmpty) { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln(); + buffer.writeln('class $className extends _BaseTranslations {'); + buffer.writeln(' @override'); + buffer.writeln(' final BuildContext? _context;'); + buffer.writeln(' $className._(this._context);'); + _generateClassMembers(buffer, child, ' ', fullKey); + buffer.writeln('}'); + _generateNestedClasses(buffer, child, fullKey); + } + } +} + +String _toNestedClassName(String prefix, String key) { + final parts = []; + if (prefix.isNotEmpty) { + parts.addAll(prefix.split('.')); + } + parts.add(key); + + final result = StringBuffer('_'); + for (final part in parts) { + final words = part.split('_'); + for (final word in words) { + if (word.isNotEmpty) { + result.write(word[0].toUpperCase()); + if (word.length > 1) { + result.write(word.substring(1).toLowerCase()); + } + } + } + } + result.write('Translations'); + + return result.toString(); +} + +String _escapeName(String name) { + if (_kReservedWords.contains(name)) { + return '$name\$'; + } + if (RegExp(r'^[0-9]').hasMatch(name)) { + return 'k$name'; + } return name; } diff --git a/mobile/makefile b/mobile/makefile index b90e95c902..ef134a1466 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -37,7 +37,7 @@ translation: dart run easy_localization:generate -S ../i18n dart run bin/generate_keys.dart dart format lib/generated/codegen_loader.g.dart - dart format lib/generated/intl_keys.g.dart + dart format lib/generated/translations.g.dart analyze: dart analyze --fatal-infos diff --git a/mobile/mise.toml b/mobile/mise.toml index cdafd1cc18..6767836aa3 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -32,13 +32,7 @@ depends = [ [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [ - { task = "//i18n:format-fix" }, - { tasks = [ - "i18n:loader", - "i18n:keys", - ] }, -] +run = [{ task = "//i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -158,10 +152,10 @@ run = [ description = "Generate i18n keys" hide = true sources = ["i18n/en.json"] -outputs = "lib/generated/intl_keys.g.dart" +outputs = "lib/generated/translations.g.dart" run = [ "dart run bin/generate_keys.dart", - "dart format lib/generated/intl_keys.g.dart", + "dart format lib/generated/translations.g.dart", ] [tasks."analyze:dart"]