mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 17:25:11 +03:00
feat: typed translation generator
This commit is contained in:
@@ -3,7 +3,99 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
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 {
|
void main() async {
|
||||||
final sourceFile = File('../i18n/en.json');
|
final sourceFile = File('../i18n/en.json');
|
||||||
@@ -15,49 +107,258 @@ void main() async {
|
|||||||
final outputDir = Directory('lib/generated');
|
final outputDir = Directory('lib/generated');
|
||||||
await outputDir.create(recursive: true);
|
await outputDir.create(recursive: true);
|
||||||
|
|
||||||
final outputFile = File('lib/generated/intl_keys.g.dart');
|
final content = await sourceFile.readAsString();
|
||||||
await _generate(sourceFile, outputFile);
|
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}');
|
print('Generated ${outputFile.path}');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _generate(File source, File output) async {
|
class TranslationNode {
|
||||||
final content = await source.readAsString();
|
final String key;
|
||||||
final translations = json.decode(content) as Map<String, dynamic>;
|
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('''
|
final buffer = StringBuffer('''
|
||||||
// DO NOT EDIT. This is code generated via generate_keys.dart
|
// 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);
|
extension TranslationsExtension on BuildContext {
|
||||||
buffer.writeln('}');
|
Translations get t => Translations.of(this);
|
||||||
|
|
||||||
await output.writeAsString(buffer.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeKeys(
|
class StaticTranslations {
|
||||||
StringBuffer buffer,
|
StaticTranslations._();
|
||||||
Map<String, dynamic> map, [
|
static final instance = Translations._(null);
|
||||||
String prefix = '',
|
}
|
||||||
]) {
|
|
||||||
for (final entry in map.entries) {
|
|
||||||
final key = entry.key;
|
|
||||||
final value = entry.value;
|
|
||||||
|
|
||||||
if (value is Map<String, dynamic>) {
|
abstract class _BaseTranslations {
|
||||||
_writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key');
|
BuildContext? get _context;
|
||||||
} else {
|
|
||||||
final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key');
|
String _t(String key, [Map<String, Object>? args]) {
|
||||||
final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.');
|
if (key.isEmpty) return '';
|
||||||
buffer.writeln(' static const $name = \'$path\';');
|
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) {
|
class Translations extends _BaseTranslations {
|
||||||
name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
@override
|
||||||
if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name';
|
final BuildContext? _context;
|
||||||
if (_kReservedWords.contains(name)) name = '${name}_';
|
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<String, dynamic>) {
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ translation:
|
|||||||
dart run easy_localization:generate -S ../i18n
|
dart run easy_localization:generate -S ../i18n
|
||||||
dart run bin/generate_keys.dart
|
dart run bin/generate_keys.dart
|
||||||
dart format lib/generated/codegen_loader.g.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:
|
analyze:
|
||||||
dart analyze --fatal-infos
|
dart analyze --fatal-infos
|
||||||
|
|||||||
@@ -32,13 +32,7 @@ depends = [
|
|||||||
[tasks."codegen:translation"]
|
[tasks."codegen:translation"]
|
||||||
alias = "translation"
|
alias = "translation"
|
||||||
description = "Generate translations from i18n JSONs"
|
description = "Generate translations from i18n JSONs"
|
||||||
run = [
|
run = [{ task = "//i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }]
|
||||||
{ task = "//i18n:format-fix" },
|
|
||||||
{ tasks = [
|
|
||||||
"i18n:loader",
|
|
||||||
"i18n:keys",
|
|
||||||
] },
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."codegen:app-icon"]
|
[tasks."codegen:app-icon"]
|
||||||
description = "Generate app icons"
|
description = "Generate app icons"
|
||||||
@@ -158,10 +152,10 @@ run = [
|
|||||||
description = "Generate i18n keys"
|
description = "Generate i18n keys"
|
||||||
hide = true
|
hide = true
|
||||||
sources = ["i18n/en.json"]
|
sources = ["i18n/en.json"]
|
||||||
outputs = "lib/generated/intl_keys.g.dart"
|
outputs = "lib/generated/translations.g.dart"
|
||||||
run = [
|
run = [
|
||||||
"dart run bin/generate_keys.dart",
|
"dart run bin/generate_keys.dart",
|
||||||
"dart format lib/generated/intl_keys.g.dart",
|
"dart format lib/generated/translations.g.dart",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tasks."analyze:dart"]
|
[tasks."analyze:dart"]
|
||||||
|
|||||||
Reference in New Issue
Block a user