| 1 | import 'dart:convert'; |
| 2 | import 'dart:io'; |
| 3 | import 'package:http/http.dart' as http; // very_insecure_http_do_not_use |
| 4 | import 'package:args/args.dart'; |
| 5 | import './print_verbose_dummy.dart'; |
| 6 | |
| 7 | class TranslationChecker { |
| 8 | final String ollamaBaseUrl; |
| 9 | final String model; |
| 10 | |
| 11 | TranslationChecker({ |
| 12 | required this.ollamaBaseUrl, |
| 13 | required this.model, |
| 14 | }); |
| 15 | |
| 16 | /// Check and correct translations line by line |
| 17 | Future<void> checkTranslations({ |
| 18 | required String sourceArbPath, |
| 19 | required String destinationArbPath, |
| 20 | String? specificKey, |
| 21 | }) async { |
| 22 | final sourceContent = await _readArbFile(sourceArbPath); |
| 23 | final destinationContent = await _readArbFile(destinationArbPath); |
| 24 | |
| 25 | final sourceMap = json.decode(sourceContent) as Map<String, dynamic>; |
| 26 | final destinationMap = json.decode(destinationContent) as Map<String, dynamic>; |
| 27 | |
| 28 | print('Found ${sourceMap.length} keys in source, ${destinationMap.length} in destination'); |
| 29 | |
| 30 | final keysToProcess = specificKey != null |
| 31 | ? (sourceMap.containsKey(specificKey) ? [specificKey] : <String>[]) |
| 32 | : sourceMap.keys.toList(); |
| 33 | |
| 34 | if (specificKey != null && keysToProcess.isEmpty) { |
| 35 | print('Error: Key "$specificKey" not found in source file'); |
| 36 | return; |
| 37 | } |
| 38 | |
| 39 | print('Processing ${keysToProcess.length} translations...'); |
| 40 | |
| 41 | int processed = 0; |
| 42 | int corrected = 0; |
| 43 | |
| 44 | for (final key in keysToProcess) { |
| 45 | final sourceValue = sourceMap[key]; |
| 46 | final destinationValue = destinationMap[key]; |
| 47 | |
| 48 | if (sourceValue is String && destinationValue is String) { |
| 49 | final correctedTranslation = await _checkSingleTranslation( |
| 50 | key: key, |
| 51 | sourceText: sourceValue, |
| 52 | currentTranslation: destinationValue, |
| 53 | sourceFile: sourceArbPath, |
| 54 | destinationFile: destinationArbPath, |
| 55 | ); |
| 56 | |
| 57 | if (correctedTranslation != destinationValue) { |
| 58 | destinationMap[key] = correctedTranslation; |
| 59 | corrected++; |
| 60 | print('Processed: "$key" -> CORRECTED'); |
| 61 | print(' - eng : "$sourceValue"'); |
| 62 | print(' - dst orig: "$destinationValue"'); |
| 63 | print(' - dst new : "$correctedTranslation"'); |
| 64 | await _writeArbFile(destinationArbPath, destinationMap); |
| 65 | } else { |
| 66 | print('Processed: "$key" -> VERIFIED'); |
| 67 | } |
| 68 | } else { |
| 69 | print('Processed: "$key" -> SKIPPED (non-string)'); |
| 70 | } |
| 71 | |
| 72 | processed++; |
| 73 | } |
| 74 | |
| 75 | await _writeArbFile(destinationArbPath, destinationMap); |
| 76 | |
| 77 | print(''); |
| 78 | print('Summary:'); |
| 79 | print('Processed: $processed keys'); |
| 80 | print('Corrected: $corrected keys'); |
| 81 | print('Updated: $destinationArbPath'); |
| 82 | } |
| 83 | |
| 84 | Future<String> _checkSingleTranslation({ |
| 85 | required String key, |
| 86 | required String sourceText, |
| 87 | required String currentTranslation, |
| 88 | required String sourceFile, |
| 89 | required String destinationFile, |
| 90 | }) async { |
| 91 | final prompt = _createTranslationCheckPrompt( |
| 92 | sourceFile: sourceFile, |
| 93 | destinationFile: destinationFile, |
| 94 | key: key, |
| 95 | sourceText: sourceText, |
| 96 | currentTranslation: currentTranslation, |
| 97 | ); |
| 98 | |
| 99 | try { |
| 100 | final response = await _callLLM(prompt); |
| 101 | |
| 102 | // Extract JSON from the response |
| 103 | final correctedTranslation = _extractJsonFromResponse(response); |
| 104 | |
| 105 | if (correctedTranslation != null && correctedTranslation != currentTranslation) { |
| 106 | return correctedTranslation; |
| 107 | } |
| 108 | |
| 109 | return currentTranslation; |
| 110 | } catch (e) { |
| 111 | print('Error checking translation for key "$key": $e'); |
| 112 | return currentTranslation; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | String _createTranslationCheckPrompt({ |
| 117 | required String key, |
| 118 | required String sourceText, |
| 119 | required String currentTranslation, |
| 120 | required String sourceFile, |
| 121 | required String destinationFile, |
| 122 | }) { |
| 123 | return ''' |
| 124 | You are a professional translator checking the accuracy of a translation for a cryptocurrency wallet called Cake Wallet. |
| 125 | |
| 126 | source file is: ${sourceFile}, destination file is: ${destinationFile}. |
| 127 | |
| 128 | Rules you must obey: |
| 129 | - respect company branding and style (Cake Wallet, Cake Pay, Bird Pay and other brandings stay the same no matter the language) |
| 130 | - be accurate and remember that Cake Wallet lets you hold cryptocurrency, so use words that are associated with cryptocurrency and wallets more preferably than words that are associated with banks. |
| 131 | - elements with \${value} should remain non-translated as they are used by codegen to dynamically insert the value. |
| 132 | - make sure that the translation that you output improves the original translation, and doesn't change the meaning of it. |
| 133 | - if original translation contains abbreviations, make sure to translate them as well, and do not expand them. |
| 134 | - Make sure to use the same capitalization as the original translation, if it is capitalized, capitalize the translation as well to keep the UI consistent. |
| 135 | - maintain new lines with `\n` preserved in the translation. |
| 136 | |
| 137 | |
| 138 | KEY: "$key" |
| 139 | SOURCE TEXT (English): "${sourceText.replaceAll("\n", "\\n")}" |
| 140 | CURRENT TRANSLATION: "${currentTranslation.replaceAll("\n", "\\n")}" |
| 141 | |
| 142 | Please check if the current translation is accurate and natural. If it needs correction, provide the corrected version. |
| 143 | |
| 144 | Before you return the JSON object, think about the translation and make sure that it is accurate and natural. |
| 145 | |
| 146 | IMPORTANT: Respond with a JSON object in the exact format, you must think about the translation and at the very end you must return the JSON object with the most correct (in destination language) translation, make sure to use proper grammar, spelling and punctuation: |
| 147 | { |
| 148 | "corrected_translation": "your corrected translation here" |
| 149 | } |
| 150 | '''; |
| 151 | } |
| 152 | |
| 153 | Future<String> _callLLM(String prompt) async { |
| 154 | final headers = { |
| 155 | 'Content-Type': 'application/json', |
| 156 | }; |
| 157 | |
| 158 | final body = json.encode({ |
| 159 | 'model': model, |
| 160 | 'prompt': prompt, |
| 161 | 'stream': false, |
| 162 | 'options': { |
| 163 | 'temperature': 0.3, |
| 164 | 'num_predict': 1000, |
| 165 | } |
| 166 | }); |
| 167 | |
| 168 | final response = await http.post( |
| 169 | Uri.parse('$ollamaBaseUrl/api/generate'), |
| 170 | headers: headers, |
| 171 | body: body, |
| 172 | ); |
| 173 | |
| 174 | if (response.statusCode != 200) { |
| 175 | throw Exception('Ollama API error: ${response.statusCode} - ${response.body}'); |
| 176 | } |
| 177 | |
| 178 | final data = json.decode(response.body); |
| 179 | return data['response'] as String; |
| 180 | } |
| 181 | |
| 182 | String? _extractJsonFromResponse(String response) { |
| 183 | try { |
| 184 | // Find JSON object in the response |
| 185 | final jsonStart = response.indexOf('{'); |
| 186 | final jsonEnd = response.lastIndexOf('}'); |
| 187 | |
| 188 | if (jsonStart == -1 || jsonEnd == -1 || jsonStart >= jsonEnd) { |
| 189 | print('No valid JSON found in LLM response'); |
| 190 | return null; |
| 191 | } |
| 192 | |
| 193 | final jsonText = response.substring(jsonStart, jsonEnd + 1); |
| 194 | final parsed = json.decode(jsonText) as Map<String, dynamic>; |
| 195 | |
| 196 | return parsed['corrected_translation'] as String?; |
| 197 | } catch (e) { |
| 198 | print('Error extracting JSON from LLM response: $e'); |
| 199 | return null; |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | Future<String> _readArbFile(String path) async { |
| 204 | final file = File(path); |
| 205 | if (!file.existsSync()) { |
| 206 | throw Exception('ARB file not found: $path'); |
| 207 | } |
| 208 | return await file.readAsString(); |
| 209 | } |
| 210 | |
| 211 | Future<void> _writeArbFile(String path, Map<String, dynamic> content) async { |
| 212 | final file = File(path); |
| 213 | final prettyJson = const JsonEncoder.withIndent(' ').convert(content); |
| 214 | await file.writeAsString(prettyJson, flush: true); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | void main(List<String> args) async { |
| 219 | final parser = ArgParser() |
| 220 | ..addOption('source', |
| 221 | abbr: 's', |
| 222 | help: 'Path to source ARB file (e.g., ./res/values/strings_en.arb)', |
| 223 | mandatory: true) |
| 224 | ..addOption('destination', |
| 225 | abbr: 'd', |
| 226 | help: 'Path to destination ARB file (e.g., ./res/values/strings_pl.arb)', |
| 227 | mandatory: true) |
| 228 | ..addOption('key', abbr: 'k', help: 'Translate only this specific key (optional)') |
| 229 | ..addOption('ollama-url', |
| 230 | help: 'Ollama server URL (default: http://localhost:11434)', |
| 231 | defaultsTo: 'http://localhost:11434') |
| 232 | ..addOption('model', |
| 233 | help: 'Ollama model name (default: gpt-oss:120b)', defaultsTo: 'gpt-oss:120b') |
| 234 | ..addFlag('help', abbr: 'h', help: 'Show this help message', negatable: false); |
| 235 | |
| 236 | try { |
| 237 | final results = parser.parse(args); |
| 238 | |
| 239 | if (results['help'] as bool) { |
| 240 | print(parser.usage); |
| 241 | return; |
| 242 | } |
| 243 | |
| 244 | final sourcePath = results['source'] as String; |
| 245 | final destinationPath = results['destination'] as String; |
| 246 | final specificKey = results['key'] as String?; |
| 247 | final ollamaUrl = results['ollama-url'] as String; |
| 248 | final model = results['model'] as String; |
| 249 | |
| 250 | if (!File(sourcePath).existsSync()) { |
| 251 | print('Error: Source ARB file not found: $sourcePath'); |
| 252 | exit(1); |
| 253 | } |
| 254 | |
| 255 | if (!File(destinationPath).existsSync()) { |
| 256 | print('Error: Destination ARB file not found: $destinationPath'); |
| 257 | exit(1); |
| 258 | } |
| 259 | |
| 260 | print('Translation Checker'); |
| 261 | print('Source: $sourcePath'); |
| 262 | print('Destination: $destinationPath'); |
| 263 | print('Ollama URL: $ollamaUrl'); |
| 264 | print('Model: $model'); |
| 265 | if (specificKey != null) { |
| 266 | print('Key: $specificKey'); |
| 267 | } |
| 268 | print(''); |
| 269 | |
| 270 | final checker = TranslationChecker( |
| 271 | ollamaBaseUrl: ollamaUrl, |
| 272 | model: model, |
| 273 | ); |
| 274 | |
| 275 | await checker.checkTranslations( |
| 276 | sourceArbPath: sourcePath, |
| 277 | destinationArbPath: destinationPath, |
| 278 | specificKey: specificKey, |
| 279 | ); |
| 280 | } catch (e) { |
| 281 | print('Error: $e'); |
| 282 | print('Run with --help for more information.'); |
| 283 | exit(1); |
| 284 | } |
| 285 | } |