Passed
Push — master ( 24ce5c...4e74dd )
by Thomas
31:55 queued 28:22
created

ExcelImportExport::getDefaultWriter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

302
        $writerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
303
        $writer = IOFactory::createWriter($spreadsheet, $writerType);
304
        $writer->save($filepath);
305
    }
306
307
    /**
308
     * Fast saving to csv
309
     *
310
     * @param array $data
311
     * @param string $filepath
312
     * @param string $delimiter
313
     * @param string $enclosure
314
     * @param string $escapeChar
315
     */
316
    public static function arrayToCsv($data, $filepath, $delimiter = ',', $enclosure = '"', $escapeChar = '\\')
317
    {
318
        if (is_file($filepath)) {
319
            unlink($filepath);
320
        }
321
        $fp = fopen($filepath, 'w');
322
        // UTF 8 fix
323
        fprintf($fp, "\xEF\xBB\xBF");
324
        foreach ($data as $row) {
325
            fputcsv($fp, $row, $delimiter, $enclosure, $escapeChar);
326
        }
327
        return fclose($fp);
328
    }
329
330
331
    /**
332
     * @param IReader $reader
333
     * @return Csv
334
     */
335
    protected static function getCsvReader(IReader $reader)
336
    {
337
        return $reader;
338
    }
339
340
    /**
341
     * Convert a file to an array
342
     *
343
     * @param string $filepath
344
     * @param string $delimiter (csv only)
345
     * @param string $enclosure (csv only)
346
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
347
     * @return array
348
     */
349
    public static function fileToArray($filepath, $delimiter = ';', $enclosure = '', $ext = null)
350
    {
351
        if ($ext === null) {
352
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
353
        }
354
        $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

354
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
355
        $reader = IOFactory::createReader($readerType);
356
        if ($readerType == 'Csv') {
357
            // @link https://phpspreadsheet.readthedocs.io/en/latest/topics/reading-and-writing-to-file/#setting-csv-options_1
358
            $reader = self::getCsvReader($reader);
359
            $reader->setDelimiter($delimiter);
360
            $reader->setEnclosure($enclosure);
361
        } else {
362
            // Does not apply to CSV
363
            $reader->setReadDataOnly(true);
364
        }
365
        $data = array();
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
366
        if ($reader->canRead($filepath)) {
367
            $excel = $reader->load($filepath);
368
            $data = $excel->getActiveSheet()->toArray(null, true, false, false);
369
        } else {
370
            throw new Exception("Cannot read $filepath");
371
        }
372
        return $data;
373
    }
374
375
    /**
376
     * Convert an excel file to an array
377
     *
378
     * @param string $filepath
379
     * @param string $sheetname Load a specific worksheet by name
380
     * @param true $onlyExisting Avoid reading empty columns
381
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
382
     * @return array
383
     */
384
    public static function excelToArray($filepath, $sheetname = null, $onlyExisting = true, $ext = null)
385
    {
386
        if ($ext === null) {
387
            $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...
388
        }
389
        $ext = pathinfo($filepath, PATHINFO_EXTENSION);
390
        $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

390
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
391
        $reader = IOFactory::createReader($readerType);
392
        $reader->setReadDataOnly(true);
393
        if ($sheetname) {
394
            $reader->setLoadSheetsOnly($sheetname);
395
        }
396
        $data = array();
397
        if ($reader->canRead($filepath)) {
398
            $excel = $reader->load($filepath);
399
            if ($onlyExisting) {
0 ignored issues
show
introduced by
The condition $onlyExisting is always true.
Loading history...
400
                $data = [];
401
                foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
402
                    $cellIterator = $row->getCellIterator();
403
                    if (self::$iterate_only_existing_cells) {
404
                        $cellIterator->setIterateOnlyExistingCells(true);
405
                    }
406
                    $cells = [];
407
                    foreach ($cellIterator as $cell) {
408
                        if (self::$use_old_calculated_value) {
409
                            $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

409
                            /** @scrutinizer ignore-call */ 
410
                            $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...
410
                        } else {
411
                            $cells[] = $cell->getFormattedValue();
412
                        }
413
                    }
414
                    $data[] = $cells;
415
                }
416
            } else {
417
                $data = $excel->getActiveSheet()->toArray(null, true, false, false);
418
            }
419
        } else {
420
            throw new Exception("Cannot read $filepath");
421
        }
422
        return $data;
423
    }
424
425
    /**
426
     * @link https://stackoverflow.com/questions/44304795/how-to-retrieve-date-from-table-cell-using-phpspreadsheet#44304796
427
     * @param int $v
428
     * @return string
429
     */
430
    public static function convertExcelDate($v)
431
    {
432
        if (!is_numeric($v)) {
0 ignored issues
show
introduced by
The condition is_numeric($v) is always true.
Loading history...
433
            return '';
434
        }
435
        return date('Y-m-d', Date::excelToTimestamp($v));
436
    }
437
438
    /**
439
     * Convert an excel file to an associative array
440
     *
441
     * Suppose the first line are the headers of the file
442
     * Headers are trimmed in case you have crappy whitespace in your files
443
     *
444
     * @param string $filepath
445
     * @param string $sheetname Load a specific worksheet by name
446
     * @param string $ext if extension cannot be deducted from filepath (eg temp files)
447
     * @return array
448
     */
449
    public static function excelToAssocArray($filepath, $sheetname = null, $ext = null)
450
    {
451
        if ($ext === null) {
452
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
453
        }
454
        $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

454
        $readerType = self::getReaderForExtension(/** @scrutinizer ignore-type */ $ext);
Loading history...
455
        $reader = IOFactory::createReader($readerType);
456
        $reader->setReadDataOnly(true);
457
        if ($sheetname) {
458
            $reader->setLoadSheetsOnly($sheetname);
459
        }
460
        $data = array();
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
461
        if ($reader->canRead($filepath)) {
462
            $excel = $reader->load($filepath);
463
            $data = [];
464
            $headers = [];
465
            $headersCount = 0;
466
            foreach ($excel->getActiveSheet()->getRowIterator() as $row) {
467
                $cellIterator = $row->getCellIterator();
468
                if (self::$iterate_only_existing_cells) {
469
                    $cellIterator->setIterateOnlyExistingCells(true);
470
                }
471
                $cells = [];
472
                foreach ($cellIterator as $cell) {
473
                    if (self::$use_old_calculated_value) {
474
                        $cells[] = $cell->getOldCalculatedValue();
475
                    } else {
476
                        $cells[] = $cell->getFormattedValue();
477
                    }
478
                }
479
                if (empty($headers)) {
480
                    $headers = $cells;
481
                    // Some crappy excel file may need this
482
                    if (self::$process_headers) {
483
                        $headers = array_map(function ($v) {
484
                            // Numeric headers are most of the time dates
485
                            if (is_numeric($v)) {
486
                                $v =  self::convertExcelDate($v);
487
                            }
488
                            // trim does not always work great and headers can contain utf8 stuff
489
                            return is_string($v) ? preg_replace('/(^\s+)|(\s+$)/us', '', $v) : $v;
490
                        }, $headers);
491
                    }
492
                    $headersCount = count($headers);
493
                } else {
494
                    $diff = count($cells) - $headersCount;
495
                    if ($diff != 0) {
496
                        if ($diff > 0) {
497
                            // we have too many cells
498
                            $cells = array_slice($cells, 0, $headersCount);
499
                        } else {
500
                            // we are missing some cells
501
                            for ($i = 0; $i < abs($diff); $i++) {
502
                                $cells[] = null;
503
                            }
504
                        }
505
                    }
506
                    $data[] = array_combine($headers, $cells);
507
                }
508
            }
509
        } else {
510
            throw new Exception("Cannot read $filepath");
511
        }
512
        return $data;
513
    }
514
}
515