Completed
Push — master ( 77b89e...301d76 )
by Iurii
01:19
created

Translator::getFileInfo()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 22
rs 9.2
c 0
b 0
f 0
cc 3
eloc 13
nc 4
nop 1
1
<?php
2
3
/**
4
 * @package Translator
5
 * @author Iurii Makukh <[email protected]>
6
 * @copyright Copyright (c) 2017, Iurii Makukh <[email protected]>
7
 * @license https://www.gnu.org/licenses/gpl-3.0.en.html GPL-3.0+
8
 */
9
10
namespace gplcart\modules\translator\models;
11
12
use Exception;
13
use DOMDocument;
14
use gplcart\core\Cache,
15
    gplcart\core\Config,
16
    gplcart\core\Hook,
17
    gplcart\core\Module;
18
use gplcart\core\helpers\Zip as ZipHelper;
19
use gplcart\core\models\Language as LanguageModel,
20
    gplcart\core\models\Translation as TranslationModel,
21
    gplcart\core\models\FileTransfer as FileTransferModel;
22
23
/**
24
 * Manages basic behaviors and data related to Translator module
25
 * @todo Validate HTML before import
26
 */
27
class Translator
28
{
29
30
    /**
31
     * Hook class instance
32
     * @var \gplcart\core\Hook $hook
33
     */
34
    protected $hook;
35
36
    /**
37
     * Config class instance
38
     * @var \gplcart\core\Config $config
39
     */
40
    protected $config;
41
42
    /**
43
     * Module class instance
44
     * @var \gplcart\core\Module $module
45
     */
46
    protected $module;
47
48
    /**
49
     * Cache class instance
50
     * @var \gplcart\core\Cache $cache
51
     */
52
    protected $cache;
53
54
    /**
55
     * File transfer model class instance
56
     * @var \gplcart\core\models\FileTransfer $file_transfer
57
     */
58
    protected $file_transfer;
59
60
    /**
61
     * Zip helper class instance
62
     * @var \gplcart\core\helpers\Zip $zip
63
     */
64
    protected $zip;
65
66
    /**
67
     * Translation UI model class instance
68
     * @var \gplcart\core\models\Translation $translation
69
     */
70
    protected $translation;
71
72
    /**
73
     * Language model class instance
74
     * @var \gplcart\core\models\Language $language
75
     */
76
    protected $language;
77
78
    /**
79
     * Translator constructor.
80
     * @param Hook $hook
81
     * @param Config $config
82
     * @param Cache $cache
83
     * @param Module $module
84
     * @param ZipHelper $zip
85
     * @param FileTransferModel $file_transfer
86
     * @param LanguageModel $language
87
     * @param TranslationModel $translation
88
     */
89
    public function __construct(Hook $hook, Config $config, Cache $cache, Module $module,
90
            ZipHelper $zip, FileTransferModel $file_transfer, LanguageModel $language,
91
            TranslationModel $translation)
92
    {
93
        $this->hook = $hook;
94
        $this->module = $module;
95
        $this->config = $config;
96
97
        $this->zip = $zip;
98
        $this->cache = $cache;
99
        $this->language = $language;
100
        $this->translation = $translation;
101
        $this->file_transfer = $file_transfer;
102
    }
103
104
    /**
105
     * Returns an array of information about the translation file
106
     * @param string $file
107
     * @return array
108
     */
109
    public function getFileInfo($file)
110
    {
111
        $lines = array();
112
113
        if (is_file($file)) {
114
            $lines = $this->translation->parseCsv($file);
115
        }
116
117
        $result = array(
118
            'progress' => 0,
119
            'translated' => 0,
120
            'total' => count($lines)
121
        );
122
123
        if (empty($result['total'])) {
124
            return $result;
125
        }
126
127
        $result['translated'] = $this->countTranslated($lines);
128
        $result['progress'] = round(($result['translated'] / $result['total']) * 100);
129
        return $result;
130
    }
131
132
    /**
133
     * Copy a translation file
134
     * @param string $source
135
     * @param string $destination
136
     * @return boolean
137
     */
138
    public function copy($source, $destination)
139
    {
140
        $result = null;
141
        $this->hook->attach('module.translator.copy.before', $source, $destination, $result, $this);
142
143
        if (isset($result)) {
144
            return $result;
145
        }
146
147
        $result = false;
148
        if ($this->prepareDirectory($destination)) {
149
            $result = copy($source, $destination);
150
        }
151
152
        $this->hook->attach('module.translator.copy.after', $source, $destination, $result, $this);
153
        return (bool) $result;
154
    }
155
156
    /**
157
     * Deletes a translation file
158
     * @param string $file
159
     * @param string $langcode
160
     * @return boolean
161
     */
162
    public function delete($file, $langcode)
163
    {
164
        $result = null;
165
        $this->hook->attach('module.translator.delete.before', $file, $langcode, $result, $this);
166
167
        if (isset($result)) {
168
            return $result;
169
        }
170
171
        if (!$this->canDelete($file, $langcode)) {
172
            return false;
173
        }
174
175
        $result = unlink($file);
176
        $this->hook->attach('module.translator.delete.after', $file, $langcode, $result, $this);
177
        return $result;
178
    }
179
180
    /**
181
     * Whether the translation file can be deleted
182
     * @param string $file
183
     * @param string $langcode
184
     * @return bool
185
     */
186
    public function canDelete($file, $langcode)
187
    {
188
        return $this->isTranslationFile($file, $langcode);
189
    }
190
191
    /**
192
     * Whether the file is a translation file
193
     * @param string $file
194
     * @param string $langcode
195
     * @return bool
196
     */
197
    public function isTranslationFile($file, $langcode)
198
    {
199
        return is_file($file)//
200
                && pathinfo($file, PATHINFO_EXTENSION) === 'csv'//
201
                && (strpos($file, $this->translation->getDirectory($langcode)) === 0//
202
                || $this->getModuleIdFromPath($file));
203
    }
204
205
    /**
206
     * Returns a module ID from the translation file path
207
     * @param string $file
208
     * @return string
209
     */
210
    public function getModuleIdFromPath($file)
211
    {
212
        $module_id = basename(dirname(dirname($file)));
213
        $module = $this->module->get($module_id);
214
215
        if (!empty($module) && strpos($file, $this->translation->getModuleDirectory($module_id)) === 0) {
216
            return $module_id;
217
        }
218
219
        return '';
220
    }
221
222
    /**
223
     * Returns an array of scanned and prepared translations
224
     * @param string|null $langcode
225
     * @return array
226
     */
227
    public function getImportList($langcode = null)
228
    {
229
        $key = "module.translator.import.$langcode";
230
        $list = $this->cache->get($key);
231
232
        if (empty($list)) {
233
            $file = $this->getImportFile();
234
            $scanned = $this->scanImportFile($file);
235
            $list = $this->buildImportList($scanned, $file, $langcode);
0 ignored issues
show
Bug introduced by
It seems like $file defined by $this->getImportFile() on line 233 can also be of type boolean; however, gplcart\modules\translat...ator::buildImportList() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
236
            $this->config->set('module_translator_saved', GC_TIME);
237
            $this->cache->set($key, $list);
238
        }
239
240
        $this->hook->attach('module.translator.import.list', $langcode, $list, $this);
241
        return $list;
242
    }
243
244
    /**
245
     * Delete both cache and ZIP file
246
     * @return boolean
247
     */
248
    public function clearImport()
249
    {
250
        $this->cache->clear('module.translator.import', array('pattern' => '*'));
251
        $file = $this->getImportFilePath();
252
        return is_file($file) && unlink($file);
253
    }
254
255
    /**
256
     * Copy translation from the source ZIP file
257
     * @param string $module_id
258
     * @param string $file
259
     * @param string $langcode
260
     * @return boolean
261
     */
262
    public function importContent($module_id, $file, $langcode)
263
    {
264
        $result = null;
265
        $this->hook->attach('module.translator.copy.before', $module_id, $file, $langcode, $result);
266
267
        if (isset($result)) {
268
            return $result;
269
        }
270
271
        $content = $this->readZip($module_id, $file, $langcode);
272
273
        if (empty($content)) {
274
            return false;
275
        }
276
277
        if ($module_id === 'core') {
278
            $module_id = '';
279
        }
280
281
        $destination = $this->translation->getFile($langcode, $module_id);
282
283
        $result = false;
284
        if ($this->prepareDirectory($destination)) {
285
            $result = file_put_contents($destination, $content) !== false;
286
        }
287
288
        $this->hook->attach('module.translator.copy.after', $module_id, $file, $langcode, $destination, $result);
289
        return $result;
290
    }
291
292
    /**
293
     * Ensure that directory exists and contains no the same file
294
     * @param string $file
295
     * @return boolean
296
     */
297
    protected function prepareDirectory($file)
298
    {
299
        $directory = dirname($file);
300
        if (!file_exists($directory) && !mkdir($directory, 0775, true)) {
301
            return false;
302
        }
303
304
        if (file_exists($file)) {
305
            unlink($file);
306
        }
307
308
        return true;
309
    }
310
311
    /**
312
     * Returns an array of scanned translations
313
     * @param string|bool $file
314
     * @return array
315
     */
316
    public function scanImportFile($file)
317
    {
318
        if (empty($file)) {
319
            return array();
320
        }
321
322
        try {
323
            $items = $this->zip->set($file)->getList();
324
        } catch (Exception $ex) {
325
            trigger_error($ex->getMessage());
326
            return array();
327
        }
328
329
        // Convert to nested array
330
        $nested = array();
331
        foreach ($items as $item) {
332
            $parents = explode('/', trim($item, '/'));
333
            gplcart_array_set($nested, $parents, $item);
334
        }
335
336
        return $nested;
337
    }
338
339
    /**
340
     * Build translation data
341
     * @param array $items
342
     * @param string $file
343
     * @param null|string $langcode
344
     * @return array
345
     */
346
    public function buildImportList(array $items, $file, $langcode)
347
    {
348
        $list = array();
349
        $version = gplcart_version(true);
350
351
        if (!empty($items["$version.x"])) {
352
            $modules = $this->module->getList();
353
            foreach ($items["$version.x"] as $module_id => $translations) {
354
                if ($module_id === 'core' || isset($modules[$module_id])) {
355
                    $list[$module_id] = $this->prepareImportTranslations($translations, $file, $langcode);
356
                }
357
            }
358
        }
359
360
        ksort($list);
361
362
        // Put core translations on the top
363
        if (isset($list['core'])) {
364
            $core = $list['core'];
365
            unset($list['core']);
366
            $list = array_merge(array('core' => $core), $list);
367
        }
368
369
        return $list;
370
    }
371
372
    /**
373
     * Prepare an array of translations
374
     * @param array $data
375
     * @param string $file
376
     * @param string $langcode
377
     * @return array
378
     */
379
    protected function prepareImportTranslations(array $data, $file, $langcode)
380
    {
381
        $languages = $this->language->getList();
382
383
        $prepared = array();
384
        foreach ($data as $filename => $path) {
385
386
            $pathinfo = pathinfo($filename);
387
            if (empty($pathinfo['extension']) || $pathinfo['extension'] !== 'csv') {
388
                continue;
389
            }
390
391
            list($lang, $version) = array_pad(explode('.', $pathinfo['filename'], 2), 2, '');
392
393
            if (!empty($langcode) && $langcode !== $lang) {
394
                continue;
395
            }
396
397
            if (empty($languages[$lang])) {
398
                continue;
399
            }
400
401
            $content = $this->translation->parseCsv("zip://$file#$path");
402
            $total = count($content);
403
            $translated = $this->countTranslated($content);
404
405
            $prepared[$lang][$filename] = array(
406
                'total' => $total,
407
                'file' => $filename,
408
                'version' => $version,
409
                'content' => $content,
410
                'translated' => $translated,
411
                'progress' => round(($translated / $total) * 100)
412
            );
413
414
            uasort($prepared[$lang], function ($a, $b) {
415
                return version_compare($a['version'], $b['version']);
416
            });
417
        }
418
419
        return $prepared;
420
    }
421
422
    /**
423
     * Read CSV from ZIP file
424
     * @param string $module_id
425
     * @param string $file
426
     * @param string $langcode
427
     * @return string
428
     */
429
    public function readZip($module_id, $file, $langcode)
430
    {
431
        $list = $this->getImportList();
432
433
        $content = '';
434
        if (!empty($list[$module_id][$langcode][$file]['content'])) {
435
            // Use php://temp stream?
436
            $data = stream_get_meta_data(tmpfile());
437
            foreach ($list[$module_id][$langcode][$file]['content'] as $line) {
438
                gplcart_file_csv($data['uri'], $line);
439
            }
440
441
            $content = file_get_contents($data['uri']);
442
            unlink($data['uri']);
443
        }
444
445
        return $content;
446
    }
447
448
    /**
449
     * Returns a total number of translated strings
450
     * @param array $lines
451
     * @return integer
452
     */
453
    protected function countTranslated(array $lines)
454
    {
455
        $count = 0;
456
        foreach ($lines as $line) {
457
            if (isset($line[1]) && $line[1] !== '') {
458
                $count ++;
459
            }
460
        }
461
462
        return $count;
463
    }
464
465
    /**
466
     * Downloads a remote ZIP file
467
     * @param string $destination
468
     * @return boolean
469
     */
470
    public function downloadImportFile($destination)
471
    {
472
        try {
473
            $result = $this->file_transfer->download($this->getImportDownloadUrl(), 'zip', $destination);
474
            if ($result !== true) {
475
                trigger_error($result);
476
                return false;
477
            }
478
        } catch (Exception $ex) {
479
            trigger_error($ex->getMessage());
480
            return false;
481
        }
482
483
        return true;
484
    }
485
486
    /**
487
     * Returns URL of source ZIP file
488
     * @return string
489
     */
490
    public function getImportDownloadUrl()
491
    {
492
        return 'https://crowdin.com/download/project/gplcart.zip';
493
    }
494
495
    /**
496
     * Returns the path of a downloaded ZIP file
497
     * @return bool|string
498
     */
499
    public function getImportFile()
500
    {
501
        $file = $this->getImportFilePath();
502
503
        if (is_file($file)) {
504
            return $file;
505
        }
506
507
        return $this->downloadImportFile($file) ? $file : false;
0 ignored issues
show
Bug introduced by
It seems like $file defined by $this->getImportFilePath() on line 501 can also be of type boolean; however, gplcart\modules\translat...r::downloadImportFile() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
508
    }
509
510
    /**
511
     * Returns the absolute path of downloaded ZIP file
512
     * @return string|bool
513
     */
514
    public function getImportFilePath()
515
    {
516
        return gplcart_file_private_temp('translator-import.zip');
517
    }
518
519
    /**
520
     * Detect invalid HTML
521
     * @param string $string
522
     * @return boolean
523
     */
524
    public function isInvalidHtml($string)
525
    {
526
        if (strpos($string, '>') === false && strpos($string, '<') === false) {
527
            return false;
528
        }
529
530
        libxml_use_internal_errors(true);
531
532
        $dom = new DOMDocument;
533
        $dom->loadHTML($string);
534
535
        // Strip wrapping html and body tags
536
        $mock = new DOMDocument;
537
        $body = $dom->getElementsByTagName('body')->item(0);
538
        foreach ($body->childNodes as $child) {
539
            $mock->appendChild($mock->importNode($child, true));
540
        }
541
542
        return trim($mock->saveHTML()) !== $string;
543
    }
544
545
}
546