PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / import / ImportService.php

ImportService.php in 404 Solution 4.3.0, at includes/import/ImportService.php

492 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Owns upload-level redirect-import orchestration.
9 *
10 * This service validates the uploaded file, streams it through
11 * ImportCsvParser, delegates each canonical row to ImportRedirectRowProcessor,
12 * and coordinates ImportProgressStore checkpoints so interrupted imports can
13 * resume by re-uploading the same file content.
14 */
15 class ABJ_404_Solution_ImportService {
16
17 const IMPORT_PROGRESS_OPTION = 'abj404_import_progress';
18 const IMPORT_PROGRESS_CHECKPOINT_INTERVAL = 50;
19
20 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
21 private $redirectsRepository;
22
23 /** @var ABJ_404_Solution_ContentRepositoryInterface */
24 private $contentRepository;
25
26 /** @var ABJ_404_Solution_Logging */
27 private $logger;
28
29 /** @var ABJ_404_Solution_ImportCsvParser */
30 private $parser;
31
32 /** @var ABJ_404_Solution_ImportProgressStore */
33 private $progressStore;
34
35 /** @var ABJ_404_Solution_ImportUploadValidator */
36 private $uploadValidator;
37
38 /** @var ABJ_404_Solution_ImportRedirectRowProcessor */
39 private $rowProcessor;
40
41 /**
42 * Constructor supports two signatures for backward compatibility:
43 * (1) New: (RedirectsRepository, ContentRepository, Logging)
44 * (2) Legacy: (DataAccess, Logging) -- DataAccess delegates to modules
45 *
46 * @param mixed $redirectsRepoOrDataAccess RedirectsRepository or legacy DataAccess facade
47 * @param mixed $contentRepoOrLogging ContentRepository or legacy Logging
48 * @param ABJ_404_Solution_Logging|null $logging
49 */
50 function __construct($redirectsRepoOrDataAccess, $contentRepoOrLogging, $logging = null) {
51 if ($logging === null) {
52 /** @var ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepoOrDataAccess */
53 $this->redirectsRepository = $redirectsRepoOrDataAccess;
54 $this->contentRepository = (is_object($redirectsRepoOrDataAccess) && method_exists($redirectsRepoOrDataAccess, 'getContentRepo'))
55 ? $redirectsRepoOrDataAccess->getContentRepo()
56 : $redirectsRepoOrDataAccess;
57 /** @var ABJ_404_Solution_Logging $contentRepoOrLogging */
58 $this->logger = $contentRepoOrLogging;
59 } else {
60 /** @var ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepoOrDataAccess */
61 $this->redirectsRepository = $redirectsRepoOrDataAccess;
62 /** @var ABJ_404_Solution_ContentRepositoryInterface $contentRepoOrLogging */
63 $this->contentRepository = $contentRepoOrLogging;
64 $this->logger = $logging;
65 }
66
67 $this->parser = new ABJ_404_Solution_ImportCsvParser();
68 $this->progressStore = new ABJ_404_Solution_ImportProgressStore(self::IMPORT_PROGRESS_OPTION);
69 $this->uploadValidator = new ABJ_404_Solution_ImportUploadValidator();
70 $this->rowProcessor = new ABJ_404_Solution_ImportRedirectRowProcessor(
71 $this->redirectsRepository,
72 $this->contentRepository,
73 $this->logger
74 );
75 }
76
77 /**
78 * Expected formats:
79 * - from_url,status,type,to_url,wp_type
80 * - from_url,to_url
81 *
82 * @return string
83 */
84 function doImportFile(): string {
85 $uploadFile = $_FILES['import_file'] ?? null;
86 if (!is_array($uploadFile) ||
87 !isset($uploadFile['error']) ||
88 !is_numeric($uploadFile['error']) ||
89 (int)$uploadFile['error'] !== UPLOAD_ERR_OK) {
90 return __('File upload error.', '404-solution');
91 }
92
93 $dryRun = isset($_POST['dry_run']) && sanitize_text_field((string)$_POST['dry_run']) === '1';
94 $overwriteExisting = isset($_POST['overwrite_existing']) && sanitize_text_field((string)$_POST['overwrite_existing']) === '1';
95
96 $validationError = $this->uploadValidator->validate($uploadFile);
97 if ($validationError !== '') {
98 return $validationError;
99 }
100
101 $tmpName = $this->uploadValidator->tmpName($uploadFile);
102 $file_handle = fopen($tmpName, 'r');
103 if (!$file_handle) {
104 return __('Error opening the file.', '404-solution');
105 }
106
107 $hashResult = hash_file('sha256', $tmpName);
108 $contentHash = ($dryRun || !is_string($hashResult)) ? '' : $hashResult;
109 $runState = $this->applyResumeProgress($contentHash, $dryRun, $this->emptyRunState());
110
111 $delimiter = $this->parser->detectCsvDelimiterFromFile($file_handle);
112 rewind($file_handle);
113 $runState = $this->processFileRows(
114 $file_handle,
115 $delimiter,
116 $this->stateInt($runState, 'resume_from'),
117 $dryRun,
118 $overwriteExisting,
119 $contentHash,
120 $runState
121 );
122 fclose($file_handle);
123
124 if (isset($runState['abort_message']) && is_string($runState['abort_message'])) {
125 return $runState['abort_message'];
126 }
127
128 if (!$dryRun) {
129 $this->progressStore->clearImportProgress();
130 }
131
132 return $this->formatImportResult($runState, $dryRun, $overwriteExisting);
133 }
134
135 /**
136 * @return array<string, mixed>
137 */
138 private function emptyRunState(): array {
139 return array(
140 'issues' => array(),
141 'processed' => 0,
142 'valid' => 0,
143 'invalid' => 0,
144 'overwritten' => 0,
145 'resume_from' => 0,
146 );
147 }
148
149 /**
150 * @param string $contentHash
151 * @param bool $dryRun
152 * @param array<string, mixed> $state
153 * @return array<string, mixed>
154 */
155 private function applyResumeProgress(string $contentHash, bool $dryRun, array $state): array {
156 if ($dryRun) {
157 return $state;
158 }
159
160 $existingProgress = $this->progressStore->getResumeProgress($contentHash);
161 if ($existingProgress === null) {
162 return $state;
163 }
164
165 $resumeFrom = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'rows_processed', 0);
166 $state['resume_from'] = $resumeFrom;
167 $state['processed'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'processed_count', $resumeFrom);
168 $state['valid'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'valid_count', 0);
169 $state['invalid'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'invalid_count', 0);
170 $state['overwritten'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'overwritten_count', 0);
171 if (isset($existingProgress['issues']) && is_array($existingProgress['issues'])) {
172 /** @var array<int, string> $persistedIssues */
173 $persistedIssues = $existingProgress['issues'];
174 $state['issues'] = $persistedIssues;
175 }
176 return $state;
177 }
178
179 /**
180 * @param resource $fileHandle
181 * @param string $delimiter
182 * @param int $resumeFromDataRow
183 * @param bool $dryRun
184 * @param bool $overwriteExisting
185 * @param string $contentHash
186 * @param array<string, mixed> $state
187 * @return array<string, mixed>
188 */
189 private function processFileRows($fileHandle, string $delimiter, int $resumeFromDataRow,
190 bool $dryRun, bool $overwriteExisting, string $contentHash,
191 array $state): array {
192 $headerColumns = null;
193 $dataRowsSeen = 0;
194 while (($row = fgetcsv($fileHandle, 0, $delimiter, '"', '\\')) !== false) {
195 $data = $this->normalizeCsvRow($row);
196
197 if ($this->isBlankCsvRow($data)) {
198 continue;
199 }
200
201 if ($headerColumns === null && $this->parser->isCompatibleImportHeaderRow($data)) {
202 $headerColumns = $this->parser->normalizeImportHeaders($data);
203 continue;
204 }
205
206 if ($dataRowsSeen < $resumeFromDataRow) {
207 $dataRowsSeen++;
208 continue;
209 }
210
211 $dataArray = $this->mapCsvDataRow($data, $headerColumns);
212 if (isset($dataArray['error'])) {
213 $state['abort_message'] = $dataArray['error'];
214 return $state;
215 }
216
217 if ($this->isRepeatedHeaderDataRow($dataArray)) {
218 $dataRowsSeen++;
219 continue;
220 }
221
222 $state = $this->processDataArray(
223 $dataArray, $dataRowsSeen, $dryRun, $overwriteExisting, $contentHash, $state
224 );
225 $dataRowsSeen++;
226
227 if (!$dryRun && ($dataRowsSeen % self::IMPORT_PROGRESS_CHECKPOINT_INTERVAL) === 0) {
228 $this->persistCheckpoint($contentHash, $dataRowsSeen, $state);
229 }
230 if (isset($state['abort_message'])) {
231 return $state;
232 }
233 }
234
235 return $state;
236 }
237
238 /**
239 * @param array<int, string|null>|false|null $row
240 * @return array<int, string>
241 */
242 private function normalizeCsvRow($row): array {
243 if (!is_array($row)) {
244 return array();
245 }
246 return array_map(function($v) {
247 return trim((string)$v);
248 }, $row);
249 }
250
251 /** @param array<int, string> $data @return bool */
252 private function isBlankCsvRow(array $data): bool {
253 return count($data) === 1 && $data[0] === '';
254 }
255
256 /**
257 * @param array<int, string> $data
258 * @param array<int, string|null>|null $headerColumns
259 * @return array<string, string>
260 */
261 private function mapCsvDataRow(array $data, $headerColumns): array {
262 return $headerColumns !== null
263 ? $this->parser->mapImportRowByHeaders($data, $headerColumns)
264 : $this->parser->mapImportRowWithoutHeaders($data);
265 }
266
267 /**
268 * @param array<string, string> $dataArray
269 * @return bool
270 */
271 private function isRepeatedHeaderDataRow(array $dataArray): bool {
272 return isset($dataArray['from_url']) &&
273 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request');
274 }
275
276 /**
277 * @param array<string, string> $dataArray
278 * @param int $dataRowsSeen
279 * @param bool $dryRun
280 * @param bool $overwriteExisting
281 * @param string $contentHash
282 * @param array<string, mixed> $state
283 * @return array<string, mixed>
284 */
285 private function processDataArray(array $dataArray, int $dataRowsSeen, bool $dryRun,
286 bool $overwriteExisting, string $contentHash,
287 array $state): array {
288 try {
289 $state['processed'] = $this->stateInt($state, 'processed') + 1;
290 $wasOverwrite = $this->wouldOverwriteExisting($dataArray, $overwriteExisting);
291 $dataArray['__line_number'] = (string)($dataRowsSeen + 1);
292 $issues = $this->rowProcessor->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting);
293 return $this->accountForRowIssues($state, $issues, $wasOverwrite);
294 } catch (\Throwable $e) {
295 $this->recordPausedImport($contentHash, $dataRowsSeen, $state, $dryRun, $e);
296 $state['abort_message'] = sprintf(
297 __('Import paused at row %1$d. %2$d redirect(s) imported so far. Re-upload the same file to resume from row %1$d.', '404-solution'),
298 $dataRowsSeen + 1,
299 $this->stateInt($state, 'valid')
300 );
301 return $state;
302 }
303 }
304
305 /**
306 * @param array<string, string> $dataArray
307 * @param bool $overwriteExisting
308 * @return bool
309 */
310 private function wouldOverwriteExisting(array $dataArray, bool $overwriteExisting): bool {
311 if (!$overwriteExisting || !isset($dataArray['from_url']) || !is_string($dataArray['from_url'])) {
312 return false;
313 }
314 $existing = $this->redirectsRepository->getExistingRedirectForURL($dataArray['from_url']);
315 return is_array($existing) && isset($existing['id']) && is_numeric($existing['id']) && (int)$existing['id'] !== 0;
316 }
317
318 /**
319 * @param array<string, mixed> $state
320 * @param array<int, string> $issues
321 * @param bool $wasOverwrite
322 * @return array<string, mixed>
323 */
324 private function accountForRowIssues(array $state, array $issues, bool $wasOverwrite): array {
325 if (count($issues) > 0) {
326 $state['invalid'] = $this->stateInt($state, 'invalid') + 1;
327 } else {
328 $state['valid'] = $this->stateInt($state, 'valid') + 1;
329 if ($wasOverwrite) {
330 $state['overwritten'] = $this->stateInt($state, 'overwritten') + 1;
331 }
332 }
333 $state['issues'] = array_merge($this->stateIssues($state), $issues);
334 return $state;
335 }
336
337 /**
338 * @param string $contentHash
339 * @param int $dataRowsSeen
340 * @param array<string, mixed> $state
341 * @param bool $dryRun
342 * @param Throwable $e
343 * @return void
344 */
345 private function recordPausedImport(string $contentHash, int $dataRowsSeen, array $state,
346 bool $dryRun, \Throwable $e): void {
347 if (!$dryRun) {
348 $this->progressStore->persistImportProgress($contentHash, array(
349 'rows_processed' => $dataRowsSeen,
350 'processed_count' => max(0, $this->stateInt($state, 'processed') - 1),
351 'valid_count' => $this->stateInt($state, 'valid'),
352 'invalid_count' => $this->stateInt($state, 'invalid'),
353 'overwritten_count' => $this->stateInt($state, 'overwritten'),
354 'issues' => $this->stateIssues($state),
355 'last_error' => $e->getMessage(),
356 'paused_at' => abj_clock()->now(),
357 ));
358 }
359 $this->logger->warn(sprintf(
360 'Import paused at row %d of %d due to: %s',
361 $dataRowsSeen + 1,
362 $dataRowsSeen + 1,
363 $e->getMessage()
364 ));
365 }
366
367 /**
368 * @param string $contentHash
369 * @param int $dataRowsSeen
370 * @param array<string, mixed> $state
371 * @return void
372 */
373 private function persistCheckpoint(string $contentHash, int $dataRowsSeen, array $state): void {
374 $this->progressStore->persistImportProgress($contentHash, array(
375 'rows_processed' => $dataRowsSeen,
376 'processed_count' => $this->stateInt($state, 'processed'),
377 'valid_count' => $this->stateInt($state, 'valid'),
378 'invalid_count' => $this->stateInt($state, 'invalid'),
379 'overwritten_count' => $this->stateInt($state, 'overwritten'),
380 'issues' => $this->stateIssues($state),
381 ));
382 }
383
384 /**
385 * @param array<string, mixed> $state
386 * @param bool $dryRun
387 * @param bool $overwriteExisting
388 * @return string
389 */
390 private function formatImportResult(array $state, bool $dryRun, bool $overwriteExisting): string {
391 $issues = $this->stateIssues($state);
392 if ($dryRun) {
393 $msg = sprintf(
394 __('Dry run complete. Valid redirects: %d. Invalid rows: %d. Total rows processed: %d.', '404-solution'),
395 $this->stateInt($state, 'valid'),
396 $this->stateInt($state, 'invalid'),
397 $this->stateInt($state, 'processed')
398 );
399 return count($issues) > 0
400 ? $msg . ' ' . __('Preview issues:', '404-solution') . ' ' . implode(", <BR/>\n", array_slice($issues, 0, 20))
401 : $msg;
402 }
403 if (count($issues) > 0) {
404 return __('Error:', '404-solution') . ' ' . implode(", <BR/>\n", $issues);
405 }
406 if ($overwriteExisting && $this->stateInt($state, 'overwritten') > 0) {
407 return sprintf(
408 __('The file seems to have loaded okay. %d existing redirect(s) were overwritten. Please check the redirects page.', '404-solution'),
409 $this->stateInt($state, 'overwritten')
410 );
411 }
412 return __('The file seems to have loaded okay. Please check the redirects page.', '404-solution');
413 }
414
415 /**
416 * @param array<string, mixed> $state
417 * @param string $key
418 * @return int
419 */
420 private function stateInt(array $state, string $key): int {
421 $value = $state[$key] ?? 0;
422 return is_numeric($value) ? (int)$value : 0;
423 }
424
425 /**
426 * @param array<string, mixed> $state
427 * @return array<int, string>
428 */
429 private function stateIssues(array $state): array {
430 $issues = $state['issues'] ?? array();
431 return is_array($issues) ? array_values(array_filter($issues, 'is_string')) : array();
432 }
433
434 /**
435 * @param array<string, mixed> $dataArray
436 * @param bool $dryRun
437 * @param bool $overwriteExisting
438 * @return array<int, string>
439 */
440 function loadDataArrayFromFile($dataArray, $dryRun = false, $overwriteExisting = false): array {
441 return $this->rowProcessor->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting);
442 }
443
444 /**
445 * @param mixed $line
446 * @return array<string, string>
447 */
448 function splitCsvLine($line): array {
449 return $this->parser->splitCsvLine($line);
450 }
451
452 /** @param array<int, string> $columns @return bool */
453 function isCompatibleImportHeaderRow($columns): bool {
454 return $this->parser->isCompatibleImportHeaderRow($columns);
455 }
456
457 /**
458 * @param array<int, string> $columns
459 * @return array<int, string|null>
460 */
461 function normalizeImportHeaders($columns): array {
462 return $this->parser->normalizeImportHeaders($columns);
463 }
464
465 /** @param array<int, string> $columns @return string */
466 function detectImportFormatFromHeaders($columns): string {
467 return $this->parser->detectImportFormatFromHeaders($columns);
468 }
469
470 /**
471 * @param array<int, string> $row
472 * @param array<int, string|null> $normalizedHeaders
473 * @return array<string, string>
474 */
475 function mapImportRowByHeaders($row, $normalizedHeaders): array {
476 return $this->parser->mapImportRowByHeaders($row, $normalizedHeaders);
477 }
478
479 /**
480 * @param array<int, string> $columns
481 * @return array<string, string>
482 */
483 function mapImportRowWithoutHeaders($columns): array {
484 return $this->parser->mapImportRowWithoutHeaders($columns);
485 }
486
487 /** @param resource $fileHandle @return string */
488 function detectCsvDelimiterFromFile($fileHandle): string {
489 return $this->parser->detectCsvDelimiterFromFile($fileHandle);
490 }
491 }
492