Passed
Push — master ( e30135...40e5bb )
by Thomas
17:26 queued 15:03
created

TranslationsImportExportTask   B

Complexity

Total Complexity 52

Size/Duplication

Total Lines 311
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 171
dl 0
loc 311
rs 7.44
c 0
b 0
f 0
wmc 52

9 Methods

Rating   Name   Duplication   Size   Complexity  
A isEnabled() 0 3 1
A run() 0 33 5
A importFromExcel() 0 18 4
A normalizeRow() 0 11 4
A getDataFromRows() 0 18 4
B importTranslations() 0 53 10
F exportTranslations() 0 93 21
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
16
/**
17
 * Helps exporting and importing labels from a csv or xls
18
 */
19
class TranslationsImportExportTask extends BuildTask
20
{
21
    use BuildTaskTools;
0 ignored issues
show
Bug introduced by
The type LeKoala\Multilingual\BuildTaskTools 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...
22
23
    /**
24
     * @var string
25
     */
26
    private static $segment = 'TranslationsImportExportTask';
0 ignored issues
show
introduced by
The private property $segment is not used, and could be removed.
Loading history...
27
    /**
28
     * @var string
29
     */
30
    protected $title = "Translations import export task";
31
    /**
32
     * @var string
33
     */
34
    protected $description = "Easily import and export translations";
35
36
    /**
37
     * @var bool
38
     */
39
    public $debug;
40
41
    /**
42
     * @param \SilverStripe\Control\HTTPRequest $request
43
     * @return void
44
     */
45
    public function run($request)
46
    {
47
        $this->request = $request;
0 ignored issues
show
Bug Best Practice introduced by
The property request does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
48
        $modules = $this->getModules();
49
        $this->addOption("import", "Import translations", false);
50
        $this->addOption("export", "Export translations", false);
51
        $this->addOption("export_only", "Export only these lang (comma separated)");
52
        $this->addOption("debug", "Show debug output and do not write files", false);
53
        $this->addOption("excel", "Use excel if possible (require excel-import-export module)", true);
54
        $this->addOption("module", "Module", null, $modules);
55
        $options = $this->askOptions();
56
57
        $module = $options['module'];
58
        $import = $options['import'];
59
        $excel = $options['excel'];
60
        $export = $options['export'];
61
        $export_only = $options['export_only'];
62
63
        $this->debug = $options['debug'];
64
65
        if ($module) {
66
            if ($import) {
67
                $this->importTranslations($module);
68
            }
69
            if ($export) {
70
                $onlyLang = [];
71
                if ($export_only) {
72
                    $onlyLang = explode(",", $export_only);
73
                }
74
                $this->exportTranslations($module, $excel, $onlyLang);
75
            }
76
        } else {
77
            $this->message("Please select a module");
78
        }
79
    }
80
81
    protected function getLangPath(string $module): string
82
    {
83
        $langPath = ModuleResourceLoader::resourcePath($module . ':lang');
84
        return Director::baseFolder() . '/' . str_replace([':', '\\'], '/', $langPath);
85
    }
86
87
    /**
88
     * @param string $module
89
     * @return void
90
     */
91
    protected function importTranslations($module)
92
    {
93
        $fullLangPath = $this->getLangPath($module);
94
        $modulePath = dirname($fullLangPath);
95
96
        $excelFile = $modulePath . "/lang.xlsx";
97
        $csvFile = $modulePath . "/lang.csv";
98
99
        $data = null;
100
        if (is_file($excelFile)) {
101
            $this->message("Importing $excelFile");
102
            $data = $this->importFromExcel($excelFile);
103
        } elseif (is_file($csvFile)) {
104
            $this->message("Importing $csvFile");
105
            $data = $this->importFromCsv($csvFile);
106
        }
107
108
        if (!$data) {
109
            $this->message("No data to import");
110
            return;
111
        }
112
113
        if ($this->debug) {
114
            Debug::dump($data);
115
        }
116
117
        $header = array_keys($data[0]);
118
        $count = count($header);
119
        $writer = Injector::inst()->create(Writer::class);
120
        $langs = array_slice($header, 1, $count);
121
        foreach ($langs as $lang) {
122
            $entities = [];
123
            foreach ($data as $row) {
124
                $key = trim($row['key']);
125
                if (!$key) {
126
                    continue;
127
                }
128
                $value = $row[$lang];
129
                if (is_string($value)) {
130
                    $value = trim($value);
131
                }
132
                $entities[$key] = $value;
133
            }
134
            if (!$this->debug) {
135
                $writer->write(
136
                    $entities,
137
                    $lang,
138
                    dirname($fullLangPath)
139
                );
140
                $this->message("Imported " . count($entities) . " messages in $lang");
141
            } else {
142
                Debug::show($lang);
143
                Debug::dump($entities);
144
            }
145
        }
146
    }
147
148
    /**
149
     * @param string $file
150
     * @return array<int,array<mixed>>
151
     */
152
    public function importFromExcel($file)
153
    {
154
        $spreadsheet = IOFactory::load($file);
155
        $worksheet = $spreadsheet->getActiveSheet();
156
        $rows = [];
157
        foreach ($worksheet->getRowIterator() as $row) {
158
            $cellIterator = $row->getCellIterator();
159
            // $cellIterator->setIterateOnlyExistingCells(true);
160
            $cells = [];
161
            foreach ($cellIterator as $cell) {
162
                $cells[] = $cell->getValue();
163
            }
164
            if (empty($cells)) {
165
                break;
166
            }
167
            $rows[] = $cells;
168
        }
169
        return $this->getDataFromRows($rows);
170
    }
171
172
    /**
173
     * @param array<array<mixed>> $rows
174
     * @return array<int,array<mixed>>
175
     */
176
    protected function getDataFromRows($rows)
177
    {
178
        $header = array_shift($rows);
179
        $firstKey = $header[0];
180
        if ($firstKey == 'key') {
181
            $header[0] = 'key'; // Fix some weird stuff
182
        }
183
        $count = count($header);
184
        $data = [];
185
        foreach ($rows as $row) {
186
            while (count($row) < $count) {
187
                $row[] = '';
188
            }
189
            $row = array_slice($row, 0, $count);
190
            $row = $this->normalizeRow($row);
191
            $data[] = array_combine($header, $row);
192
        }
193
        return $data;
194
    }
195
196
    /**
197
     * @return array<int,array<mixed>>
198
     */
199
    protected function importFromCsv(string $file)
200
    {
201
        $arr = file($file);
202
        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...
203
            return [];
204
        }
205
        $rows = array_map('str_getcsv', $arr);
206
        return $this->getDataFromRows($rows);
207
    }
208
209
    /**
210
     * @param array<int,mixed> $row
211
     * @return array<int,mixed>
212
     */
213
    protected function normalizeRow($row)
214
    {
215
        foreach ($row as $idx => $value) {
216
            if ($idx == 0) {
217
                continue;
218
            }
219
            if (strpos($value, '{"') === 0) {
220
                $row[$idx] = json_decode($value, true);
221
            }
222
        }
223
        return $row;
224
    }
225
226
    /**
227
     * @param string $module
228
     * @param boolean $excel
229
     * @param array<string> $onlyLang
230
     * @return void
231
     */
232
    public function exportTranslations($module, $excel = true, $onlyLang = [])
233
    {
234
        $fullLangPath = $this->getLangPath($module);
235
236
        $translationFiles = glob($fullLangPath . '/*.yml');
237
        if ($translationFiles === false) {
238
            $this->message("No yml");
239
            return;
240
        }
241
242
        // Collect messages in all lang
243
        $allMessages = [];
244
        $headers = ['key'];
245
        $default = [];
246
        foreach ($translationFiles as $translationFile) {
247
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
248
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
249
                continue;
250
            }
251
            $headers[] = $lang;
252
            $default[] = '';
253
        }
254
255
        $i = 0;
256
        foreach ($translationFiles as $translationFile) {
257
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
258
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
259
                continue;
260
            }
261
            $reader = new YamlReader;
262
            $messages = $reader->read($lang, $translationFile);
263
264
            foreach ($messages as $entityKey => $v) {
265
                if (!isset($allMessages[$entityKey])) {
266
                    $allMessages[$entityKey] = $default;
267
                }
268
                // Plurals can be arrays and need to be converted
269
                if (is_array($v)) {
270
                    $v = json_encode($v);
271
                }
272
                $allMessages[$entityKey][$i] = $v;
273
            }
274
            $i++;
275
        }
276
        ksort($allMessages);
277
        if ($this->debug) {
278
            Debug::show($allMessages);
279
        }
280
281
        // Write them to a file
282
        if ($excel && class_exists(ExcelImportExport::class)) {
283
            $ext = 'xlsx';
284
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
285
            if ($this->debug) {
286
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
287
                return;
288
            }
289
            if (is_file($destinationFilename)) {
290
                unlink($destinationFilename);
291
            }
292
            // First row contains headers
293
            $data = [$headers];
294
            // Add a row per lang
295
            foreach ($allMessages as $key => $translations) {
296
                array_unshift($translations, $key);
297
                $data[] = $translations;
298
            }
299
            ExcelImportExport::arrayToFile($data, $destinationFilename);
300
        } else {
301
            $ext = 'csv';
302
            $destinationFilename = str_replace('/lang', '/lang.' . $ext, $fullLangPath);
303
            if ($this->debug) {
304
                Debug::show("Debug mode enabled : no output will be sent to $destinationFilename");
305
                return;
306
            }
307
            if (is_file($destinationFilename)) {
308
                unlink($destinationFilename);
309
            }
310
            $fp = fopen($destinationFilename, 'w');
311
            if ($fp === false) {
312
                throw new Exception("Failed to open stream");
313
            }
314
            // UTF 8 fix
315
            fprintf($fp, "\xEF\xBB\xBF");
316
            fputcsv($fp, $headers);
317
            foreach ($allMessages as $key => $translations) {
318
                array_unshift($translations, $key);
319
                fputcsv($fp, $translations);
320
            }
321
            fclose($fp);
322
        }
323
324
        $this->message("Translations written to $destinationFilename");
325
    }
326
327
    public function isEnabled(): bool
328
    {
329
        return Director::isDev();
330
    }
331
}
332