Passed
Push — master ( 985aae...d90b4e )
by Thomas
03:00
created

ExcelImportExport::getDefaultExtension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Exception;
6
use Generator;
7
use LeKoala\SpreadCompat\Common\Options;
8
use LeKoala\SpreadCompat\SpreadCompat;
0 ignored issues
show
Bug introduced by
The type LeKoala\SpreadCompat\SpreadCompat 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...
9
use SilverStripe\Forms\Form;
10
use SilverStripe\Core\ClassInfo;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\Control\Controller;
13
use SilverStripe\Core\Config\Config;
14
use SilverStripe\Control\HTTPResponse;
15
use PhpOffice\PhpSpreadsheet\IOFactory;
16
use SilverStripe\Dev\BulkLoader_Result;
17
use PhpOffice\PhpSpreadsheet\Reader\Csv;
18
use PhpOffice\PhpSpreadsheet\Shared\Date;
19
use PhpOffice\PhpSpreadsheet\Spreadsheet;
20
use SilverStripe\Core\Config\Configurable;
21
use PhpOffice\PhpSpreadsheet\Reader\IReader;
22
use PhpOffice\PhpSpreadsheet\Writer\IWriter;
23
use SilverStripe\Assets\FileNameFilter;
24
25
/**
26
 * Support class for the module
27
 *
28
 * @author Koala
29
 */
30
class ExcelImportExport
31
{
32
    use Configurable;
33
34
    /**
35
     * Setting this to false improve performance but may lead to skipped cells
36
     * @var bool
37
     */
38
    public static $iterate_only_existing_cells = false;
39
40
    /**
41
     * Useful if importing only one sheet or if computation fails
42
     * @var bool
43
     */
44
    public static $use_old_calculated_value = false;
45
46
    /**
47
     * Some excel sheets need extra processing
48
     * @var boolean
49
     */
50
    public static $process_headers = false;
51
52
    /**
53
     * @var string
54
     */
55
    public static $default_tmp_reader = 'Xlsx';
56
57
    /**
58
     * @var integer
59
     */
60
    public static $limit_exports = 1000;
61
62
    /**
63
     * Get all db fields for a given dataobject class
64
     *
65
     * @param string $class
66
     * @return array
67
     */
68
    public static function allFieldsForClass($class)
69
    {
70
        $dataClasses = ClassInfo::dataClassesFor($class);
71
        $fields      = [];
72
        $dataObjectSchema = DataObject::getSchema();
73
        foreach ($dataClasses as $dataClass) {
74
            $dataFields = $dataObjectSchema->databaseFields($dataClass);
75
            $fields = array_merge($fields, array_keys($dataFields));
76
        }
77
        return array_combine($fields, $fields);
78
    }
79
80
    /**
81
     * Get fields that should be exported by default for a class
82
     *
83
     * @param string $class
84
     * @return array
85
     */
86
    public static function exportFieldsForClass($class)
87
    {
88
        $singl = singleton($class);
89
        if ($singl->hasMethod('exportedFields')) {
90
            return $singl->exportedFields();
91
        }
92
        $exportedFields = Config::inst()->get($class, 'exported_fields');
93
94
        if (!$exportedFields) {
95
            $exportedFields = array_keys(self::allFieldsForClass($class));
96
        }
97
98
        $unexportedFields = Config::inst()->get($class, 'unexported_fields');
99
100
        if ($unexportedFields) {
101
            $exportedFields = array_diff($exportedFields, $unexportedFields);
102
        }
103
104
        $fields = [];
105
        foreach ($exportedFields as $key => $value) {
106
            if (is_int($key)) {
107
                $key = $value;
108
            }
109
            $fields[$key] = $value;
110
        }
111
112
        return $fields;
113
    }
114
115
    /**
116
     * Get fields that can be imported by default for a class
117
     *
118
     * @param string $class
119
     * @return array
120
     */
121
    public static function importFieldsForClass($class)
122
    {
123
        $singl = singleton($class);
124
        if ($singl->hasMethod('importedFields')) {
125
            return $singl->importedFields();
126
        }
127
        $importedFields = Config::inst()->get($class, 'imported_fields');
128
129
        if (!$importedFields) {
130
            $importedFields = array_keys(self::allFieldsForClass($class));
131
        }
132
133
        $unimportedFields = Config::inst()->get($class, 'unimported_Fields');
134
135
        if ($unimportedFields) {
136
            $importedFields = array_diff($importedFields, $unimportedFields);
137
        }
138
        return array_combine($importedFields, $importedFields);
139
    }
140
141
    /**
142
     * Output a sample file for a class
143
     *
144
     * A custom file can be provided with a custom sampleExcelFile method
145
     * either as a file path or as a Excel instance
146
     *
147
     * @param string $class
148
     * @return void
149
     */
150
    public static function sampleFileForClass($class)
151
    {
152
        $ext = self::getDefaultExtension();
153
        $filter = new FileNameFilter();
154
        $fileName = $filter->filter("sample-file-for-$class.$ext");
155
        $spreadsheet = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $spreadsheet is dead and can be removed.
Loading history...
156
157
        $sng = singleton($class);
158
159
        // Deprecated
160
        if ($sng->hasMethod('sampleExcelFile')) {
161
            $spreadsheet = $sng->sampleExcelFile();
162
            // PHPSpreadsheet is required for this
163
            $writer = self::getDefaultWriter($spreadsheet);
164
            self::outputHeaders($fileName);
165
            $writer->save('php://output');
166
            exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
167
        }
168
169
        if ($sng->hasMethod('sampleImportData')) {
170
            $data = $sng->sampleImportData();
171
        } else {
172
            // Simply output the default headers
173
            $data = [ExcelImportExport::importFieldsForClass($class)];
174
        }
175
176
        if (!is_iterable($data)) {
177
            throw new Exception("`sampleImportData` must return an iterable");
178
        }
179
180
        $options = new Options();
181
        $options->creator = "SilverStripe";
182
        SpreadCompat::output($data, $fileName, $options);
183
        exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
184
    }
185
186
    /**
187
     * @param Controller $controller
188
     * @return bool
189
     */
190
    public static function checkImportForm($controller)
191
    {
192
        if (!$controller->showImportForm) {
193
            return false;
194
        }
195
        $modelClass = $controller->getModelClass();
196
        if (is_array($controller->showImportForm)) {
197
            /** @var array $valid */
198
            $valid = $controller->showImportForm;
199
            if (!in_array($modelClass, $valid)) {
200
                return false;
201
            }
202
        }
203
        return true;
204
    }
205
206
    /**
207
     * @param string $handler
208
     * @param Form $form
209
     * @param Controller $controller
210
     * @return HTTPResponse
211
     */
212
    public static function useCustomHandler($handler, Form $form, Controller $controller)
213
    {
214
        if (!$handler || !method_exists($handler, "load")) {
215
            $form->sessionMessage("Invalid handler: $handler", 'bad');
216
            return $controller->redirectBack();
217
        }
218
        $file = $_FILES['_CsvFile']['tmp_name'];
219
        $name = $_FILES['_CsvFile']['name'];
220
        $inst = new $handler();
221
222
        if (!empty($_POST['OnlyUpdateRecords']) && method_exists($handler, 'setOnlyUpdate')) {
223
            $inst->setOnlyUpdate(true);
224
        }
225
226
        /** @var BulkLoader_Result|string $results  */
227
        try {
228
            $results = $inst->load($file, $name);
229
        } catch (Exception $e) {
230
            $form->sessionMessage($e->getMessage(), 'bad');
231
            return $controller->redirectBack();
232
        }
233
234
        $message = '';
235
        if ($results instanceof BulkLoader_Result) {
236
            if ($results->CreatedCount()) {
237
                $message .= _t(
238
                    'ModelAdmin.IMPORTEDRECORDS',
239
                    "Imported {count} records.",
240
                    ['count' => $results->CreatedCount()]
241
                );
242
            }
243
            if ($results->UpdatedCount()) {
244
                $message .= _t(
245
                    'ModelAdmin.UPDATEDRECORDS',
246
                    "Updated {count} records.",
247
                    ['count' => $results->UpdatedCount()]
248
                );
249
            }
250
            if ($results->DeletedCount()) {
251
                $message .= _t(
252
                    'ModelAdmin.DELETEDRECORDS',
253
                    "Deleted {count} records.",
254
                    ['count' => $results->DeletedCount()]
255
                );
256
            }
257
            if (!$results->CreatedCount() && !$results->UpdatedCount()) {
258
                $message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
259
            }
260
        } else {
261
            // Or we have a simple result
262
            $message = $results;
263
        }
264
265
        $form->sessionMessage($message, 'good');
266
        return $controller->redirectBack();
267
    }
268
269
    public static function getDefaultExtension()
270
    {
271
        return self::config()->default_extension ?? 'xlsx';
272
    }
273
274
    /**
275
     * Get default writer for PHPSpreadsheet if installed
276
     *
277
     * @param Spreadsheet $spreadsheet
278
     * @return IWriter
279
     */
280
    public static function getDefaultWriter(Spreadsheet $spreadsheet): IWriter
281
    {
282
        if (!self::isPhpSpreadsheetAvailable()) {
283
            throw new Exception("PHPSpreadsheet is not installed");
284
        }
285
        $writer = ucfirst(self::getDefaultExtension());
286
        return IOFactory::createWriter($spreadsheet, $writer);
287
    }
288
289
    /**
290
     * Output excel headers
291
     *
292
     * @param string $fileName
293
     * @return void
294
     */
295
    public static function outputHeaders($fileName)
296
    {
297
        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
298
        switch ($ext) {
299
            case 'csv':
300
                header('Content-Type: text/csv');
301
                break;
302
            case 'xlsx':
303
                header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
304
                break;
305
            default:
306
                header('Content-type: application/vnd.ms-excel');
307
                break;
308
        }
309
310
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
311
        header('Cache-Control: max-age=0');
312
        ob_clean();
313
    }
314
315
    /**
316
     * Generate a default import file with all field name
317
     * @deprecated
318
     * @param string $class
319
     * @return string
320
     */
321
    public static function generateDefaultSampleFile($class)
322
    {
323
        $opts = [
324
            'creator' => "SilverStripe"
325
        ];
326
        $allFields = ExcelImportExport::importFieldsForClass($class);
327
        $tmpname = SpreadCompat::getTempFilename();
328
        SpreadCompat::write([
329
            $allFields
330
        ], $tmpname, ...$opts);
331
        return $tmpname;
332
    }
333
334
    /**
335
     * Show valid extensions helper (for uploaders)
336
     *
337
     * @return string
338
     */
339
    public static function getValidExtensionsText()
340
    {
341
        return _t(
342
            'ExcelImportExport.VALIDEXTENSIONS',
343
            "Allowed extensions: {extensions}",
344
            array('extensions' => implode(', ', self::getValidExtensions()))
345
        );
346
    }
347
348
    /**
349
     * Extracted from PHPSpreadsheet
350
     *
351
     * @param string $ext
352
     * @return string
353
     */
354
    public static function getReaderForExtension($ext)
355
    {
356
        switch (strtolower($ext)) {
357
            case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet
358
            case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded)
359
            case 'xltx': // Excel (OfficeOpenXML) Template
360
            case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded)
361
                return 'Xlsx';
362
            case 'xls': // Excel (BIFF) Spreadsheet
363
            case 'xlt': // Excel (BIFF) Template
364
                return 'Xls';
365
            case 'ods': // Open/Libre Offic Calc
366
            case 'ots': // Open/Libre Offic Calc Template
367
                return 'Ods';
368
            case 'slk':
369
                return 'Slk';
370
            case 'xml': // Excel 2003 SpreadSheetML
371
                return 'Xml';
372
            case 'gnumeric':
373
                return 'Gnumeric';
374
            case 'htm':
375
            case 'html':
376
                return 'Html';
377
            case 'csv':
378
                return 'Csv';
379
            case 'tmp': // Useful when importing uploaded files
380
                return self::$default_tmp_reader;
381
            default:
382
                throw new Exception("Unsupported file type : $ext");
383
        }
384
    }
385
386
    public static function isPhpSpreadsheetAvailable()
387
    {
388
        return class_exists(\PhpOffice\PhpSpreadsheet\Spreadsheet::class);
389
    }
390
391
    /**
392
     * If you exported separated files, you can merge them in one big file
393
     * Requires PHPSpreadsheet
394
     * @param array $files
395
     * @return Spreadsheet
396
     */
397
    public static function mergeExcelFiles($files)
398
    {
399
        if (!self::isPhpSpreadsheetAvailable()) {
400
            throw new Exception("PHPSpreadsheet is not installed");
401
        }
402
        $merged = new Spreadsheet;
403
        $merged->removeSheetByIndex(0);
404
        foreach ($files as $filename) {
405
            $remoteExcel = IOFactory::load($filename);
406
            $merged->addExternalSheet($remoteExcel->getActiveSheet());
407
        }
408
        return $merged;
409
    }
410
411
    /**
412
     * Get valid extensions
413
     *
414
     * @return array
415
     */
416
    public static function getValidExtensions()
417
    {
418
        $v = self::config()->allowed_extensions;
419
        if (!$v || !is_array($v)) {
420
            return [];
421
        }
422
        return $v;
423
    }
424
425
    /**
426
     * Save content of an array to a file
427
     *
428
     * @param iterable $data
429
     * @param string $filepath
430
     * @return void
431
     */
432
    public static function arrayToFile($data, $filepath)
433
    {
434
        SpreadCompat::write($data, $filepath);
435
    }
436
437
    /**
438
     * Fast saving to csv
439
     *
440
     * @param array $data
441
     * @param string $filepath
442
     * @param string $delimiter
443
     * @param string $enclosure
444
     * @param string $escapeChar
445
     */
446
    public static function arrayToCsv($data, $filepath, $delimiter = ',', $enclosure = '"', $escapeChar = '\\')
447
    {
448
        if (is_file($filepath)) {
449
            unlink($filepath);
450
        }
451
        $options = new Options();
452
        $options->separator = $delimiter;
453
        $options->enclosure = $enclosure;
454
        $options->escape = $escapeChar;
455
        SpreadCompat::write($data, $filepath, $options);
456
    }
457
458
    public static function excelColumnRange(string $lower = 'A', string $upper = 'ZZ'): Generator
459
    {
460
        ++$upper;
461
        for ($i = $lower; $i !== $upper; ++$i) {
462
            yield $i;
463
        }
464
    }
465
466
    /**
467
     * String from column index.
468
     *
469
     * @param int $index Column index (1 = A)
470
     * @param $fallback
471
     * @return string
472
     */
473
    public static function getLetter($index)
474
    {
475
        foreach (self::excelColumnRange() as $letter) {
476
            $index--;
477
            if ($index <= 0) {
478
                return $letter;
479
            }
480
        }
481
    }
482
483
    /**
484
     * Convert a file to an array
485
     *
486
     * @param string $filepath
487
     * @param string $delimiter (csv only)
488
     * @param string $enclosure (csv only)
489
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
490
     * @return array
491
     */
492
    public static function fileToArray($filepath, $delimiter = ';', $enclosure = '', $ext = null)
493
    {
494
        if ($ext === null) {
495
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
496
        }
497
498
        $data = iterator_to_array(
499
            SpreadCompat::read(
500
                $filepath,
501
                extension: $ext,
502
                separator: $delimiter,
503
                enclosure: $enclosure
504
            )
505
        );
506
        return $data;
507
    }
508
509
    /**
510
     * Convert an excel file to an array
511
     *
512
     * @param string $filepath
513
     * @param string $sheetname Load a specific worksheet by name
514
     * @param true $onlyExisting Avoid reading empty columns
515
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
516
     * @return array
517
     */
518
    public static function excelToArray($filepath, $sheetname = null, $onlyExisting = true, $ext = null)
519
    {
520
        if (!self::isPhpSpreadsheetAvailable()) {
521
            throw new Exception("PHPSpreadsheet is not installed");
522
        }
523
        if ($ext === null) {
524
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
0 ignored issues
show
Unused Code introduced by
The assignment to $ext is dead and can be removed.
Loading history...
525
        }
526
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
527
        $readerType = self::getReaderForExtension($ext);
0 ignored issues
show
Bug introduced by
It seems like $ext can also be of type array; however, parameter $ext of LeKoala\ExcelImportExpor...getReaderForExtension() 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

527
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
528
        $reader = IOFactory::createReader($readerType);
529
        $reader->setReadDataOnly(true);
530
        if ($sheetname) {
531
            $reader->setLoadSheetsOnly($sheetname);
532
        }
533
        $data = [];
534
        if ($reader->canRead($filepath)) {
535
            $excel = $reader->load($filepath);
536
            if ($onlyExisting) {
0 ignored issues
show
introduced by
The condition $onlyExisting is always true.
Loading history...
537
                $data = [];
538
                foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
539
                    $cellIterator = $row->getCellIterator();
540
                    if (self::$iterate_only_existing_cells) {
541
                        $cellIterator->setIterateOnlyExistingCells(true);
542
                    }
543
                    $cells = [];
544
                    foreach ($cellIterator as $cell) {
545
                        if (self::$use_old_calculated_value) {
546
                            $cells[] = $cell->getOldCalculatedValue();
0 ignored issues
show
Bug introduced by
The method getOldCalculatedValue() does not exist on null. ( Ignorable by Annotation )

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

546
                            /** @scrutinizer ignore-call */ 
547
                            $cells[] = $cell->getOldCalculatedValue();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
547
                        } else {
548
                            $cells[] = $cell->getFormattedValue();
549
                        }
550
                    }
551
                    $data[] = $cells;
552
                }
553
            } else {
554
                $data = $excel->getActiveSheet()->toArray(null, true, false, false);
555
            }
556
        } else {
557
            throw new Exception("Cannot read $filepath");
558
        }
559
        return $data;
560
    }
561
562
    /**
563
     * @link https://stackoverflow.com/questions/44304795/how-to-retrieve-date-from-table-cell-using-phpspreadsheet#44304796
564
     * @param int $v
565
     * @return string
566
     */
567
    public static function convertExcelDate($v)
568
    {
569
        if (!is_numeric($v)) {
0 ignored issues
show
introduced by
The condition is_numeric($v) is always true.
Loading history...
570
            return '';
571
        }
572
        if (!self::isPhpSpreadsheetAvailable()) {
573
            throw new Exception("PHPSpreadsheet is not installed");
574
        }
575
        return date('Y-m-d', Date::excelToTimestamp($v));
576
    }
577
578
    /**
579
     * Convert an excel file to an associative array
580
     *
581
     * Suppose the first line are the headers of the file
582
     * Headers are trimmed in case you have crappy whitespace in your files
583
     *
584
     * @param string $filepath
585
     * @param string $sheetname Load a specific worksheet by name
586
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
587
     * @return array
588
     */
589
    public static function excelToAssocArray($filepath, $sheetname = null, $ext = null)
590
    {
591
        if (!self::isPhpSpreadsheetAvailable()) {
592
            throw new Exception("PHPSpreadsheet is not installed");
593
        }
594
        if ($ext === null) {
595
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
596
        }
597
        $readerType = self::getReaderForExtension($ext);
0 ignored issues
show
Bug introduced by
It seems like $ext can also be of type array; however, parameter $ext of LeKoala\ExcelImportExpor...getReaderForExtension() 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

597
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
598
        $reader = IOFactory::createReader($readerType);
599
        $reader->setReadDataOnly(true);
600
        if ($sheetname) {
601
            $reader->setLoadSheetsOnly($sheetname);
602
        }
603
        $data = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
604
        if ($reader->canRead($filepath)) {
605
            $excel = $reader->load($filepath);
606
            $data = [];
607
            $headers = [];
608
            $headersCount = 0;
609
            foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
610
                $cellIterator = $row->getCellIterator();
611
                if (self::$iterate_only_existing_cells) {
612
                    $cellIterator->setIterateOnlyExistingCells(true);
613
                }
614
                $cells = [];
615
                foreach ($cellIterator as $cell) {
616
                    if (self::$use_old_calculated_value) {
617
                        $cells[] = $cell->getOldCalculatedValue();
618
                    } else {
619
                        $cells[] = $cell->getFormattedValue();
620
                    }
621
                }
622
                if (empty($headers)) {
623
                    $headers = $cells;
624
                    // Some crappy excel file may need this
625
                    if (self::$process_headers) {
626
                        $headers = array_map(function ($v) {
627
                            // Numeric headers are most of the time dates
628
                            if (is_numeric($v)) {
629
                                $v =  self::convertExcelDate($v);
630
                            }
631
                            // trim does not always work great and headers can contain utf8 stuff
632
                            return is_string($v) ? preg_replace('/(^\s+)|(\s+$)/us', '', $v) : $v;
633
                        }, $headers);
634
                    }
635
                    $headersCount = count($headers);
636
                } else {
637
                    $diff = count($cells) - $headersCount;
638
                    if ($diff != 0) {
639
                        if ($diff > 0) {
640
                            // we have too many cells
641
                            $cells = array_slice($cells, 0, $headersCount);
642
                        } else {
643
                            // we are missing some cells
644
                            for ($i = 0; $i < abs($diff); $i++) {
645
                                $cells[] = null;
646
                            }
647
                        }
648
                    }
649
                    $data[] = array_combine($headers, $cells);
650
                }
651
            }
652
        } else {
653
            throw new Exception("Cannot read $filepath");
654
        }
655
        return $data;
656
    }
657
}
658