Passed
Push — master ( d90b4e...f33e4b )
by Thomas
12:35
created

src/ExcelImportExport.php (1 issue)

Severity
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;
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<string,string>
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<int|string,mixed>
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<string,string>
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;
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();
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();
184
    }
185
186
    /**
187
     * @param \SilverStripe\Admin\ModelAdmin $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)) {
0 ignored issues
show
The condition is_array($controller->showImportForm) is always false.
Loading history...
197
            /** @var array<string> $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($inst, 'setOnlyUpdate')) {
223
            $inst->setOnlyUpdate(true);
224
        }
225
        /** @var ExcelLoaderInterface $inst */
226
        try {
227
            $results = $inst->load($file, $name);
228
        } catch (Exception $e) {
229
            $form->sessionMessage($e->getMessage(), 'bad');
230
            return $controller->redirectBack();
231
        }
232
233
        $message = '';
234
        if ($results instanceof BulkLoader_Result) {
235
            if ($results->CreatedCount()) {
236
                $message .= _t(
237
                    'ModelAdmin.IMPORTEDRECORDS',
238
                    "Imported {count} records.",
239
                    ['count' => $results->CreatedCount()]
240
                );
241
            }
242
            if ($results->UpdatedCount()) {
243
                $message .= _t(
244
                    'ModelAdmin.UPDATEDRECORDS',
245
                    "Updated {count} records.",
246
                    ['count' => $results->UpdatedCount()]
247
                );
248
            }
249
            if ($results->DeletedCount()) {
250
                $message .= _t(
251
                    'ModelAdmin.DELETEDRECORDS',
252
                    "Deleted {count} records.",
253
                    ['count' => $results->DeletedCount()]
254
                );
255
            }
256
            if (!$results->CreatedCount() && !$results->UpdatedCount()) {
257
                $message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
258
            }
259
        } else {
260
            // Or we have a simple result
261
            $message = $results;
262
        }
263
264
        $form->sessionMessage($message, 'good');
265
        return $controller->redirectBack();
266
    }
267
268
    /**
269
     * @return string
270
     */
271
    public static function getDefaultExtension()
272
    {
273
        return self::config()->default_extension ?? 'xlsx';
274
    }
275
276
    /**
277
     * Get default writer for PHPSpreadsheet if installed
278
     *
279
     * @param Spreadsheet $spreadsheet
280
     * @return IWriter
281
     */
282
    public static function getDefaultWriter(Spreadsheet $spreadsheet): IWriter
283
    {
284
        if (!self::isPhpSpreadsheetAvailable()) {
285
            throw new Exception("PHPSpreadsheet is not installed");
286
        }
287
        $writer = ucfirst(self::getDefaultExtension());
288
        return IOFactory::createWriter($spreadsheet, $writer);
289
    }
290
291
    /**
292
     * Get default reader for PHPSpreadsheet if installed
293
     *
294
     * @return IReader
295
     */
296
    public static function getDefaultReader(): IReader
297
    {
298
        if (!self::isPhpSpreadsheetAvailable()) {
299
            throw new Exception("PHPSpreadsheet is not installed");
300
        }
301
        $writer = ucfirst(self::getDefaultExtension());
302
        return IOFactory::createReader($writer);
303
    }
304
305
    /**
306
     * Output excel headers
307
     *
308
     * @param string $fileName
309
     * @return void
310
     */
311
    public static function outputHeaders($fileName)
312
    {
313
        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
314
        switch ($ext) {
315
            case 'csv':
316
                header('Content-Type: text/csv');
317
                break;
318
            case 'xlsx':
319
                header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
320
                break;
321
            default:
322
                header('Content-type: application/vnd.ms-excel');
323
                break;
324
        }
325
326
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
327
        header('Cache-Control: max-age=0');
328
        ob_clean();
329
    }
330
331
    /**
332
     * Generate a default import file with all field name
333
     * @deprecated
334
     * @param string $class
335
     * @return string
336
     */
337
    public static function generateDefaultSampleFile($class)
338
    {
339
        $opts = [
340
            'creator' => "SilverStripe"
341
        ];
342
        $allFields = ExcelImportExport::importFieldsForClass($class);
343
        $tmpname = SpreadCompat::getTempFilename();
344
        SpreadCompat::write([
345
            $allFields
346
        ], $tmpname, ...$opts);
347
        return $tmpname;
348
    }
349
350
    /**
351
     * Show valid extensions helper (for uploaders)
352
     *
353
     * @return string
354
     */
355
    public static function getValidExtensionsText()
356
    {
357
        return _t(
358
            'ExcelImportExport.VALIDEXTENSIONS',
359
            "Allowed extensions: {extensions}",
360
            array('extensions' => implode(', ', self::getValidExtensions()))
361
        );
362
    }
363
364
    /**
365
     * Extracted from PHPSpreadsheet
366
     *
367
     * @param string $ext
368
     * @return string
369
     */
370
    public static function getReaderForExtension($ext)
371
    {
372
        switch (strtolower($ext)) {
373
            case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet
374
            case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded)
375
            case 'xltx': // Excel (OfficeOpenXML) Template
376
            case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded)
377
                return 'Xlsx';
378
            case 'xls': // Excel (BIFF) Spreadsheet
379
            case 'xlt': // Excel (BIFF) Template
380
                return 'Xls';
381
            case 'ods': // Open/Libre Offic Calc
382
            case 'ots': // Open/Libre Offic Calc Template
383
                return 'Ods';
384
            case 'slk':
385
                return 'Slk';
386
            case 'xml': // Excel 2003 SpreadSheetML
387
                return 'Xml';
388
            case 'gnumeric':
389
                return 'Gnumeric';
390
            case 'htm':
391
            case 'html':
392
                return 'Html';
393
            case 'csv':
394
                return 'Csv';
395
            case 'tmp': // Useful when importing uploaded files
396
                return self::$default_tmp_reader;
397
            default:
398
                throw new Exception("Unsupported file type : $ext");
399
        }
400
    }
401
402
    /**
403
     * @return boolean
404
     */
405
    public static function isPhpSpreadsheetAvailable()
406
    {
407
        return class_exists(\PhpOffice\PhpSpreadsheet\Spreadsheet::class);
408
    }
409
410
    /**
411
     * If you exported separated files, you can merge them in one big file
412
     * Requires PHPSpreadsheet
413
     * @param array<string> $files
414
     * @return Spreadsheet
415
     */
416
    public static function mergeExcelFiles($files)
417
    {
418
        if (!self::isPhpSpreadsheetAvailable()) {
419
            throw new Exception("PHPSpreadsheet is not installed");
420
        }
421
        $merged = new Spreadsheet;
422
        $merged->removeSheetByIndex(0);
423
        foreach ($files as $filename) {
424
            $remoteExcel = IOFactory::load($filename);
425
            $merged->addExternalSheet($remoteExcel->getActiveSheet());
426
        }
427
        return $merged;
428
    }
429
430
    /**
431
     * Get valid extensions
432
     *
433
     * @return array<string>
434
     */
435
    public static function getValidExtensions()
436
    {
437
        $v = self::config()->allowed_extensions;
438
        if (!$v || !is_array($v)) {
439
            return [];
440
        }
441
        return $v;
442
    }
443
444
    /**
445
     * Save content of an array to a file
446
     *
447
     * @param iterable<array<mixed>> $data
448
     * @param string $filepath
449
     * @return void
450
     */
451
    public static function arrayToFile($data, $filepath)
452
    {
453
        SpreadCompat::write($data, $filepath);
454
    }
455
456
    /**
457
     * Fast saving to csv
458
     *
459
     * @param array<mixed> $data
460
     * @param string $filepath
461
     * @param string $delimiter
462
     * @param string $enclosure
463
     * @param string $escapeChar
464
     * @return void
465
     */
466
    public static function arrayToCsv($data, $filepath, $delimiter = ',', $enclosure = '"', $escapeChar = '\\')
467
    {
468
        if (is_file($filepath)) {
469
            unlink($filepath);
470
        }
471
        $options = new Options();
472
        $options->separator = $delimiter;
473
        $options->enclosure = $enclosure;
474
        $options->escape = $escapeChar;
475
        SpreadCompat::write($data, $filepath, $options);
476
    }
477
478
    public static function excelColumnRange(string $lower = 'A', string $upper = 'ZZ'): Generator
479
    {
480
        ++$upper;
481
        for ($i = $lower; $i !== $upper; ++$i) {
482
            yield $i;
483
        }
484
    }
485
486
    /**
487
     * String from column index.
488
     *
489
     * @param int $index Column index (1 = A)
490
     * @param string $fallback
491
     * @return string
492
     */
493
    public static function getLetter($index, $fallback = 'A')
494
    {
495
        foreach (self::excelColumnRange() as $letter) {
496
            $index--;
497
            if ($index <= 0) {
498
                return $letter;
499
            }
500
        }
501
        return $fallback;
502
    }
503
504
    /**
505
     * Convert a file to an array
506
     *
507
     * @param string $filepath
508
     * @param string $delimiter (csv only)
509
     * @param string $enclosure (csv only)
510
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
511
     * @return array<mixed>
512
     */
513
    public static function fileToArray($filepath, $delimiter = ';', $enclosure = '', $ext = null)
514
    {
515
        if ($ext === null) {
516
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
517
        }
518
519
        $data = iterator_to_array(
520
            SpreadCompat::read(
521
                $filepath,
522
                extension: $ext,
523
                separator: $delimiter,
524
                enclosure: $enclosure
525
            )
526
        );
527
        return $data;
528
    }
529
530
    /**
531
     * Convert an excel file to an array
532
     *
533
     * @param string $filepath
534
     * @param string $sheetname Load a specific worksheet by name
535
     * @param bool $onlyExisting Avoid reading empty columns
536
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
537
     * @return array<mixed>
538
     */
539
    public static function excelToArray($filepath, $sheetname = null, $onlyExisting = true, $ext = null)
540
    {
541
        if (!self::isPhpSpreadsheetAvailable()) {
542
            throw new Exception("PHPSpreadsheet is not installed");
543
        }
544
        if ($ext === null) {
545
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
546
        }
547
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
548
        $readerType = self::getReaderForExtension($ext);
549
        $reader = IOFactory::createReader($readerType);
550
        $reader->setReadDataOnly(true);
551
        if ($sheetname) {
552
            $reader->setLoadSheetsOnly($sheetname);
553
        }
554
        $data = [];
555
        if ($reader->canRead($filepath)) {
556
            $excel = $reader->load($filepath);
557
            if ($onlyExisting) {
558
                $data = [];
559
                foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
560
                    $cellIterator = $row->getCellIterator();
561
                    if (self::$iterate_only_existing_cells) {
562
                        $cellIterator->setIterateOnlyExistingCells(true);
563
                    }
564
                    $cells = [];
565
                    foreach ($cellIterator as $cell) {
566
                        if (self::$use_old_calculated_value) {
567
                            $cells[] = $cell->getOldCalculatedValue();
568
                        } else {
569
                            $cells[] = $cell->getFormattedValue();
570
                        }
571
                    }
572
                    $data[] = $cells;
573
                }
574
            } else {
575
                $data = $excel->getActiveSheet()->toArray(null, true, false, false);
576
            }
577
        } else {
578
            throw new Exception("Cannot read $filepath");
579
        }
580
        return $data;
581
    }
582
583
    /**
584
     * @link https://stackoverflow.com/questions/44304795/how-to-retrieve-date-from-table-cell-using-phpspreadsheet#44304796
585
     * @param int|string|null|float $v
586
     * @return string
587
     */
588
    public static function convertExcelDate($v)
589
    {
590
        if ($v === null || !is_numeric($v)) {
591
            return '';
592
        }
593
        if (!self::isPhpSpreadsheetAvailable()) {
594
            throw new Exception("PHPSpreadsheet is not installed");
595
        }
596
        if (is_string($v)) {
597
            $v = intval($v);
598
        }
599
        return date('Y-m-d', Date::excelToTimestamp($v));
600
    }
601
602
    /**
603
     * Convert an excel file to an associative array
604
     *
605
     * Suppose the first line are the headers of the file
606
     * Headers are trimmed in case you have crappy whitespace in your files
607
     *
608
     * @param string $filepath
609
     * @param string $sheetname Load a specific worksheet by name
610
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
611
     * @return array<mixed>
612
     */
613
    public static function excelToAssocArray($filepath, $sheetname = null, $ext = null)
614
    {
615
        if (!self::isPhpSpreadsheetAvailable()) {
616
            throw new Exception("PHPSpreadsheet is not installed");
617
        }
618
        if ($ext === null) {
619
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
620
        }
621
        $readerType = self::getReaderForExtension($ext);
622
        $reader = IOFactory::createReader($readerType);
623
        $reader->setReadDataOnly(true);
624
        if ($sheetname) {
625
            $reader->setLoadSheetsOnly($sheetname);
626
        }
627
        $data = [];
628
        if ($reader->canRead($filepath)) {
629
            $excel = $reader->load($filepath);
630
            $data = [];
631
            $headers = [];
632
            $headersCount = 0;
633
            foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
634
                $cellIterator = $row->getCellIterator();
635
                if (self::$iterate_only_existing_cells) {
636
                    $cellIterator->setIterateOnlyExistingCells(true);
637
                }
638
                $cells = [];
639
                foreach ($cellIterator as $cell) {
640
                    if (self::$use_old_calculated_value) {
641
                        $cells[] = $cell->getOldCalculatedValue();
642
                    } else {
643
                        $cells[] = $cell->getFormattedValue();
644
                    }
645
                }
646
                if (empty($headers)) {
647
                    $headers = $cells;
648
                    // Some crappy excel file may need this
649
                    if (self::$process_headers) {
650
                        $headers = array_map(function ($v) {
651
                            // Numeric headers are most of the time dates
652
                            if (is_numeric($v)) {
653
                                $v =  self::convertExcelDate($v);
654
                            }
655
                            // trim does not always work great and headers can contain utf8 stuff
656
                            return is_string($v) ? preg_replace('/(^\s+)|(\s+$)/us', '', $v) : $v;
657
                        }, $headers);
658
                    }
659
                    $headersCount = count($headers);
660
                } else {
661
                    $diff = count($cells) - $headersCount;
662
                    if ($diff != 0) {
663
                        if ($diff > 0) {
664
                            // we have too many cells
665
                            $cells = array_slice($cells, 0, $headersCount);
666
                        } else {
667
                            // we are missing some cells
668
                            for ($i = 0; $i < abs($diff); $i++) {
669
                                $cells[] = null;
670
                            }
671
                        }
672
                    }
673
                    $data[] = array_combine($headers, $cells);
674
                }
675
            }
676
        } else {
677
            throw new Exception("Cannot read $filepath");
678
        }
679
        return $data;
680
    }
681
}
682