Passed
Push — master ( 6e1f19...dca8c4 )
by Thomas
12:39
created

src/ExcelImportExport.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Exception;
6
use Generator;
7
use LeKoala\SpreadCompat\SpreadCompat;
0 ignored issues
show
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...
8
use SilverStripe\Forms\Form;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\Control\Controller;
12
use SilverStripe\Core\Config\Config;
13
use SilverStripe\Control\HTTPResponse;
14
use PhpOffice\PhpSpreadsheet\IOFactory;
15
use SilverStripe\Dev\BulkLoader_Result;
16
use PhpOffice\PhpSpreadsheet\Reader\Csv;
17
use PhpOffice\PhpSpreadsheet\Shared\Date;
18
use PhpOffice\PhpSpreadsheet\Spreadsheet;
19
use SilverStripe\Core\Config\Configurable;
20
use PhpOffice\PhpSpreadsheet\Reader\IReader;
21
use PhpOffice\PhpSpreadsheet\Writer\IWriter;
22
23
/**
24
 * Support class for the module
25
 *
26
 * @author Koala
27
 */
28
class ExcelImportExport
29
{
30
    use Configurable;
31
32
    /**
33
     * Setting this to false improve performance but may lead to skipped cells
34
     * @var bool
35
     */
36
    public static $iterate_only_existing_cells = false;
37
38
    /**
39
     * Useful if importing only one sheet or if computation fails
40
     * @var bool
41
     */
42
    public static $use_old_calculated_value = false;
43
44
    /**
45
     * Some excel sheets need extra processing
46
     * @var boolean
47
     */
48
    public static $process_headers = false;
49
50
    /**
51
     * @var string
52
     */
53
    public static $default_tmp_reader = 'Xlsx';
54
55
    /**
56
     * @var integer
57
     */
58
    public static $limit_exports = 1000;
59
60
    /**
61
     * Get all db fields for a given dataobject class
62
     *
63
     * @param string $class
64
     * @return array
65
     */
66
    public static function allFieldsForClass($class)
67
    {
68
        $dataClasses = ClassInfo::dataClassesFor($class);
69
        $fields      = [];
70
        $dataObjectSchema = DataObject::getSchema();
71
        foreach ($dataClasses as $dataClass) {
72
            $dataFields = $dataObjectSchema->databaseFields($dataClass);
73
            $fields = array_merge($fields, array_keys($dataFields));
74
        }
75
        return array_combine($fields, $fields);
76
    }
77
78
    /**
79
     * Get fields that should be exported by default for a class
80
     *
81
     * @param string $class
82
     * @return array
83
     */
84
    public static function exportFieldsForClass($class)
85
    {
86
        $singl = singleton($class);
87
        if ($singl->hasMethod('exportedFields')) {
88
            return $singl->exportedFields();
89
        }
90
        $exportedFields = Config::inst()->get($class, 'exported_fields');
91
92
        if (!$exportedFields) {
93
            $exportedFields = array_keys(self::allFieldsForClass($class));
94
        }
95
96
        $unexportedFields = Config::inst()->get($class, 'unexported_fields');
97
98
        if ($unexportedFields) {
99
            $exportedFields = array_diff($exportedFields, $unexportedFields);
100
        }
101
102
        $fields = [];
103
        foreach ($exportedFields as $key => $value) {
104
            if (is_int($key)) {
105
                $key = $value;
106
            }
107
            $fields[$key] = $value;
108
        }
109
110
        return $fields;
111
    }
112
113
    /**
114
     * Get fields that can be imported by default for a class
115
     *
116
     * @param string $class
117
     * @return array
118
     */
119
    public static function importFieldsForClass($class)
120
    {
121
        $singl = singleton($class);
122
        if ($singl->hasMethod('importedFields')) {
123
            return $singl->importedFields();
124
        }
125
        $importedFields = Config::inst()->get($class, 'imported_fields');
126
127
        if (!$importedFields) {
128
            $importedFields = array_keys(self::allFieldsForClass($class));
129
        }
130
131
        $unimportedFields = Config::inst()->get($class, 'unimported_Fields');
132
133
        if ($unimportedFields) {
134
            $importedFields = array_diff($importedFields, $unimportedFields);
135
        }
136
        return array_combine($importedFields, $importedFields);
137
    }
138
139
    /**
140
     * Output a sample file for a class
141
     *
142
     * A custom file can be provided with a custom sampleExcelFile method
143
     * either as a file path or as a Excel instance
144
     *
145
     * @param string $class
146
     * @return void
147
     */
148
    public static function sampleFileForClass($class)
149
    {
150
        $fileName = "sample-file-for-$class.xlsx";
151
        $spreadsheet = null;
152
153
        $sng = singleton($class);
154
        if ($sng->hasMethod('sampleExcelFile')) {
155
            $spreadsheet = $sng->sampleExcelFile();
156
        }
157
        if (!$spreadsheet) {
158
            $spreadsheet = self::generateDefaultSampleFile($class);
159
        }
160
        // We have a file, output directly
161
        if (is_string($spreadsheet) && is_file($spreadsheet)) {
162
            self::outputHeaders($fileName);
163
            readfile($spreadsheet);
164
            exit();
165
        }
166
        // PHPSpreadsheet is required for this
167
        $writer = self::getDefaultWriter($spreadsheet);
168
        self::outputHeaders($fileName);
169
        $writer->save('php://output');
170
        exit();
171
    }
172
173
    /**
174
     * @param Controller $controller
175
     * @return bool
176
     */
177
    public static function checkImportForm($controller)
178
    {
179
        if (!$controller->showImportForm) {
180
            return false;
181
        }
182
        $modelClass = $controller->getModelClass();
183
        if (is_array($controller->showImportForm)) {
184
            /** @var array $valid */
185
            $valid = $controller->showImportForm;
186
            if (!in_array($modelClass, $valid)) {
187
                return false;
188
            }
189
        }
190
        return true;
191
    }
192
193
    /**
194
     * @param string $handler
195
     * @param Form $form
196
     * @param Controller $controller
197
     * @return HTTPResponse
198
     */
199
    public static function useCustomHandler($handler, Form $form, Controller $controller)
200
    {
201
        if (!$handler || !method_exists($handler, "load")) {
202
            $form->sessionMessage("Invalid handler: $handler", 'bad');
203
            return $controller->redirectBack();
204
        }
205
        $file = $_FILES['_CsvFile']['tmp_name'];
206
        $name = $_FILES['_CsvFile']['name'];
207
        $inst = new $handler();
208
209
        if (!empty($_POST['OnlyUpdateRecords']) && method_exists($handler, 'setOnlyUpdate')) {
210
            $inst->setOnlyUpdate(true);
211
        }
212
213
        /** @var BulkLoader_Result|string $results  */
214
        try {
215
            $results = $inst->load($file, $name);
216
        } catch (Exception $e) {
217
            $form->sessionMessage($e->getMessage(), 'bad');
218
            return $controller->redirectBack();
219
        }
220
221
        $message = '';
222
        if ($results instanceof BulkLoader_Result) {
223
            if ($results->CreatedCount()) {
224
                $message .= _t(
225
                    'ModelAdmin.IMPORTEDRECORDS',
226
                    "Imported {count} records.",
227
                    ['count' => $results->CreatedCount()]
228
                );
229
            }
230
            if ($results->UpdatedCount()) {
231
                $message .= _t(
232
                    'ModelAdmin.UPDATEDRECORDS',
233
                    "Updated {count} records.",
234
                    ['count' => $results->UpdatedCount()]
235
                );
236
            }
237
            if ($results->DeletedCount()) {
238
                $message .= _t(
239
                    'ModelAdmin.DELETEDRECORDS',
240
                    "Deleted {count} records.",
241
                    ['count' => $results->DeletedCount()]
242
                );
243
            }
244
            if (!$results->CreatedCount() && !$results->UpdatedCount()) {
245
                $message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
246
            }
247
        } else {
248
            // Or we have a simple result
249
            $message = $results;
250
        }
251
252
        $form->sessionMessage($message, 'good');
253
        return $controller->redirectBack();
254
    }
255
256
    public static function getDefaultWriter(Spreadsheet $spreadsheet): IWriter
257
    {
258
        return IOFactory::createWriter($spreadsheet, self::config()->default_writer);
259
    }
260
261
    /**
262
     * Output excel headers
263
     *
264
     * @param string $fileName
265
     * @return void
266
     */
267
    public static function outputHeaders($fileName)
268
    {
269
        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
270
        switch ($ext) {
271
            case 'xlsx':
272
                header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
273
                break;
274
            default:
275
                header('Content-type: application/vnd.ms-excel');
276
                break;
277
        }
278
279
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
280
        header('Cache-Control: max-age=0');
281
        ob_clean();
282
    }
283
284
    /**
285
     * Generate a default import file with all field name
286
     *
287
     * @param string $class
288
     * @return string
289
     */
290
    public static function generateDefaultSampleFile($class)
291
    {
292
        $opts = [
293
            'creator' => "SilverStripe"
294
        ];
295
        $allFields = ExcelImportExport::importFieldsForClass($class);
296
        $tmpname = SpreadCompat::getTempFilename();
297
        SpreadCompat::write([
298
            $allFields
299
        ], $tmpname, ...$opts);
300
        return $tmpname;
301
    }
302
303
    /**
304
     * Show valid extensions helper (for uploaders)
305
     *
306
     * @return string
307
     */
308
    public static function getValidExtensionsText()
309
    {
310
        return _t(
311
            'ExcelImportExport.VALIDEXTENSIONS',
312
            "Allowed extensions: {extensions}",
313
            array('extensions' => implode(', ', self::getValidExtensions()))
314
        );
315
    }
316
317
    /**
318
     * Extracted from PHPSpreadsheet
319
     *
320
     * @param string $ext
321
     * @return string
322
     */
323
    public static function getReaderForExtension($ext)
324
    {
325
        switch (strtolower($ext)) {
326
            case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet
327
            case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded)
328
            case 'xltx': // Excel (OfficeOpenXML) Template
329
            case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded)
330
                return 'Xlsx';
331
            case 'xls': // Excel (BIFF) Spreadsheet
332
            case 'xlt': // Excel (BIFF) Template
333
                return 'Xls';
334
            case 'ods': // Open/Libre Offic Calc
335
            case 'ots': // Open/Libre Offic Calc Template
336
                return 'Ods';
337
            case 'slk':
338
                return 'Slk';
339
            case 'xml': // Excel 2003 SpreadSheetML
340
                return 'Xml';
341
            case 'gnumeric':
342
                return 'Gnumeric';
343
            case 'htm':
344
            case 'html':
345
                return 'Html';
346
            case 'csv':
347
                return 'Csv';
348
            case 'tmp': // Useful when importing uploaded files
349
                return self::$default_tmp_reader;
350
            default:
351
                throw new Exception("Unsupported file type : $ext");
352
        }
353
    }
354
355
    /**
356
     * Get valid extensions
357
     *
358
     * @return array
359
     */
360
    public static function getValidExtensions()
361
    {
362
        $v = self::config()->allowed_extensions;
363
        if (!$v || !is_array($v)) {
364
            return [];
365
        }
366
        return $v;
367
    }
368
369
    /**
370
     * Save content of an array to a file
371
     *
372
     * @param iterable $data
373
     * @param string $filepath
374
     * @return void
375
     */
376
    public static function arrayToFile($data, $filepath)
377
    {
378
        SpreadCompat::write($data, $filepath);
379
    }
380
381
    /**
382
     * Fast saving to csv
383
     *
384
     * @param array $data
385
     * @param string $filepath
386
     * @param string $delimiter
387
     * @param string $enclosure
388
     * @param string $escapeChar
389
     */
390
    public static function arrayToCsv($data, $filepath, $delimiter = ',', $enclosure = '"', $escapeChar = '\\')
391
    {
392
        if (is_file($filepath)) {
393
            unlink($filepath);
394
        }
395
        $fp = fopen($filepath, 'w');
396
        // UTF 8 fix
397
        fprintf($fp, "\xEF\xBB\xBF");
398
        foreach ($data as $row) {
399
            fputcsv($fp, $row, $delimiter, $enclosure, $escapeChar);
400
        }
401
        return fclose($fp);
402
    }
403
404
405
    /**
406
     * @param IReader $reader
407
     * @return Csv
408
     */
409
    protected static function getCsvReader(IReader $reader)
410
    {
411
        return $reader;
412
    }
413
414
    public static function excelColumnRange(string $lower = 'A', string $upper = 'ZZ'): Generator
415
    {
416
        ++$upper;
417
        for ($i = $lower; $i !== $upper; ++$i) {
418
            yield $i;
419
        }
420
    }
421
422
    /**
423
     * String from column index.
424
     *
425
     * @param int $index Column index (1 = A)
426
     * @param $fallback
427
     * @return string
428
     */
429
    public static function getLetter($index)
430
    {
431
        foreach (self::excelColumnRange() as $letter) {
432
            $index--;
433
            if ($index <= 0) {
434
                return $letter;
435
            }
436
        }
437
    }
438
439
440
    /**
441
     * Convert a file to an array
442
     *
443
     * @param string $filepath
444
     * @param string $delimiter (csv only)
445
     * @param string $enclosure (csv only)
446
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
447
     * @return array
448
     */
449
    public static function fileToArray($filepath, $delimiter = ';', $enclosure = '', $ext = null)
450
    {
451
        if ($ext === null) {
452
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
453
        }
454
        $readerType = self::getReaderForExtension($ext);
455
        $reader = IOFactory::createReader($readerType);
456
        if ($readerType == 'Csv') {
457
            // @link https://phpspreadsheet.readthedocs.io/en/latest/topics/reading-and-writing-to-file/#setting-csv-options_1
458
            $reader = self::getCsvReader($reader);
459
            $reader->setDelimiter($delimiter);
460
            $reader->setEnclosure($enclosure);
461
        } else {
462
            // Does not apply to CSV
463
            $reader->setReadDataOnly(true);
464
        }
465
        $data = [];
466
        if ($reader->canRead($filepath)) {
467
            $excel = $reader->load($filepath);
468
            $data = $excel->getActiveSheet()->toArray(null, true, false, false);
469
        } else {
470
            throw new Exception("Cannot read $filepath");
471
        }
472
        return $data;
473
    }
474
475
    /**
476
     * Convert an excel file to an array
477
     *
478
     * @param string $filepath
479
     * @param string $sheetname Load a specific worksheet by name
480
     * @param true $onlyExisting Avoid reading empty columns
481
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
482
     * @return array
483
     */
484
    public static function excelToArray($filepath, $sheetname = null, $onlyExisting = true, $ext = null)
485
    {
486
        if ($ext === null) {
487
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
488
        }
489
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
490
        $readerType = self::getReaderForExtension($ext);
491
        $reader = IOFactory::createReader($readerType);
492
        $reader->setReadDataOnly(true);
493
        if ($sheetname) {
494
            $reader->setLoadSheetsOnly($sheetname);
495
        }
496
        $data = [];
497
        if ($reader->canRead($filepath)) {
498
            $excel = $reader->load($filepath);
499
            if ($onlyExisting) {
500
                $data = [];
501
                foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
502
                    $cellIterator = $row->getCellIterator();
503
                    if (self::$iterate_only_existing_cells) {
504
                        $cellIterator->setIterateOnlyExistingCells(true);
505
                    }
506
                    $cells = [];
507
                    foreach ($cellIterator as $cell) {
508
                        if (self::$use_old_calculated_value) {
509
                            $cells[] = $cell->getOldCalculatedValue();
510
                        } else {
511
                            $cells[] = $cell->getFormattedValue();
512
                        }
513
                    }
514
                    $data[] = $cells;
515
                }
516
            } else {
517
                $data = $excel->getActiveSheet()->toArray(null, true, false, false);
518
            }
519
        } else {
520
            throw new Exception("Cannot read $filepath");
521
        }
522
        return $data;
523
    }
524
525
    /**
526
     * @link https://stackoverflow.com/questions/44304795/how-to-retrieve-date-from-table-cell-using-phpspreadsheet#44304796
527
     * @param int $v
528
     * @return string
529
     */
530
    public static function convertExcelDate($v)
531
    {
532
        if (!is_numeric($v)) {
533
            return '';
534
        }
535
        return date('Y-m-d', Date::excelToTimestamp($v));
536
    }
537
538
    /**
539
     * Convert an excel file to an associative array
540
     *
541
     * Suppose the first line are the headers of the file
542
     * Headers are trimmed in case you have crappy whitespace in your files
543
     *
544
     * @param string $filepath
545
     * @param string $sheetname Load a specific worksheet by name
546
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
547
     * @return array
548
     */
549
    public static function excelToAssocArray($filepath, $sheetname = null, $ext = null)
550
    {
551
        if ($ext === null) {
552
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
553
        }
554
        $readerType = self::getReaderForExtension($ext);
555
        $reader = IOFactory::createReader($readerType);
556
        $reader->setReadDataOnly(true);
557
        if ($sheetname) {
558
            $reader->setLoadSheetsOnly($sheetname);
559
        }
560
        $data = [];
561
        if ($reader->canRead($filepath)) {
562
            $excel = $reader->load($filepath);
563
            $data = [];
564
            $headers = [];
565
            $headersCount = 0;
566
            foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
567
                $cellIterator = $row->getCellIterator();
568
                if (self::$iterate_only_existing_cells) {
569
                    $cellIterator->setIterateOnlyExistingCells(true);
570
                }
571
                $cells = [];
572
                foreach ($cellIterator as $cell) {
573
                    if (self::$use_old_calculated_value) {
574
                        $cells[] = $cell->getOldCalculatedValue();
575
                    } else {
576
                        $cells[] = $cell->getFormattedValue();
577
                    }
578
                }
579
                if (empty($headers)) {
580
                    $headers = $cells;
581
                    // Some crappy excel file may need this
582
                    if (self::$process_headers) {
583
                        $headers = array_map(function ($v) {
584
                            // Numeric headers are most of the time dates
585
                            if (is_numeric($v)) {
586
                                $v =  self::convertExcelDate($v);
587
                            }
588
                            // trim does not always work great and headers can contain utf8 stuff
589
                            return is_string($v) ? preg_replace('/(^\s+)|(\s+$)/us', '', $v) : $v;
590
                        }, $headers);
591
                    }
592
                    $headersCount = count($headers);
593
                } else {
594
                    $diff = count($cells) - $headersCount;
595
                    if ($diff != 0) {
596
                        if ($diff > 0) {
597
                            // we have too many cells
598
                            $cells = array_slice($cells, 0, $headersCount);
599
                        } else {
600
                            // we are missing some cells
601
                            for ($i = 0; $i < abs($diff); $i++) {
602
                                $cells[] = null;
603
                            }
604
                        }
605
                    }
606
                    $data[] = array_combine($headers, $cells);
607
                }
608
            }
609
        } else {
610
            throw new Exception("Cannot read $filepath");
611
        }
612
        return $data;
613
    }
614
}
615