Skip to content

Commit 8a8865b

Browse files
authored
Merge pull request #7896 from neznaika0/feat-4.5-lang-finder
feat: Language translations finder and update
2 parents 2daf762 + 070d7e5 commit 8a8865b

File tree

11 files changed

+1091
-0
lines changed

11 files changed

+1091
-0
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
<?php
2+
3+
/**
4+
* This file is part of CodeIgniter 4 framework.
5+
*
6+
* (c) CodeIgniter Foundation <[email protected]>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace CodeIgniter\Commands\Translation;
13+
14+
use CodeIgniter\CLI\BaseCommand;
15+
use CodeIgniter\CLI\CLI;
16+
use CodeIgniter\Commands\Translation\LocalizationFinder\ArrayHelper;
17+
use Config\App;
18+
use Locale;
19+
use RecursiveDirectoryIterator;
20+
use RecursiveIteratorIterator;
21+
use SplFileInfo;
22+
23+
/**
24+
* @see \CodeIgniter\Commands\Translation\LocalizationFinderTest
25+
*/
26+
class LocalizationFinder extends BaseCommand
27+
{
28+
protected $group = 'Translation';
29+
protected $name = 'lang:find';
30+
protected $description = 'Find and save available phrases to translate.';
31+
protected $usage = 'lang:find [options]';
32+
protected $arguments = [];
33+
protected $options = [
34+
'--locale' => 'Specify locale (en, ru, etc.) to save files.',
35+
'--dir' => 'Directory to search for translations relative to APPPATH.',
36+
'--show-new' => 'Show only new translations in table. Does not write to files.',
37+
'--verbose' => 'Output detailed information.',
38+
];
39+
40+
/**
41+
* Flag for output detailed information
42+
*/
43+
private bool $verbose = false;
44+
45+
/**
46+
* Flag for showing only translations, without saving
47+
*/
48+
private bool $showNew = false;
49+
50+
private string $languagePath;
51+
52+
public function run(array $params)
53+
{
54+
$this->verbose = array_key_exists('verbose', $params);
55+
$this->showNew = array_key_exists('show-new', $params);
56+
$optionLocale = $params['locale'] ?? null;
57+
$optionDir = $params['dir'] ?? null;
58+
$currentLocale = Locale::getDefault();
59+
$currentDir = APPPATH;
60+
$this->languagePath = $currentDir . 'Language';
61+
62+
if (ENVIRONMENT === 'testing') {
63+
$currentDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR;
64+
$this->languagePath = SUPPORTPATH . 'Language';
65+
}
66+
67+
if (is_string($optionLocale)) {
68+
if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) {
69+
CLI::error(
70+
'Error: "' . $optionLocale . '" is not supported. Supported locales: '
71+
. implode(', ', config(App::class)->supportedLocales)
72+
);
73+
74+
return EXIT_USER_INPUT;
75+
}
76+
77+
$currentLocale = $optionLocale;
78+
}
79+
80+
if (is_string($optionDir)) {
81+
$tempCurrentDir = realpath($currentDir . $optionDir);
82+
83+
if (false === $tempCurrentDir) {
84+
CLI::error('Error: Directory must be located in "' . $currentDir . '"');
85+
86+
return EXIT_USER_INPUT;
87+
}
88+
89+
if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) {
90+
CLI::error('Error: Directory "' . $this->languagePath . '" restricted to scan.');
91+
92+
return EXIT_USER_INPUT;
93+
}
94+
95+
$currentDir = $tempCurrentDir;
96+
}
97+
98+
$this->process($currentDir, $currentLocale);
99+
100+
CLI::write('All operations done!');
101+
102+
return EXIT_SUCCESS;
103+
}
104+
105+
private function process(string $currentDir, string $currentLocale): void
106+
{
107+
$tableRows = [];
108+
$countNewKeys = 0;
109+
110+
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir));
111+
$files = iterator_to_array($iterator, true);
112+
ksort($files);
113+
114+
[$foundLanguageKeys, $countFiles] = $this->findLanguageKeysInFiles($files);
115+
ksort($foundLanguageKeys);
116+
117+
$languageDiff = [];
118+
$languageFoundGroups = array_unique(array_keys($foundLanguageKeys));
119+
120+
foreach ($languageFoundGroups as $langFileName) {
121+
$languageStoredKeys = [];
122+
$languageFilePath = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php';
123+
124+
if (is_file($languageFilePath)) {
125+
// Load old localization
126+
$languageStoredKeys = require $languageFilePath;
127+
}
128+
129+
$languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $languageStoredKeys);
130+
$countNewKeys += ArrayHelper::recursiveCount($languageDiff);
131+
132+
if ($this->showNew) {
133+
$tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows);
134+
} else {
135+
$newLanguageKeys = array_replace_recursive($foundLanguageKeys[$langFileName], $languageStoredKeys);
136+
137+
if ($languageDiff !== []) {
138+
if (false === file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys))) {
139+
$this->writeIsVerbose('Lang file ' . $langFileName . ' (error write).', 'red');
140+
} else {
141+
$this->writeIsVerbose('Lang file "' . $langFileName . '" successful updated!', 'green');
142+
}
143+
}
144+
}
145+
}
146+
147+
if ($this->showNew && $tableRows !== []) {
148+
sort($tableRows);
149+
CLI::table($tableRows, ['File', 'Key']);
150+
}
151+
152+
if (! $this->showNew && $countNewKeys > 0) {
153+
CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red');
154+
}
155+
156+
$this->writeIsVerbose('Files found: ' . $countFiles);
157+
$this->writeIsVerbose('New translates found: ' . $countNewKeys);
158+
}
159+
160+
/**
161+
* @param SplFileInfo|string $file
162+
*/
163+
private function findTranslationsInFile($file): array
164+
{
165+
$foundLanguageKeys = [];
166+
167+
if (is_string($file) && is_file($file)) {
168+
$file = new SplFileInfo($file);
169+
}
170+
171+
$fileContent = file_get_contents($file->getRealPath());
172+
preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches);
173+
174+
if ($matches[1] === []) {
175+
return [];
176+
}
177+
178+
foreach ($matches[1] as $phraseKey) {
179+
$phraseKeys = explode('.', $phraseKey);
180+
181+
// Language key not have Filename or Lang key
182+
if (count($phraseKeys) < 2) {
183+
continue;
184+
}
185+
186+
$languageFileName = array_shift($phraseKeys);
187+
$isEmptyNestedArray = ($languageFileName !== '' && $phraseKeys[0] === '')
188+
|| ($languageFileName === '' && $phraseKeys[0] !== '')
189+
|| ($languageFileName === '' && $phraseKeys[0] === '');
190+
191+
if ($isEmptyNestedArray) {
192+
continue;
193+
}
194+
195+
if (count($phraseKeys) === 1) {
196+
$foundLanguageKeys[$languageFileName][$phraseKeys[0]] = $phraseKey;
197+
} else {
198+
$childKeys = $this->buildMultiArray($phraseKeys, $phraseKey);
199+
200+
$foundLanguageKeys[$languageFileName] = array_replace_recursive($foundLanguageKeys[$languageFileName] ?? [], $childKeys);
201+
}
202+
}
203+
204+
return $foundLanguageKeys;
205+
}
206+
207+
private function isIgnoredFile(SplFileInfo $file): bool
208+
{
209+
if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) {
210+
return true;
211+
}
212+
213+
return $file->getExtension() !== 'php';
214+
}
215+
216+
private function templateFile(array $language = []): string
217+
{
218+
if ($language !== []) {
219+
$languageArrayString = var_export($language, true);
220+
221+
$code = <<<PHP
222+
<?php
223+
224+
return {$languageArrayString};
225+
226+
PHP;
227+
228+
return $this->replaceArraySyntax($code);
229+
}
230+
231+
return <<<'PHP'
232+
<?php
233+
234+
return [];
235+
236+
PHP;
237+
}
238+
239+
private function replaceArraySyntax(string $code): string
240+
{
241+
$tokens = token_get_all($code);
242+
$newTokens = $tokens;
243+
244+
foreach ($tokens as $i => $token) {
245+
if (is_array($token)) {
246+
[$tokenId, $tokenValue] = $token;
247+
248+
// Replace "array ("
249+
if (
250+
$tokenId === T_ARRAY
251+
&& $tokens[$i + 1][0] === T_WHITESPACE
252+
&& $tokens[$i + 2] === '('
253+
) {
254+
$newTokens[$i][1] = '[';
255+
$newTokens[$i + 1][1] = '';
256+
$newTokens[$i + 2] = '';
257+
}
258+
259+
// Replace indent
260+
if ($tokenId === T_WHITESPACE && preg_match('/\n([ ]+)/u', $tokenValue, $matches)) {
261+
$newTokens[$i][1] = "\n{$matches[1]}{$matches[1]}";
262+
}
263+
} // Replace ")"
264+
elseif ($token === ')') {
265+
$newTokens[$i] = ']';
266+
}
267+
}
268+
269+
$output = '';
270+
271+
foreach ($newTokens as $token) {
272+
$output .= $token[1] ?? $token;
273+
}
274+
275+
return $output;
276+
}
277+
278+
/**
279+
* Create multidimensional array from another keys
280+
*/
281+
private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array
282+
{
283+
$newArray = [];
284+
$lastIndex = array_pop($fromKeys);
285+
$current = &$newArray;
286+
287+
foreach ($fromKeys as $value) {
288+
$current[$value] = [];
289+
$current = &$current[$value];
290+
}
291+
292+
$current[$lastIndex] = $lastArrayValue;
293+
294+
return $newArray;
295+
}
296+
297+
/**
298+
* Convert multi arrays to specific CLI table rows (flat array)
299+
*/
300+
private function arrayToTableRows(string $langFileName, array $array): array
301+
{
302+
$rows = [];
303+
304+
foreach ($array as $value) {
305+
if (is_array($value)) {
306+
$rows = array_merge($rows, $this->arrayToTableRows($langFileName, $value));
307+
308+
continue;
309+
}
310+
311+
if (is_string($value)) {
312+
$rows[] = [$langFileName, $value];
313+
}
314+
}
315+
316+
return $rows;
317+
}
318+
319+
/**
320+
* Show details in the console if the flag is set
321+
*/
322+
private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void
323+
{
324+
if ($this->verbose) {
325+
CLI::write($text, $foreground, $background);
326+
}
327+
}
328+
329+
private function isSubDirectory(string $directory, string $rootDirectory): bool
330+
{
331+
return 0 === strncmp($directory, $rootDirectory, strlen($directory));
332+
}
333+
334+
/**
335+
* @param SplFileInfo[] $files
336+
*
337+
* @return array<int, array|int>
338+
* @phpstan-return list{0: array<string, array<string, string>>, 1: int}
339+
*/
340+
private function findLanguageKeysInFiles(array $files): array
341+
{
342+
$foundLanguageKeys = [];
343+
$countFiles = 0;
344+
345+
foreach ($files as $file) {
346+
if ($this->isIgnoredFile($file)) {
347+
continue;
348+
}
349+
350+
$this->writeIsVerbose('File found: ' . mb_substr($file->getRealPath(), mb_strlen(APPPATH)));
351+
$countFiles++;
352+
$foundLanguageKeys = array_replace_recursive($this->findTranslationsInFile($file), $foundLanguageKeys);
353+
}
354+
355+
return [$foundLanguageKeys, $countFiles];
356+
}
357+
}

0 commit comments

Comments
 (0)