Passed
Push — master ( 3067ee...69b207 )
by Thomas
13:22
created

TranslationsImportExportTask   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 349
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 191
c 3
b 0
f 0
dl 0
loc 349
rs 4.08
wmc 59

9 Methods

Rating   Name   Duplication   Size   Complexity  
A isEnabled() 0 3 1
A run() 0 37 5
A importFromExcel() 0 18 4
A normalizeRow() 0 11 4
A getDataFromRows() 0 18 4
B importTranslations() 0 60 10
F exportTranslations() 0 118 28
A getLangPath() 0 4 1
A importFromCsv() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like TranslationsImportExportTask often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TranslationsImportExportTask, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\Multilingual;
4
5
use Exception;
6
use SilverStripe\Dev\Debug;
7
use SilverStripe\Dev\BuildTask;
8
use SilverStripe\Control\Director;
9
use SilverStripe\i18n\Messages\Writer;
10
use PhpOffice\PhpSpreadsheet\IOFactory;
0 ignored issues
show
Bug introduced by
The type PhpOffice\PhpSpreadsheet\IOFactory was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use SilverStripe\Core\Injector\Injector;
12
use SilverStripe\i18n\Messages\YamlReader;
13
use LeKoala\ExcelImportExport\ExcelImportExport;
0 ignored issues
show
Bug introduced by
The type LeKoala\ExcelImportExport\ExcelImportExport was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use SilverStripe\Core\Manifest\ModuleResourceLoader;
15
use SilverStripe\i18n\i18n;
16
17
/**
18
 * Helps exporting and importing labels from a csv or xls
19
 */
20
class TranslationsImportExportTask extends BuildTask
21
{
22
    use BuildTaskTools;
23
24
    /**
25
     * @var string
26
     */
27
    private static $segment = 'TranslationsImportExportTask';
0 ignored issues
show
introduced by
The private property $segment is not used, and could be removed.
Loading history...
28
    /**
29
     * @var string
30
     */
31
    protected $title = "Translations import export task";
32
    /**
33
     * @var string
34
     */
35
    protected $description = "Easily import and export translations";
36
37
    /**
38
     * @var bool
39
     */
40
    public $debug;
41
42
    /**
43
     * @param \SilverStripe\Control\HTTPRequest $request
44
     * @return void
45
     */
46
    public function run($request)
47
    {
48
        $this->request = $request;
49
        $modules = $this->getModulesAndThemes();
50
        $this->addOption("import", "Import translations", false);
51
        $this->addOption("export", "Export translations", false);
52
        $this->addOption("export_untranslated", "Export untranslated", false);
53
        $this->addOption("export_auto_translate", "Translate exported strings", false);
54
        $this->addOption("export_only", "Export only these lang (comma separated)");
55
        $this->addOption("debug", "Show debug output and do not write files", false);
56
        $this->addOption("excel", "Use excel if possible (require excel-import-export module)", true);
57
        $this->addOption("module", "Module", null, $modules);
58
        $options = $this->askOptions();
59
60
        $module = $options['module'];
61
        $import = $options['import'];
62
        $excel = $options['excel'];
63
        $export = $options['export'];
64
        $export_only = $options['export_only'];
65
        $export_untranslated = $options['export_untranslated'];
66
        $export_auto_translate = $options['export_auto_translate'];
67
68
        $this->debug = $options['debug'];
69
70
        if ($module) {
71
            if ($import) {
72
                $this->importTranslations($module);
73
            }
74
            if ($export) {
75
                $onlyLang = [];
76
                if ($export_only) {
77
                    $onlyLang = explode(",", $export_only);
78
                }
79
                $this->exportTranslations($module, $excel, $onlyLang, $export_untranslated, $export_auto_translate);
80
            }
81
        } else {
82
            $this->message("Please select a module");
83
        }
84
    }
85
86
    protected function getLangPath(string $module): string
87
    {
88
        $langPath = ModuleResourceLoader::resourcePath($module . ':lang');
89
        return Director::baseFolder() . '/' . str_replace([':', '\\'], '/', $langPath);
90
    }
91
92
    /**
93
     * @param string $module
94
     * @return void
95
     */
96
    protected function importTranslations($module)
97
    {
98
        $fullLangPath = $this->getLangPath($module);
99
        $modulePath = dirname($fullLangPath);
100
101
        $excelFile = $modulePath . "/lang.xlsx";
102
        $csvFile = $modulePath . "/lang.csv";
103
104
        $data = null;
105
        if (is_file($excelFile)) {
106
            $this->message("Importing $excelFile");
107
            $data = $this->importFromExcel($excelFile);
108
        } elseif (is_file($csvFile)) {
109
            $this->message("Importing $csvFile");
110
            $data = $this->importFromCsv($csvFile);
111
        }
112
113
        if (!$data) {
114
            $this->message("No data to import");
115
            return;
116
        }
117
118
        if ($this->debug) {
119
            Debug::dump($data);
120
        }
121
122
        $header = array_keys($data[0]);
123
        $count = count($header);
124
        $writer = Injector::inst()->create(Writer::class);
125
        $langs = array_slice($header, 1, $count);
126
        $new = 0;
127
        foreach ($langs as $lang) {
128
            // $entities = [];
129
130
            // keep original
131
            $reader = new YamlReader;
132
            $entities = $reader->read($lang, $fullLangPath . '/' . $lang . '.yml');
133
            foreach ($data as $row) {
134
                $key = trim($row['\ufeffkey'] ?? $row['key'] ?? '');
135
                if (!$key) {
136
                    $this->message("invalid row " . json_encode($row));
137
                    continue;
138
                }
139
                $value = $row[$lang];
140
                if (is_string($value)) {
141
                    $value = trim($value);
142
                }
143
                $new++;
144
                $entities[$key] = $value;
145
            }
146
            if (!$this->debug) {
147
                $writer->write(
148
                    $entities,
149
                    $lang,
150
                    dirname($fullLangPath)
151
                );
152
                $this->message("Imported $new messages in $lang");
153
            } else {
154
                Debug::show($lang);
155
                Debug::dump($entities);
156
            }
157
        }
158
    }
159
160
    /**
161
     * @param string $file
162
     * @return array<int,array<mixed>>
163
     */
164
    public function importFromExcel($file)
165
    {
166
        $spreadsheet = IOFactory::load($file);
167
        $worksheet = $spreadsheet->getActiveSheet();
168
        $rows = [];
169
        foreach ($worksheet->getRowIterator() as $row) {
170
            $cellIterator = $row->getCellIterator();
171
            // $cellIterator->setIterateOnlyExistingCells(true);
172
            $cells = [];
173
            foreach ($cellIterator as $cell) {
174
                $cells[] = $cell->getValue();
175
            }
176
            if (empty($cells)) {
177
                break;
178
            }
179
            $rows[] = $cells;
180
        }
181
        return $this->getDataFromRows($rows);
182
    }
183
184
    /**
185
     * @param array<array<mixed>> $rows
186
     * @return array<int,array<mixed>>
187
     */
188
    protected function getDataFromRows($rows)
189
    {
190
        $header = array_shift($rows);
191
        $firstKey = $header[0];
192
        if ($firstKey == 'key') {
193
            $header[0] = 'key'; // Fix some weird stuff
194
        }
195
        $count = count($header);
196
        $data = [];
197
        foreach ($rows as $row) {
198
            while (count($row) < $count) {
199
                $row[] = '';
200
            }
201
            $row = array_slice($row, 0, $count);
202
            $row = $this->normalizeRow($row);
203
            $data[] = array_combine($header, $row);
204
        }
205
        return $data;
206
    }
207
208
    /**
209
     * @return array<int,array<mixed>>
210
     */
211
    protected function importFromCsv(string $file)
212
    {
213
        $arr = file($file);
214
        if (!$arr) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $arr of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
215
            return [];
216
        }
217
        $rows = array_map('str_getcsv', $arr);
218
        return $this->getDataFromRows($rows);
219
    }
220
221
    /**
222
     * @param array<int,mixed> $row
223
     * @return array<int,mixed>
224
     */
225
    protected function normalizeRow($row)
226
    {
227
        foreach ($row as $idx => $value) {
228
            if ($idx == 0) {
229
                continue;
230
            }
231
            if (strpos($value, '{"') === 0) {
232
                $row[$idx] = json_decode($value, true);
233
            }
234
        }
235
        return $row;
236
    }
237
238
    /**
239
     * @param string $module
240
     * @param boolean $excel
241
     * @param array<string> $onlyLang
242
     * @param bool $untranslated
243
     * @param bool $translate
244
     * @return void
245
     */
246
    public function exportTranslations($module, $excel = true, $onlyLang = [], $untranslated = false, $translate = false)
247
    {
248
        $fullLangPath = $this->getLangPath($module);
249
250
        $translationFiles = glob($fullLangPath . '/*.yml');
251
        if ($translationFiles === false) {
252
            $this->message("No yml");
253
            return;
254
        }
255
256
        // Collect messages in all lang
257
        $allMessages = [];
258
        $headers = ['key'];
259
        $default = [];
260
        $defaultFile = null;
261
        $defaultLang = substr(i18n::get_locale(), 0, 2);
262
        foreach ($translationFiles as $translationFile) {
263
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
264
            if ($lang == $defaultLang) {
265
                $defaultFile = $translationFile;
266
            }
267
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
268
                continue;
269
            }
270
            $headers[] = $lang;
271
            $default[] = '';
272
        }
273
274
275
        $masterMessages = [];
276
        if ($untranslated) {
277
            $reader = new YamlReader;
278
            $masterMessages = $reader->read($defaultLang, $defaultFile);
279
        }
280
281
        $i = 0;
282
        foreach ($translationFiles as $translationFile) {
283
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
284
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
285
                continue;
286
            }
287
            $reader = new YamlReader;
288
            $messages = $reader->read($lang, $translationFile);
289
290
            foreach ($messages as $entityKey => $v) {
291
                if ($untranslated) {
292
                    if (isset($masterMessages[$entityKey]) && $masterMessages[$entityKey] != $v) {
293
                        continue;
294
                    }
295
                }
296
297
                if (!isset($allMessages[$entityKey])) {
298
                    $allMessages[$entityKey] = $default;
299
                }
300
                // Plurals can be arrays and need to be converted
301
                if (is_array($v)) {
302
                    $v = json_encode($v);
303
                }
304
305
                // Attempt auto translation / 200
306
                if ($translate && count($allMessages) < 200) {
307
                    $v = EasyNmtHelper::translate($v, $lang, $defaultLang);
0 ignored issues
show
Bug introduced by
It seems like $lang can also be of type array; however, parameter $targetLang of LeKoala\Multilingual\EasyNmtHelper::translate() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

307
                    $v = EasyNmtHelper::translate($v, /** @scrutinizer ignore-type */ $lang, $defaultLang);
Loading history...
308
                }
309
310
                $allMessages[$entityKey][$i] = $v;
311
            }
312
            $i++;
313
        }
314
        // don't sort this will be mess up git when merging later
315
        // ksort($allMessages);
316
        if ($this->debug) {
317
            Debug::show($allMessages);
318
        }
319
320
        // Write them to a file
321
        if ($excel && class_exists(ExcelImportExport::class)) {
322
            $ext = 'xlsx';
323
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
324
            if ($this->debug) {
325
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
326
                return;
327
            }
328
            if (is_file($destinationFilename)) {
329
                unlink($destinationFilename);
330
            }
331
            // First row contains headers
332
            $data = [$headers];
333
            // Add a row per lang
334
            foreach ($allMessages as $key => $translations) {
335
                array_unshift($translations, $key);
336
                $data[] = $translations;
337
            }
338
            ExcelImportExport::arrayToFile($data, $destinationFilename);
339
        } else {
340
            $ext = 'csv';
341
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
342
            if ($this->debug) {
343
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
344
                return;
345
            }
346
            if (is_file($destinationFilename)) {
347
                unlink($destinationFilename);
348
            }
349
            $fp = fopen($destinationFilename, 'w');
350
            if ($fp === false) {
351
                throw new Exception("Failed to open stream");
352
            }
353
            // UTF 8 fix
354
            fprintf($fp, "\xEF\xBB\xBF");
355
            fputcsv($fp, $headers);
356
            foreach ($allMessages as $key => $translations) {
357
                array_unshift($translations, $key);
358
                fputcsv($fp, $translations);
359
            }
360
            fclose($fp);
361
        }
362
363
        $this->message("Translations written to $destinationFilename");
364
    }
365
366
    public function isEnabled(): bool
367
    {
368
        return Director::isDev();
369
    }
370
}
371