PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/import/ImportService.php

497 lines 18.4 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 = ABJ_404_Solution_RequestInputNormalizer::readText(
94 $_POST, array('name' => 'dry_run')) === '1';
95 $overwriteExisting = ABJ_404_Solution_RequestInputNormalizer::readText(
96 $_POST, array('name' => 'overwrite_existing')) === '1';
97
98 $validationError = $this->uploadValidator->validate($uploadFile);
99 if ($validationError !== '') {
100 return $validationError;
101 }
102
103 $tmpName = $this->uploadValidator->tmpName($uploadFile);
104 $file_handle = fopen($tmpName, 'r');
105 if (!$file_handle) {
106 return __('Error opening the file.', '404-solution');
107 }
108
109 try {
110 $hashResult = hash_file('sha256', $tmpName);
111 $contentHash = ($dryRun || !is_string($hashResult)) ? '' : $hashResult;
112 $runState = $this->applyResumeProgress($contentHash, $dryRun, $this->emptyRunState());
113
114 $delimiter = $this->parser->detectCsvDelimiterFromFile($file_handle);
115 rewind($file_handle);
116 $runState = $this->processFileRows(
117 $file_handle,
118 $delimiter,
119 $this->stateInt($runState, 'resume_from'),
120 $dryRun,
121 $overwriteExisting,
122 $contentHash,
123 $runState
124 );
125 } finally {
126 fclose($file_handle);
127 }
128
129 if (isset($runState['abort_message']) && is_string($runState['abort_message'])) {
130 return $runState['abort_message'];
131 }
132
133 if (!$dryRun) {
134 $this->progressStore->clearImportProgress();
135 }
136
137 return $this->formatImportResult($runState, $dryRun, $overwriteExisting);
138 }
139
140 /**
141 * @return array<string, mixed>
142 */
143 private function emptyRunState(): array {
144 return array(
145 'issues' => array(),
146 'processed' => 0,
147 'valid' => 0,
148 'invalid' => 0,
149 'overwritten' => 0,
150 'resume_from' => 0,
151 );
152 }
153
154 /**
155 * @param string $contentHash
156 * @param bool $dryRun
157 * @param array<string, mixed> $state
158 * @return array<string, mixed>
159 */
160 private function applyResumeProgress(string $contentHash, bool $dryRun, array $state): array {
161 if ($dryRun) {
162 return $state;
163 }
164
165 $existingProgress = $this->progressStore->getResumeProgress($contentHash);
166 if ($existingProgress === null) {
167 return $state;
168 }
169
170 $resumeFrom = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'rows_processed', 0);
171 $state['resume_from'] = $resumeFrom;
172 $state['processed'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'processed_count', $resumeFrom);
173 $state['valid'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'valid_count', 0);
174 $state['invalid'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'invalid_count', 0);
175 $state['overwritten'] = ABJ_404_Solution_ImportProgressStore::progressInt($existingProgress, 'overwritten_count', 0);
176 if (isset($existingProgress['issues']) && is_array($existingProgress['issues'])) {
177 /** @var array<int, string> $persistedIssues */
178 $persistedIssues = $existingProgress['issues'];
179 $state['issues'] = $persistedIssues;
180 }
181 return $state;
182 }
183
184 /**
185 * @param resource $fileHandle
186 * @param string $delimiter
187 * @param int $resumeFromDataRow
188 * @param bool $dryRun
189 * @param bool $overwriteExisting
190 * @param string $contentHash
191 * @param array<string, mixed> $state
192 * @return array<string, mixed>
193 */
194 private function processFileRows($fileHandle, string $delimiter, int $resumeFromDataRow,
195 bool $dryRun, bool $overwriteExisting, string $contentHash,
196 array $state): array {
197 $headerColumns = null;
198 $dataRowsSeen = 0;
199 while (($row = fgetcsv($fileHandle, 0, $delimiter, '"', '\\')) !== false) {
200 $data = $this->normalizeCsvRow($row);
201
202 if ($this->isBlankCsvRow($data)) {
203 continue;
204 }
205
206 if ($headerColumns === null && $this->parser->isCompatibleImportHeaderRow($data)) {
207 $headerColumns = $this->parser->normalizeImportHeaders($data);
208 continue;
209 }
210
211 if ($dataRowsSeen < $resumeFromDataRow) {
212 $dataRowsSeen++;
213 continue;
214 }
215
216 $dataArray = $this->mapCsvDataRow($data, $headerColumns);
217 if (isset($dataArray['error'])) {
218 $state['abort_message'] = $dataArray['error'];
219 return $state;
220 }
221
222 if ($this->isRepeatedHeaderDataRow($dataArray)) {
223 $dataRowsSeen++;
224 continue;
225 }
226
227 $state = $this->processDataArray(
228 $dataArray, $dataRowsSeen, $dryRun, $overwriteExisting, $contentHash, $state
229 );
230 $dataRowsSeen++;
231
232 if (!$dryRun && ($dataRowsSeen % self::IMPORT_PROGRESS_CHECKPOINT_INTERVAL) === 0) {
233 $this->persistCheckpoint($contentHash, $dataRowsSeen, $state);
234 }
235 if (isset($state['abort_message'])) {
236 return $state;
237 }
238 }
239
240 return $state;
241 }
242
243 /**
244 * @param array<int, string|null>|false|null $row
245 * @return array<int, string>
246 */
247 private function normalizeCsvRow($row): array {
248 if (!is_array($row)) {
249 return array();
250 }
251 return array_map(function($v) {
252 return trim((string)$v);
253 }, $row);
254 }
255
256 /** @param array<int, string> $data @return bool */
257 private function isBlankCsvRow(array $data): bool {
258 return count($data) === 1 && $data[0] === '';
259 }
260
261 /**
262 * @param array<int, string> $data
263 * @param array<int, string|null>|null $headerColumns
264 * @return array<string, string>
265 */
266 private function mapCsvDataRow(array $data, $headerColumns): array {
267 return $headerColumns !== null
268 ? $this->parser->mapImportRowByHeaders($data, $headerColumns)
269 : $this->parser->mapImportRowWithoutHeaders($data);
270 }
271
272 /**
273 * @param array<string, string> $dataArray
274 * @return bool
275 */
276 private function isRepeatedHeaderDataRow(array $dataArray): bool {
277 return isset($dataArray['from_url']) &&
278 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request');
279 }
280
281 /**
282 * @param array<string, string> $dataArray
283 * @param int $dataRowsSeen
284 * @param bool $dryRun
285 * @param bool $overwriteExisting
286 * @param string $contentHash
287 * @param array<string, mixed> $state
288 * @return array<string, mixed>
289 */
290 private function processDataArray(array $dataArray, int $dataRowsSeen, bool $dryRun,
291 bool $overwriteExisting, string $contentHash,
292 array $state): array {
293 try {
294 $state['processed'] = $this->stateInt($state, 'processed') + 1;
295 $wasOverwrite = $this->wouldOverwriteExisting($dataArray, $overwriteExisting);
296 $dataArray['__line_number'] = (string)($dataRowsSeen + 1);
297 $issues = $this->rowProcessor->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting);
298 return $this->accountForRowIssues($state, $issues, $wasOverwrite);
299 } catch (\Throwable $e) {
300 $this->recordPausedImport($contentHash, $dataRowsSeen, $state, $dryRun, $e);
301 $state['abort_message'] = sprintf(
302 __('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'),
303 $dataRowsSeen + 1,
304 $this->stateInt($state, 'valid')
305 );
306 return $state;
307 }
308 }
309
310 /**
311 * @param array<string, string> $dataArray
312 * @param bool $overwriteExisting
313 * @return bool
314 */
315 private function wouldOverwriteExisting(array $dataArray, bool $overwriteExisting): bool {
316 if (!$overwriteExisting || !isset($dataArray['from_url']) || !is_string($dataArray['from_url'])) {
317 return false;
318 }
319 $existing = $this->redirectsRepository->getExistingRedirectForURL($dataArray['from_url']);
320 return is_array($existing) && isset($existing['id']) && is_numeric($existing['id']) && (int)$existing['id'] !== 0;
321 }
322
323 /**
324 * @param array<string, mixed> $state
325 * @param array<int, string> $issues
326 * @param bool $wasOverwrite
327 * @return array<string, mixed>
328 */
329 private function accountForRowIssues(array $state, array $issues, bool $wasOverwrite): array {
330 if (count($issues) > 0) {
331 $state['invalid'] = $this->stateInt($state, 'invalid') + 1;
332 } else {
333 $state['valid'] = $this->stateInt($state, 'valid') + 1;
334 if ($wasOverwrite) {
335 $state['overwritten'] = $this->stateInt($state, 'overwritten') + 1;
336 }
337 }
338 $state['issues'] = array_merge($this->stateIssues($state), $issues);
339 return $state;
340 }
341
342 /**
343 * @param string $contentHash
344 * @param int $dataRowsSeen
345 * @param array<string, mixed> $state
346 * @param bool $dryRun
347 * @param Throwable $e
348 * @return void
349 */
350 private function recordPausedImport(string $contentHash, int $dataRowsSeen, array $state,
351 bool $dryRun, \Throwable $e): void {
352 if (!$dryRun) {
353 $this->progressStore->persistImportProgress($contentHash, array(
354 'rows_processed' => $dataRowsSeen,
355 'processed_count' => max(0, $this->stateInt($state, 'processed') - 1),
356 'valid_count' => $this->stateInt($state, 'valid'),
357 'invalid_count' => $this->stateInt($state, 'invalid'),
358 'overwritten_count' => $this->stateInt($state, 'overwritten'),
359 'issues' => $this->stateIssues($state),
360 'last_error' => $e->getMessage(),
361 'paused_at' => abj_clock()->now(),
362 ));
363 }
364 $this->logger->warn(sprintf(
365 'Import paused at row %d of %d due to: %s',
366 $dataRowsSeen + 1,
367 $dataRowsSeen + 1,
368 $e->getMessage()
369 ));
370 }
371
372 /**
373 * @param string $contentHash
374 * @param int $dataRowsSeen
375 * @param array<string, mixed> $state
376 * @return void
377 */
378 private function persistCheckpoint(string $contentHash, int $dataRowsSeen, array $state): void {
379 $this->progressStore->persistImportProgress($contentHash, array(
380 'rows_processed' => $dataRowsSeen,
381 'processed_count' => $this->stateInt($state, 'processed'),
382 'valid_count' => $this->stateInt($state, 'valid'),
383 'invalid_count' => $this->stateInt($state, 'invalid'),
384 'overwritten_count' => $this->stateInt($state, 'overwritten'),
385 'issues' => $this->stateIssues($state),
386 ));
387 }
388
389 /**
390 * @param array<string, mixed> $state
391 * @param bool $dryRun
392 * @param bool $overwriteExisting
393 * @return string
394 */
395 private function formatImportResult(array $state, bool $dryRun, bool $overwriteExisting): string {
396 $issues = $this->stateIssues($state);
397 if ($dryRun) {
398 $msg = sprintf(
399 __('Dry run complete. Valid redirects: %d. Invalid rows: %d. Total rows processed: %d.', '404-solution'),
400 $this->stateInt($state, 'valid'),
401 $this->stateInt($state, 'invalid'),
402 $this->stateInt($state, 'processed')
403 );
404 return count($issues) > 0
405 ? $msg . ' ' . __('Preview issues:', '404-solution') . ' ' . implode(", <BR/>\n", array_slice($issues, 0, 20))
406 : $msg;
407 }
408 if (count($issues) > 0) {
409 return __('Error:', '404-solution') . ' ' . implode(", <BR/>\n", $issues);
410 }
411 if ($overwriteExisting && $this->stateInt($state, 'overwritten') > 0) {
412 return sprintf(
413 __('The file seems to have loaded okay. %d existing redirect(s) were overwritten. Please check the redirects page.', '404-solution'),
414 $this->stateInt($state, 'overwritten')
415 );
416 }
417 return __('The file seems to have loaded okay. Please check the redirects page.', '404-solution');
418 }
419
420 /**
421 * @param array<string, mixed> $state
422 * @param string $key
423 * @return int
424 */
425 private function stateInt(array $state, string $key): int {
426 $value = $state[$key] ?? 0;
427 return is_numeric($value) ? (int)$value : 0;
428 }
429
430 /**
431 * @param array<string, mixed> $state
432 * @return array<int, string>
433 */
434 private function stateIssues(array $state): array {
435 $issues = $state['issues'] ?? array();
436 return is_array($issues) ? array_values(array_filter($issues, 'is_string')) : array();
437 }
438
439 /**
440 * @param array<string, mixed> $dataArray
441 * @param bool $dryRun
442 * @param bool $overwriteExisting
443 * @return array<int, string>
444 */
445 function loadDataArrayFromFile($dataArray, $dryRun = false, $overwriteExisting = false): array {
446 return $this->rowProcessor->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting);
447 }
448
449 /**
450 * @param mixed $line
451 * @return array<string, string>
452 */
453 function splitCsvLine($line): array {
454 return $this->parser->splitCsvLine($line);
455 }
456
457 /** @param array<int, string> $columns @return bool */
458 function isCompatibleImportHeaderRow($columns): bool {
459 return $this->parser->isCompatibleImportHeaderRow($columns);
460 }
461
462 /**
463 * @param array<int, string> $columns
464 * @return array<int, string|null>
465 */
466 function normalizeImportHeaders($columns): array {
467 return $this->parser->normalizeImportHeaders($columns);
468 }
469
470 /** @param array<int, string> $columns @return string */
471 function detectImportFormatFromHeaders($columns): string {
472 return $this->parser->detectImportFormatFromHeaders($columns);
473 }
474
475 /**
476 * @param array<int, string> $row
477 * @param array<int, string|null> $normalizedHeaders
478 * @return array<string, string>
479 */
480 function mapImportRowByHeaders($row, $normalizedHeaders): array {
481 return $this->parser->mapImportRowByHeaders($row, $normalizedHeaders);
482 }
483
484 /**
485 * @param array<int, string> $columns
486 * @return array<string, string>
487 */
488 function mapImportRowWithoutHeaders($columns): array {
489 return $this->parser->mapImportRowWithoutHeaders($columns);
490 }
491
492 /** @param resource $fileHandle @return string */
493 function detectCsvDelimiterFromFile($fileHandle): string {
494 return $this->parser->detectCsvDelimiterFromFile($fileHandle);
495 }
496 }
497