Passed
Push — master ( 0815d7...3067ee )
by Thomas
02:27
created

TranslationsImportExportTask   C

Complexity

Total Complexity 57

Size/Duplication

Total Lines 340
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 187
c 2
b 0
f 0
dl 0
loc 340
rs 5.04
wmc 57

9 Methods

Rating   Name   Duplication   Size   Complexity  
A isEnabled() 0 3 1
A run() 0 35 5
A importFromExcel() 0 18 4
A normalizeRow() 0 11 4
A getDataFromRows() 0 18 4
B importTranslations() 0 60 10
F exportTranslations() 0 112 26
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_only", "Export only these lang (comma separated)");
54
        $this->addOption("debug", "Show debug output and do not write files", false);
55
        $this->addOption("excel", "Use excel if possible (require excel-import-export module)", true);
56
        $this->addOption("module", "Module", null, $modules);
57
        $options = $this->askOptions();
58
59
        $module = $options['module'];
60
        $import = $options['import'];
61
        $excel = $options['excel'];
62
        $export = $options['export'];
63
        $export_only = $options['export_only'];
64
        $export_untranslated = $options['export_untranslated'];
65
66
        $this->debug = $options['debug'];
67
68
        if ($module) {
69
            if ($import) {
70
                $this->importTranslations($module);
71
            }
72
            if ($export) {
73
                $onlyLang = [];
74
                if ($export_only) {
75
                    $onlyLang = explode(",", $export_only);
76
                }
77
                $this->exportTranslations($module, $excel, $onlyLang, $export_untranslated);
78
            }
79
        } else {
80
            $this->message("Please select a module");
81
        }
82
    }
83
84
    protected function getLangPath(string $module): string
85
    {
86
        $langPath = ModuleResourceLoader::resourcePath($module . ':lang');
87
        return Director::baseFolder() . '/' . str_replace([':', '\\'], '/', $langPath);
88
    }
89
90
    /**
91
     * @param string $module
92
     * @return void
93
     */
94
    protected function importTranslations($module)
95
    {
96
        $fullLangPath = $this->getLangPath($module);
97
        $modulePath = dirname($fullLangPath);
98
99
        $excelFile = $modulePath . "/lang.xlsx";
100
        $csvFile = $modulePath . "/lang.csv";
101
102
        $data = null;
103
        if (is_file($excelFile)) {
104
            $this->message("Importing $excelFile");
105
            $data = $this->importFromExcel($excelFile);
106
        } elseif (is_file($csvFile)) {
107
            $this->message("Importing $csvFile");
108
            $data = $this->importFromCsv($csvFile);
109
        }
110
111
        if (!$data) {
112
            $this->message("No data to import");
113
            return;
114
        }
115
116
        if ($this->debug) {
117
            Debug::dump($data);
118
        }
119
120
        $header = array_keys($data[0]);
121
        $count = count($header);
122
        $writer = Injector::inst()->create(Writer::class);
123
        $langs = array_slice($header, 1, $count);
124
        $new = 0;
125
        foreach ($langs as $lang) {
126
            // $entities = [];
127
128
            // keep original
129
            $reader = new YamlReader;
130
            $entities = $reader->read($lang, $fullLangPath . '/' . $lang . '.yml');
131
            foreach ($data as $row) {
132
                $key = trim($row['\ufeffkey'] ?? $row['key'] ?? '');
133
                if (!$key) {
134
                    $this->message("invalid row " . json_encode($row));
135
                    continue;
136
                }
137
                $value = $row[$lang];
138
                if (is_string($value)) {
139
                    $value = trim($value);
140
                }
141
                $new++;
142
                $entities[$key] = $value;
143
            }
144
            if (!$this->debug) {
145
                $writer->write(
146
                    $entities,
147
                    $lang,
148
                    dirname($fullLangPath)
149
                );
150
                $this->message("Imported $new messages in $lang");
151
            } else {
152
                Debug::show($lang);
153
                Debug::dump($entities);
154
            }
155
        }
156
    }
157
158
    /**
159
     * @param string $file
160
     * @return array<int,array<mixed>>
161
     */
162
    public function importFromExcel($file)
163
    {
164
        $spreadsheet = IOFactory::load($file);
165
        $worksheet = $spreadsheet->getActiveSheet();
166
        $rows = [];
167
        foreach ($worksheet->getRowIterator() as $row) {
168
            $cellIterator = $row->getCellIterator();
169
            // $cellIterator->setIterateOnlyExistingCells(true);
170
            $cells = [];
171
            foreach ($cellIterator as $cell) {
172
                $cells[] = $cell->getValue();
173
            }
174
            if (empty($cells)) {
175
                break;
176
            }
177
            $rows[] = $cells;
178
        }
179
        return $this->getDataFromRows($rows);
180
    }
181
182
    /**
183
     * @param array<array<mixed>> $rows
184
     * @return array<int,array<mixed>>
185
     */
186
    protected function getDataFromRows($rows)
187
    {
188
        $header = array_shift($rows);
189
        $firstKey = $header[0];
190
        if ($firstKey == 'key') {
191
            $header[0] = 'key'; // Fix some weird stuff
192
        }
193
        $count = count($header);
194
        $data = [];
195
        foreach ($rows as $row) {
196
            while (count($row) < $count) {
197
                $row[] = '';
198
            }
199
            $row = array_slice($row, 0, $count);
200
            $row = $this->normalizeRow($row);
201
            $data[] = array_combine($header, $row);
202
        }
203
        return $data;
204
    }
205
206
    /**
207
     * @return array<int,array<mixed>>
208
     */
209
    protected function importFromCsv(string $file)
210
    {
211
        $arr = file($file);
212
        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...
213
            return [];
214
        }
215
        $rows = array_map('str_getcsv', $arr);
216
        return $this->getDataFromRows($rows);
217
    }
218
219
    /**
220
     * @param array<int,mixed> $row
221
     * @return array<int,mixed>
222
     */
223
    protected function normalizeRow($row)
224
    {
225
        foreach ($row as $idx => $value) {
226
            if ($idx == 0) {
227
                continue;
228
            }
229
            if (strpos($value, '{"') === 0) {
230
                $row[$idx] = json_decode($value, true);
231
            }
232
        }
233
        return $row;
234
    }
235
236
    /**
237
     * @param string $module
238
     * @param boolean $excel
239
     * @param array<string> $onlyLang
240
     * @param bool $untranslated
241
     * @return void
242
     */
243
    public function exportTranslations($module, $excel = true, $onlyLang = [], $untranslated = false)
244
    {
245
        $fullLangPath = $this->getLangPath($module);
246
247
        $translationFiles = glob($fullLangPath . '/*.yml');
248
        if ($translationFiles === false) {
249
            $this->message("No yml");
250
            return;
251
        }
252
253
        // Collect messages in all lang
254
        $allMessages = [];
255
        $headers = ['key'];
256
        $default = [];
257
        $defaultFile = null;
258
        $defaultLang = substr(i18n::get_locale(), 0, 2);
259
        foreach ($translationFiles as $translationFile) {
260
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
261
            if ($lang == $defaultLang) {
262
                $defaultFile = $translationFile;
263
            }
264
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
265
                continue;
266
            }
267
            $headers[] = $lang;
268
            $default[] = '';
269
        }
270
271
272
        $masterMessages = [];
273
        if ($untranslated) {
274
            $reader = new YamlReader;
275
            $masterMessages = $reader->read($defaultLang, $defaultFile);
276
        }
277
278
        $i = 0;
279
        foreach ($translationFiles as $translationFile) {
280
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
281
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
282
                continue;
283
            }
284
            $reader = new YamlReader;
285
            $messages = $reader->read($lang, $translationFile);
286
287
            foreach ($messages as $entityKey => $v) {
288
                if ($untranslated) {
289
                    if (isset($masterMessages[$entityKey]) && $masterMessages[$entityKey] != $v) {
290
                        continue;
291
                    }
292
                }
293
294
                if (!isset($allMessages[$entityKey])) {
295
                    $allMessages[$entityKey] = $default;
296
                }
297
                // Plurals can be arrays and need to be converted
298
                if (is_array($v)) {
299
                    $v = json_encode($v);
300
                }
301
                $allMessages[$entityKey][$i] = $v;
302
            }
303
            $i++;
304
        }
305
        // don't sort this will be mess up git when merging later
306
        // ksort($allMessages);
307
        if ($this->debug) {
308
            Debug::show($allMessages);
309
        }
310
311
        // Write them to a file
312
        if ($excel && class_exists(ExcelImportExport::class)) {
313
            $ext = 'xlsx';
314
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
315
            if ($this->debug) {
316
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
317
                return;
318
            }
319
            if (is_file($destinationFilename)) {
320
                unlink($destinationFilename);
321
            }
322
            // First row contains headers
323
            $data = [$headers];
324
            // Add a row per lang
325
            foreach ($allMessages as $key => $translations) {
326
                array_unshift($translations, $key);
327
                $data[] = $translations;
328
            }
329
            ExcelImportExport::arrayToFile($data, $destinationFilename);
330
        } else {
331
            $ext = 'csv';
332
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
333
            if ($this->debug) {
334
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
335
                return;
336
            }
337
            if (is_file($destinationFilename)) {
338
                unlink($destinationFilename);
339
            }
340
            $fp = fopen($destinationFilename, 'w');
341
            if ($fp === false) {
342
                throw new Exception("Failed to open stream");
343
            }
344
            // UTF 8 fix
345
            fprintf($fp, "\xEF\xBB\xBF");
346
            fputcsv($fp, $headers);
347
            foreach ($allMessages as $key => $translations) {
348
                array_unshift($translations, $key);
349
                fputcsv($fp, $translations);
350
            }
351
            fclose($fp);
352
        }
353
354
        $this->message("Translations written to $destinationFilename");
355
    }
356
357
    public function isEnabled(): bool
358
    {
359
        return Director::isDev();
360
    }
361
}
362