Test Failed
Push — develop ( 5be0e5...3f5c00 )
by Paul
14:23
created

ProcessCsvFile::handle()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
dl 0
loc 15
rs 9.9
c 1
b 0
f 0
cc 4
nc 4
nop 0
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Commands;
4
5
use GeminiLabs\League\Csv\CannotInsertRecord;
6
use GeminiLabs\League\Csv\CharsetConverter;
7
use GeminiLabs\League\Csv\Exception;
8
use GeminiLabs\League\Csv\Info;
9
use GeminiLabs\League\Csv\Reader;
10
use GeminiLabs\League\Csv\Statement;
11
use GeminiLabs\League\Csv\Writer;
12
use GeminiLabs\SiteReviews\Database\ImportManager;
13
use GeminiLabs\SiteReviews\Exceptions\FileNotFoundException;
14
use GeminiLabs\SiteReviews\Helpers\Arr;
15
use GeminiLabs\SiteReviews\Helpers\Str;
16
use GeminiLabs\SiteReviews\Modules\Date;
17
use GeminiLabs\SiteReviews\Modules\Dump;
18
use GeminiLabs\SiteReviews\Modules\Notice;
19
use GeminiLabs\SiteReviews\Modules\Rating;
20
use GeminiLabs\SiteReviews\Request;
21
use GeminiLabs\SiteReviews\Upload;
22
use GeminiLabs\SiteReviews\UploadedFile;
23
24
class ProcessCsvFile extends AbstractCommand
25
{
26
    use Upload;
27
28
    public const ALLOWED_DATE_FORMATS = [
29
        'd-m-Y', 'd-m-Y H:i', 'd-m-Y H:i:s',
30
        'd/m/Y', 'd/m/Y H:i', 'd/m/Y H:i:s',
31
        'm-d-Y', 'm-d-Y H:i', 'm-d-Y H:i:s',
32
        'm/d/Y', 'm/d/Y H:i', 'm/d/Y H:i:s',
33
        'Y-m-d', 'Y-m-d H:i', 'Y-m-d H:i:s',
34
        'Y/m/d', 'Y/m/d H:i', 'Y/m/d H:i:s',
35
    ];
36
37
    public const ALLOWED_DELIMITERS = [
38
        ',', ';',
39
    ];
40
41
    public const REQUIRED_KEYS = [
42
        'date', 'rating',
43
    ];
44
45
    protected string $dateFormat;
46
47
    protected string $delimiter;
48
49
    protected array $errors;
50
51
    protected int $skipped;
52
53
    protected int $total;
54
55
    public function __construct(Request $request)
56
    {
57
        $this->dateFormat = Str::restrictTo(static::ALLOWED_DATE_FORMATS, $request->date_format, 'Y-m-d');
58
        $this->delimiter = Str::restrictTo(static::ALLOWED_DELIMITERS, $request->delimiter, '');
59
        $this->errors = [];
60
        $this->skipped = 0;
61
        $this->total = 0;
62
    }
63
64
    public function handle(): void
65
    {
66
        try {
67
            $file = $this->file();
68
        } catch (FileNotFoundException $e) {
69
            glsr(Notice::class)->addError($e->getMessage());
70
            $this->fail();
71
            return;
72
        }
73
        if (!$this->validateFile($file)) {
74
            $this->fail();
75
            return;
76
        }
77
        if (!$this->process($file)) {
78
            $this->fail();
79
        }
80
    }
81
82
    public function response(): array
83
    {
84
        return [
85
            'errors' => $this->errors, // @todo store this to the session
86
            'notices' => glsr(Notice::class)->get(), // this should be empty on success
87
            'skipped' => $this->skipped,
88
            'total' => $this->total,
89
        ];
90
    }
91
92
    protected function formatRecord(array $record): array
93
    {
94
        $record = array_map('trim', $record);
95
        if (!empty($record['date'])) {
96
            $date = \DateTime::createFromFormat($this->dateFormat, $record['date']);
97
            $record['date'] = $date->format('Y-m-d H:i:s'); // format the provided date
98
        }
99
        return $record;
100
    }
101
102
    protected function process(UploadedFile $file): bool
103
    {
104
        if (!defined('WP_IMPORTING')) {
105
            define('WP_IMPORTING', true);
106
        }
107
        glsr(ImportManager::class)->flush(); // flush the temporary table in the database
108
        glsr(ImportManager::class)->unlinkTempFile(); //.delete the temporary import file if it exists
109
        try {
110
            wp_raise_memory_limit('admin');
111
            $reader = $this->reader($file->getPathname());
112
            $header = array_map('trim', $reader->getHeader());
113
            if (!empty(array_diff(static::REQUIRED_KEYS, $header))) {
114
                throw new Exception(_x('The CSV file could not be imported. Please verify the following details and try again:', 'admin-text', 'site-reviews'));
115
            }
116
            $filePath = glsr(ImportManager::class)->tempFilePath();
117
            $writer = Writer::createFromPath($filePath, 'w+');
118
            $writer->insertOne($header);
119
            $chunks = $reader->chunkBy(1000);
120
            foreach ($chunks as $chunk) {
121
                $records = Statement::create()
122
                    ->where(fn (array $record) => !empty(array_filter($record, 'trim'))) // @phpstan-ignore-line remove empty rows
123
                    ->where(fn (array $record) => $this->validateRecord($record))
124
                    ->process($reader, $header);
125
                $writer->insertAll($records);
126
                $this->total += count($records);
127
            }
128
            return true;
129
        } catch (CannotInsertRecord $e) {
130
            glsr(Notice::class)->addError(_x('Unable to process a row in the CSV document:', 'admin-text', 'site-reviews'), [
131
                glsr(Dump::class)->dump($e->getRecord()),
132
            ]);
133
        } catch (Exception $e) {
134
            glsr(Notice::class)->addError($e->getMessage(), [
135
                '👉🏼 '._x('Does the CSV file include all required columns?', 'admin-text', 'site-reviews'),
136
                '👉🏼 '._x('Have you named all of the columns in the CSV file?', 'admin-text', 'site-reviews'),
137
                '👉🏼 '._x('Have you removed all empty columns from the CSV file?', 'admin-text', 'site-reviews'),
138
                '👉🏼 '._x('Have you selected the correct delimiter?', 'admin-text', 'site-reviews'),
139
                '👉🏼 '._x('Is the CSV file encoded as UTF-8?', 'admin-text', 'site-reviews'),
140
            ]);
141
        } catch (\OutOfRangeException|\Exception|\TypeError $e) {
142
            glsr(Notice::class)->addError($e->getMessage());
143
        }
144
        glsr(ImportManager::class)->unlinkTempFile();
145
        return false;
146
    }
147
148
    /**
149
     * @throws Exception
150
     */
151
    protected function reader(string $filepath): Reader
152
    {
153
        $reader = Reader::createFromPath($filepath);
154
        if (empty($this->delimiter)) {
155
            $delimiters = Info::getDelimiterStats($reader, static::ALLOWED_DELIMITERS);
156
            $delimiters = array_keys(array_filter($delimiters));
157
            if (1 !== count($delimiters)) {
158
                throw new Exception(_x('Cannot detect the delimiter used in the CSV file (supported delimiters are comma and semicolon).', 'admin-text', 'site-reviews'));
159
            }
160
            $this->delimiter = $delimiters[0];
161
        }
162
        $reader->setDelimiter($this->delimiter);
163
        $reader->setHeaderOffset(0);
164
        $reader->skipEmptyRecords();
165
        $reader->addFormatter(fn (array $record) => $this->formatRecord($record));
166
        if ($reader->supportsStreamFilterOnRead()) {
167
            $inputBom = $reader->getInputBOM();
168
            if (in_array($inputBom, [Reader::BOM_UTF16_LE, Reader::BOM_UTF16_BE], true)) {
169
                return CharsetConverter::addTo($reader, 'utf-16', 'utf-8'); // @phpstan-ignore-line
170
            } elseif (in_array($inputBom, [Reader::BOM_UTF32_LE, Reader::BOM_UTF32_BE], true)) {
171
                return CharsetConverter::addTo($reader, 'utf-32', 'utf-8'); // @phpstan-ignore-line
172
            }
173
        }
174
        return $reader;
175
    }
176
177
    protected function validateFile(UploadedFile $file): bool
178
    {
179
        if (!$file->isValid()) {
180
            glsr(Notice::class)->addError($file->getErrorMessage());
181
            return false;
182
        }
183
        if (!$file->hasMimeType('text/csv')) {
184
            glsr(Notice::class)->addError(
185
                sprintf(_x('The import file is not a valid CSV file (detected: %s).', 'admin-text', 'site-reviews'), $file->getClientMimeType())
186
            );
187
            return false;
188
        }
189
        return true;
190
    }
191
192
    protected function validateRecord(array $record): bool
193
    {
194
        $record = array_map('trim', $record);
195
        $required = [
196
            'date' => glsr(Date::class)->isDate(Arr::getAs('string', $record, 'date'), $this->dateFormat),
197
            'rating' => glsr(Rating::class)->isValid(Arr::getAs('int', $record, 'rating')),
198
        ];
199
        if (2 === count(array_filter($required))) {
200
            return true;
201
        }
202
        $errorMessages = [
203
            'date' => _x('Incorrect date format', 'admin-text', 'site-reviews'),
204
            'rating' => _x('Empty or invalid rating', 'admin-text', 'site-reviews'),
205
        ];
206
        $errors = array_intersect_key($errorMessages, array_diff_key($required, array_filter($required)));
207
        $this->errors = array_merge($this->errors, $errors);
208
        ++$this->skipped;
209
        return false;
210
    }
211
}
212