Passed
Push — master ( 5e779f...172bf8 )
by Thomas
02:51
created

ExcelImportExport::checkImportForm()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
c 0
b 0
f 0
dl 0
loc 14
rs 10
cc 4
nc 4
nop 1
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Exception;
6
use SilverStripe\Forms\Form;
7
use SilverStripe\Core\ClassInfo;
8
use SilverStripe\ORM\DataObject;
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Core\Config\Config;
11
use SilverStripe\Control\HTTPResponse;
12
use PhpOffice\PhpSpreadsheet\IOFactory;
13
use SilverStripe\Dev\BulkLoader_Result;
14
use PhpOffice\PhpSpreadsheet\Reader\Csv;
15
use PhpOffice\PhpSpreadsheet\Shared\Date;
16
use PhpOffice\PhpSpreadsheet\Spreadsheet;
17
use SilverStripe\Core\Config\Configurable;
18
use PhpOffice\PhpSpreadsheet\Reader\IReader;
19
20
/**
21
 * Support class for the module
22
 *
23
 * @author Koala
24
 */
25
class ExcelImportExport
26
{
27
    use Configurable;
28
29
    /**
30
     * Setting this to false improve performance but may lead to skipped cells
31
     * @var bool
32
     */
33
    public static $iterate_only_existing_cells = false;
34
35
    /**
36
     * Useful if importing only one sheet or if computation fails
37
     * @var bool
38
     */
39
    public static $use_old_calculated_value = false;
40
41
    /**
42
     * Some excel sheets need extra processing
43
     * @var boolean
44
     */
45
    public static $process_headers = false;
46
47
    /**
48
     * @var string
49
     */
50
    public static $default_tmp_reader = 'Xlsx';
51
52
    /**
53
     * @var integer
54
     */
55
    public static $limit_exports = 1000;
56
57
    /**
58
     * Get all db fields for a given dataobject class
59
     *
60
     * @param string $class
61
     * @return array
62
     */
63
    public static function allFieldsForClass($class)
64
    {
65
        $dataClasses = ClassInfo::dataClassesFor($class);
66
        $fields      = array();
67
        $dataObjectSchema = DataObject::getSchema();
68
        foreach ($dataClasses as $dataClass) {
69
            $dataFields = $dataObjectSchema->databaseFields($dataClass);
70
            $fields = array_merge($fields, array_keys($dataFields));
71
        }
72
        return array_combine($fields, $fields);
73
    }
74
75
    /**
76
     * Get fields that should be exported by default for a class
77
     *
78
     * @param string $class
79
     * @return array
80
     */
81
    public static function exportFieldsForClass($class)
82
    {
83
        $singl = singleton($class);
84
        if ($singl->hasMethod('exportedFields')) {
85
            return $singl->exportedFields();
86
        }
87
        $exportedFields = Config::inst()->get($class, 'exported_fields');
88
89
        if (!$exportedFields) {
90
            $exportedFields = array_keys(self::allFieldsForClass($class));
91
        }
92
93
        $unexportedFields = Config::inst()->get($class, 'unexported_fields');
94
95
        if ($unexportedFields) {
96
            $exportedFields = array_diff($exportedFields, $unexportedFields);
97
        }
98
99
        $fields = [];
100
        foreach ($exportedFields as $key => $value) {
101
            if (is_int($key)) {
102
                $key = $value;
103
            }
104
            $fields[$key] = $value;
105
        }
106
107
        return $fields;
108
    }
109
110
    /**
111
     * Get fields that can be imported by default for a class
112
     *
113
     * @param string $class
114
     * @return array
115
     */
116
    public static function importFieldsForClass($class)
117
    {
118
        $singl = singleton($class);
119
        if ($singl->hasMethod('importedFields')) {
120
            return $singl->importedFields();
121
        }
122
        $importedFields = Config::inst()->get($class, 'imported_fields');
123
124
        if (!$importedFields) {
125
            $importedFields = array_keys(self::allFieldsForClass($class));
126
        }
127
128
        $unimportedFields = Config::inst()->get($class, 'unimported_Fields');
129
130
        if ($unimportedFields) {
131
            $importedFields = array_diff($importedFields, $unimportedFields);
132
        }
133
        return array_combine($importedFields, $importedFields);
134
    }
135
136
    /**
137
     * Output a sample file for a class
138
     *
139
     * A custom file can be provided with a custom sampleExcelFile method
140
     * either as a file path or as a Excel instance
141
     *
142
     * @param string $class
143
     * @return void
144
     */
145
    public static function sampleFileForClass($class)
146
    {
147
        $fileName = "sample-file-for-$class.xlsx";
148
        $spreadsheet = null;
149
150
        $sng = singleton($class);
151
        if ($sng->hasMethod('sampleExcelFile')) {
152
            $spreadsheet = $sng->sampleExcelFile();
153
154
            // We have a file, output directly
155
            if (is_string($spreadsheet) && is_file($spreadsheet)) {
156
                self::outputHeaders($fileName);
157
                readfile($spreadsheet);
158
                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...
159
            }
160
        }
161
        if (!$spreadsheet) {
162
            $spreadsheet = self::generateDefaultSampleFile($class);
163
        }
164
165
        $writer = self::getDefaultWriter($spreadsheet);
166
        self::outputHeaders($fileName);
167
        $writer->save('php://output');
168
        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...
169
    }
170
171
    /**
172
     * @param Controller $controller
173
     * @return bool
174
     */
175
    public static function checkImportForm($controller)
176
    {
177
        if (!$controller->showImportForm) {
178
            return false;
179
        }
180
        $modelClass = $controller->getModelClass();
181
        if (is_array($controller->showImportForm)) {
182
            /** @var array $valid */
183
            $valid = $controller->showImportForm;
184
            if (!in_array($modelClass, $valid)) {
185
                return false;
186
            }
187
        }
188
        return true;
189
    }
190
191
    /**
192
     * @param string $handler
193
     * @param Form $form
194
     * @param Controller $controller
195
     * @return HTTPResponse
196
     */
197
    public static function useCustomHandler($handler, Form $form, Controller $controller)
198
    {
199
        if (!$handler || !method_exists($handler, "load")) {
200
            $form->sessionMessage("Invalid handler: $handler", 'bad');
201
            return $controller->redirectBack();
202
        }
203
        $file = $_FILES['_CsvFile']['tmp_name'];
204
        $name = $_FILES['_CsvFile']['name'];
205
        $inst = new $handler();
206
207
        if (!empty($_POST['OnlyUpdateRecords']) && method_exists($handler, 'setOnlyUpdate')) {
208
            $inst->setOnlyUpdate(true);
209
        }
210
211
        /** @var BulkLoader_Result|string $results  */
212
        try {
213
            $results = $inst->load($file, $name);
214
        } catch (Exception $e) {
215
            $form->sessionMessage($e->getMessage(), 'bad');
216
            return $controller->redirectBack();
217
        }
218
219
        $message = '';
220
        if ($results instanceof BulkLoader_Result) {
221
            if ($results->CreatedCount()) {
222
                $message .= _t(
223
                    'ModelAdmin.IMPORTEDRECORDS',
224
                    "Imported {count} records.",
225
                    ['count' => $results->CreatedCount()]
226
                );
227
            }
228
            if ($results->UpdatedCount()) {
229
                $message .= _t(
230
                    'ModelAdmin.UPDATEDRECORDS',
231
                    "Updated {count} records.",
232
                    ['count' => $results->UpdatedCount()]
233
                );
234
            }
235
            if ($results->DeletedCount()) {
236
                $message .= _t(
237
                    'ModelAdmin.DELETEDRECORDS',
238
                    "Deleted {count} records.",
239
                    ['count' => $results->DeletedCount()]
240
                );
241
            }
242
            if (!$results->CreatedCount() && !$results->UpdatedCount()) {
243
                $message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
244
            }
245
        } else {
246
            // Or we have a simple result
247
            $message = $results;
248
        }
249
250
        $form->sessionMessage($message, 'good');
251
        return $controller->redirectBack();
252
    }
253
254
    public static function getDefaultWriter($spreadsheet)
255
    {
256
        return IOFactory::createWriter($spreadsheet, self::config()->default_writer);
257
    }
258
259
    /**
260
     * Output excel headers
261
     *
262
     * @param string $fileName
263
     * @return void
264
     */
265
    public static function outputHeaders($fileName)
266
    {
267
        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
268
        switch ($ext) {
269
            case 'xlsx':
270
                header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
271
                break;
272
            default:
273
                header('Content-type: application/vnd.ms-excel');
274
                break;
275
        }
276
277
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
278
        header('Cache-Control: max-age=0');
279
        ob_clean();
280
    }
281
282
    /**
283
     * Generate a default import file with all field name
284
     *
285
     * @param string $class
286
     * @return Spreadsheet
287
     */
288
    public static function generateDefaultSampleFile($class)
289
    {
290
        $spreadsheet = new Spreadsheet();
291
        $spreadsheet->getProperties()
292
            ->setCreator('SilverStripe')
293
            ->setTitle("Sample file for $class");
294
        $sheet = $spreadsheet->getActiveSheet();
295
296
        $row = 1;
297
        $col = 1;
298
        $allFields = ExcelImportExport::importFieldsForClass($class);
299
        foreach ($allFields as $header) {
300
            $sheet->setCellValueByColumnAndRow($col, $row, $header);
301
            $col++;
302
        }
303
        return $spreadsheet;
304
    }
305
306
    /**
307
     * Show valid extensions helper (for uploaders)
308
     *
309
     * @return string
310
     */
311
    public static function getValidExtensionsText()
312
    {
313
        return _t(
314
            'ExcelImportExport.VALIDEXTENSIONS',
315
            "Allowed extensions: {extensions}",
316
            array('extensions' => implode(', ', self::getValidExtensions()))
317
        );
318
    }
319
320
    /**
321
     * Extracted from PHPSpreadhseet
322
     *
323
     * @param string $ext
324
     * @return string
325
     */
326
    public static function getReaderForExtension($ext)
327
    {
328
        switch (strtolower($ext)) {
329
            case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet
330
            case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded)
331
            case 'xltx': // Excel (OfficeOpenXML) Template
332
            case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded)
333
                return 'Xlsx';
334
            case 'xls': // Excel (BIFF) Spreadsheet
335
            case 'xlt': // Excel (BIFF) Template
336
                return 'Xls';
337
            case 'ods': // Open/Libre Offic Calc
338
            case 'ots': // Open/Libre Offic Calc Template
339
                return 'Ods';
340
            case 'slk':
341
                return 'Slk';
342
            case 'xml': // Excel 2003 SpreadSheetML
343
                return 'Xml';
344
            case 'gnumeric':
345
                return 'Gnumeric';
346
            case 'htm':
347
            case 'html':
348
                return 'Html';
349
            case 'csv':
350
                return 'Csv';
351
            case 'tmp': // Useful when importing uploaded files
352
                return self::$default_tmp_reader;
353
            default:
354
                throw new Exception("Unsupported file type : $ext");
355
        }
356
    }
357
358
    /**
359
     * Get valid extensions
360
     *
361
     * @return array
362
     */
363
    public static function getValidExtensions()
364
    {
365
        $v = self::config()->allowed_extensions;
366
        if (!$v || !is_array($v)) {
367
            return [];
368
        }
369
        return $v;
370
    }
371
372
    /**
373
     * Save content of an array to a file
374
     *
375
     * @param array $data
376
     * @param string $filepath
377
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
378
     * @return void
379
     */
380
    public static function arrayToFile($data, $filepath)
381
    {
382
        $spreadsheet = new Spreadsheet;
383
        $spreadsheet->setActiveSheetIndex(0);
384
        $spreadsheet->getActiveSheet()->fromArray($data);
385
386
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
387
388
        // Writer is the same as read : Csv, Xlsx...
389
        $writerType = 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

389
        $writerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
390
        $writer = IOFactory::createWriter($spreadsheet, $writerType);
391
        $writer->save($filepath);
392
    }
393
394
    /**
395
     * Fast saving to csv
396
     *
397
     * @param array $data
398
     * @param string $filepath
399
     * @param string $delimiter
400
     * @param string $enclosure
401
     * @param string $escapeChar
402
     */
403
    public static function arrayToCsv($data, $filepath, $delimiter = ',', $enclosure = '"', $escapeChar = '\\')
404
    {
405
        if (is_file($filepath)) {
406
            unlink($filepath);
407
        }
408
        $fp = fopen($filepath, 'w');
409
        // UTF 8 fix
410
        fprintf($fp, "\xEF\xBB\xBF");
411
        foreach ($data as $row) {
412
            fputcsv($fp, $row, $delimiter, $enclosure, $escapeChar);
413
        }
414
        return fclose($fp);
415
    }
416
417
418
    /**
419
     * @param IReader $reader
420
     * @return Csv
421
     */
422
    protected static function getCsvReader(IReader $reader)
423
    {
424
        return $reader;
425
    }
426
427
    /**
428
     * Convert a file to an array
429
     *
430
     * @param string $filepath
431
     * @param string $delimiter (csv only)
432
     * @param string $enclosure (csv only)
433
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
434
     * @return array
435
     */
436
    public static function fileToArray($filepath, $delimiter = ';', $enclosure = '', $ext = null)
437
    {
438
        if ($ext === null) {
439
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
440
        }
441
        $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

441
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
442
        $reader = IOFactory::createReader($readerType);
443
        if ($readerType == 'Csv') {
444
            // @link https://phpspreadsheet.readthedocs.io/en/latest/topics/reading-and-writing-to-file/#setting-csv-options_1
445
            $reader = self::getCsvReader($reader);
446
            $reader->setDelimiter($delimiter);
447
            $reader->setEnclosure($enclosure);
448
        } else {
449
            // Does not apply to CSV
450
            $reader->setReadDataOnly(true);
451
        }
452
        $data = array();
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
453
        if ($reader->canRead($filepath)) {
454
            $excel = $reader->load($filepath);
455
            $data = $excel->getActiveSheet()->toArray(null, true, false, false);
456
        } else {
457
            throw new Exception("Cannot read $filepath");
458
        }
459
        return $data;
460
    }
461
462
    /**
463
     * Convert an excel file to an array
464
     *
465
     * @param string $filepath
466
     * @param string $sheetname Load a specific worksheet by name
467
     * @param true $onlyExisting Avoid reading empty columns
468
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
469
     * @return array
470
     */
471
    public static function excelToArray($filepath, $sheetname = null, $onlyExisting = true, $ext = null)
472
    {
473
        if ($ext === null) {
474
            $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...
475
        }
476
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
477
        $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

477
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
478
        $reader = IOFactory::createReader($readerType);
479
        $reader->setReadDataOnly(true);
480
        if ($sheetname) {
481
            $reader->setLoadSheetsOnly($sheetname);
482
        }
483
        $data = array();
484
        if ($reader->canRead($filepath)) {
485
            $excel = $reader->load($filepath);
486
            if ($onlyExisting) {
0 ignored issues
show
introduced by
The condition $onlyExisting is always true.
Loading history...
487
                $data = [];
488
                foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
489
                    $cellIterator = $row->getCellIterator();
490
                    if (self::$iterate_only_existing_cells) {
491
                        $cellIterator->setIterateOnlyExistingCells(true);
492
                    }
493
                    $cells = [];
494
                    foreach ($cellIterator as $cell) {
495
                        if (self::$use_old_calculated_value) {
496
                            $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

496
                            /** @scrutinizer ignore-call */ 
497
                            $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...
497
                        } else {
498
                            $cells[] = $cell->getFormattedValue();
499
                        }
500
                    }
501
                    $data[] = $cells;
502
                }
503
            } else {
504
                $data = $excel->getActiveSheet()->toArray(null, true, false, false);
505
            }
506
        } else {
507
            throw new Exception("Cannot read $filepath");
508
        }
509
        return $data;
510
    }
511
512
    /**
513
     * @link https://stackoverflow.com/questions/44304795/how-to-retrieve-date-from-table-cell-using-phpspreadsheet#44304796
514
     * @param int $v
515
     * @return string
516
     */
517
    public static function convertExcelDate($v)
518
    {
519
        if (!is_numeric($v)) {
0 ignored issues
show
introduced by
The condition is_numeric($v) is always true.
Loading history...
520
            return '';
521
        }
522
        return date('Y-m-d', Date::excelToTimestamp($v));
523
    }
524
525
    /**
526
     * Convert an excel file to an associative array
527
     *
528
     * Suppose the first line are the headers of the file
529
     * Headers are trimmed in case you have crappy whitespace in your files
530
     *
531
     * @param string $filepath
532
     * @param string $sheetname Load a specific worksheet by name
533
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
534
     * @return array
535
     */
536
    public static function excelToAssocArray($filepath, $sheetname = null, $ext = null)
537
    {
538
        if ($ext === null) {
539
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
540
        }
541
        $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

541
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
542
        $reader = IOFactory::createReader($readerType);
543
        $reader->setReadDataOnly(true);
544
        if ($sheetname) {
545
            $reader->setLoadSheetsOnly($sheetname);
546
        }
547
        $data = array();
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
548
        if ($reader->canRead($filepath)) {
549
            $excel = $reader->load($filepath);
550
            $data = [];
551
            $headers = [];
552
            $headersCount = 0;
553
            foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
554
                $cellIterator = $row->getCellIterator();
555
                if (self::$iterate_only_existing_cells) {
556
                    $cellIterator->setIterateOnlyExistingCells(true);
557
                }
558
                $cells = [];
559
                foreach ($cellIterator as $cell) {
560
                    if (self::$use_old_calculated_value) {
561
                        $cells[] = $cell->getOldCalculatedValue();
562
                    } else {
563
                        $cells[] = $cell->getFormattedValue();
564
                    }
565
                }
566
                if (empty($headers)) {
567
                    $headers = $cells;
568
                    // Some crappy excel file may need this
569
                    if (self::$process_headers) {
570
                        $headers = array_map(function ($v) {
571
                            // Numeric headers are most of the time dates
572
                            if (is_numeric($v)) {
573
                                $v =  self::convertExcelDate($v);
574
                            }
575
                            // trim does not always work great and headers can contain utf8 stuff
576
                            return is_string($v) ? preg_replace('/(^\s+)|(\s+$)/us', '', $v) : $v;
577
                        }, $headers);
578
                    }
579
                    $headersCount = count($headers);
580
                } else {
581
                    $diff = count($cells) - $headersCount;
582
                    if ($diff != 0) {
583
                        if ($diff > 0) {
584
                            // we have too many cells
585
                            $cells = array_slice($cells, 0, $headersCount);
586
                        } else {
587
                            // we are missing some cells
588
                            for ($i = 0; $i < abs($diff); $i++) {
589
                                $cells[] = null;
590
                            }
591
                        }
592
                    }
593
                    $data[] = array_combine($headers, $cells);
594
                }
595
            }
596
        } else {
597
            throw new Exception("Cannot read $filepath");
598
        }
599
        return $data;
600
    }
601
}
602