mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 01:11:32 +03:00
feat: typed translation generator
This commit is contained in:
@@ -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<String, dynamic>;
|
||||
|
||||
final outputFile = File('lib/generated/translations.g.dart');
|
||||
await _generateTranslations(translations, outputFile);
|
||||
print('Generated ${outputFile.path}');
|
||||
}
|
||||
|
||||
Future<void> _generate(File source, File output) async {
|
||||
final content = await source.readAsString();
|
||||
final translations = json.decode(content) as Map<String, dynamic>;
|
||||
class TranslationNode {
|
||||
final String key;
|
||||
final String? value;
|
||||
final Map<String, TranslationNode> children;
|
||||
final List<TranslationParam> params;
|
||||
|
||||
const TranslationNode({
|
||||
required this.key,
|
||||
this.value,
|
||||
Map<String, TranslationNode>? children,
|
||||
List<TranslationParam>? 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<void> _generateTranslations(Map<String, dynamic> 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';
|
||||
|
||||
extension TranslationsExtension on BuildContext {
|
||||
Translations get t => Translations.of(this);
|
||||
}
|
||||
|
||||
class StaticTranslations {
|
||||
StaticTranslations._();
|
||||
static final instance = Translations._(null);
|
||||
}
|
||||
|
||||
abstract class _BaseTranslations {
|
||||
BuildContext? get _context;
|
||||
|
||||
String _t(String key, [Map<String, Object>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Translations extends _BaseTranslations {
|
||||
@override
|
||||
final BuildContext? _context;
|
||||
Translations._(this._context);
|
||||
|
||||
static Translations of(BuildContext context) {
|
||||
context.locale;
|
||||
return Translations._(context);
|
||||
}
|
||||
|
||||
''');
|
||||
|
||||
_writeKeys(buffer, translations);
|
||||
_generateClassMembers(buffer, root, ' ');
|
||||
buffer.writeln('}');
|
||||
_generateNestedClasses(buffer, root);
|
||||
|
||||
await output.writeAsString(buffer.toString());
|
||||
}
|
||||
|
||||
void _writeKeys(
|
||||
StringBuffer buffer,
|
||||
Map<String, dynamic> map, [
|
||||
String prefix = '',
|
||||
]) {
|
||||
for (final entry in map.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
|
||||
TranslationNode _buildTranslationTree(String key, dynamic value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
_writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key');
|
||||
final children = <String, TranslationNode>{};
|
||||
for (final entry in value.entries) {
|
||||
children[entry.key] = _buildTranslationTree(entry.key, entry.value);
|
||||
}
|
||||
return TranslationNode(key: key, children: children);
|
||||
} else {
|
||||
final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key');
|
||||
final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.');
|
||||
buffer.writeln(' static const $name = \'$path\';');
|
||||
final stringValue = value.toString();
|
||||
final params = _extractParams(stringValue);
|
||||
return TranslationNode(key: key, value: stringValue, params: params);
|
||||
}
|
||||
}
|
||||
|
||||
List<TranslationParam> _extractParams(String value) {
|
||||
final params = <String, TranslationParam>{};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}_';
|
||||
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<TranslationParam> 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 = <String>[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user