Issues (17)

src/TranslationsImportExportTask.php (5 issues)

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
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
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
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
                // Only write non empty values
145
                if ($value !== null) {
146
                    $entities[$key] = $value;
147
                }
148
            }
149
            if (!$this->debug) {
150
                $writer->write(
151
                    $entities,
152
                    $lang,
153
                    dirname($fullLangPath)
154
                );
155
                $this->message("Imported $new messages in $lang");
156
            } else {
157
                Debug::show($lang);
158
                Debug::dump($entities);
159
            }
160
        }
161
    }
162
163
    /**
164
     * @param string $file
165
     * @return array<int,array<mixed>>
166
     */
167
    public function importFromExcel($file)
168
    {
169
        $spreadsheet = IOFactory::load($file);
170
        $worksheet = $spreadsheet->getActiveSheet();
171
        $rows = [];
172
        foreach ($worksheet->getRowIterator() as $row) {
173
            $cellIterator = $row->getCellIterator();
174
            // $cellIterator->setIterateOnlyExistingCells(true);
175
            $cells = [];
176
            foreach ($cellIterator as $cell) {
177
                $cells[] = $cell->getValue();
178
            }
179
            if (empty($cells)) {
180
                break;
181
            }
182
            $rows[] = $cells;
183
        }
184
        return $this->getDataFromRows($rows);
185
    }
186
187
    /**
188
     * @param array<array<mixed>> $rows
189
     * @return array<int,array<mixed>>
190
     */
191
    protected function getDataFromRows($rows)
192
    {
193
        $header = array_shift($rows);
194
        $firstKey = $header[0];
195
        if ($firstKey == 'key') {
196
            $header[0] = 'key'; // Fix some weird stuff
197
        }
198
        $count = count($header);
199
        $data = [];
200
        foreach ($rows as $row) {
201
            while (count($row) < $count) {
202
                $row[] = '';
203
            }
204
            $row = array_slice($row, 0, $count);
205
            $row = $this->normalizeRow($row);
206
            $data[] = array_combine($header, $row);
207
        }
208
        return $data;
209
    }
210
211
    /**
212
     * @return array<int,array<mixed>>
213
     */
214
    protected function importFromCsv(string $file)
215
    {
216
        $arr = file($file);
217
        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...
218
            return [];
219
        }
220
        $rows = array_map('str_getcsv', $arr);
221
        return $this->getDataFromRows($rows);
222
    }
223
224
    /**
225
     * @param array<int,mixed> $row
226
     * @return array<int,mixed>
227
     */
228
    protected function normalizeRow($row)
229
    {
230
        foreach ($row as $idx => $value) {
231
            if ($idx == 0 || $value === null) {
232
                continue;
233
            }
234
            if (strpos($value, '{"') === 0) {
235
                $row[$idx] = json_decode($value, true);
236
            }
237
        }
238
        return $row;
239
    }
240
241
    /**
242
     * @param string $module
243
     * @param boolean $excel
244
     * @param array<string> $onlyLang
245
     * @param bool $untranslated
246
     * @param bool $translate
247
     * @return void
248
     */
249
    public function exportTranslations($module, $excel = true, $onlyLang = [], $untranslated = false, $translate = false)
250
    {
251
        $fullLangPath = $this->getLangPath($module);
252
253
        $translationFiles = glob($fullLangPath . '/*.yml');
254
        if ($translationFiles === false) {
255
            $this->message("No yml");
256
            return;
257
        }
258
259
        // Collect messages in all lang
260
        $allMessages = [];
261
        $headers = ['key'];
262
        $default = [];
263
        $defaultFile = null;
264
        $defaultLang = substr(i18n::get_locale(), 0, 2);
265
        foreach ($translationFiles as $translationFile) {
266
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
267
            if ($lang == $defaultLang) {
268
                $defaultFile = $translationFile;
269
            }
270
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
271
                continue;
272
            }
273
            $headers[] = $lang;
274
            $default[] = '';
275
        }
276
277
278
        $masterMessages = [];
279
        if ($untranslated) {
280
            $reader = new YamlReader;
281
            $masterMessages = $reader->read($defaultLang, $defaultFile);
282
        }
283
284
        $i = 0;
285
        foreach ($translationFiles as $translationFile) {
286
            $lang = pathinfo($translationFile, PATHINFO_FILENAME);
287
            if (!empty($onlyLang) && !in_array($lang, $onlyLang)) {
288
                continue;
289
            }
290
            $reader = new YamlReader;
291
            $messages = $reader->read($lang, $translationFile);
292
293
            $translator = new OllamaTowerInstruct();
294
295
            foreach ($messages as $entityKey => $v) {
296
                if ($untranslated) {
297
                    if (isset($masterMessages[$entityKey]) && $masterMessages[$entityKey] != $v) {
298
                        continue;
299
                    }
300
                }
301
302
                if (!isset($allMessages[$entityKey])) {
303
                    $allMessages[$entityKey] = $default;
304
                }
305
                // Plurals can be arrays and need to be converted
306
                if (is_array($v)) {
307
                    $v = json_encode($v);
308
                }
309
310
                // Attempt auto translation / 200
311
                if ($translate && count($allMessages) < 200) {
312
                    $v = $translator->translate($v, $lang, $defaultLang);
0 ignored issues
show
It seems like $lang can also be of type array; however, parameter $to of LeKoala\Multilingual\Oll...erInstruct::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

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