PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / ImportExportService.php

ImportExportService.php in 404 Solution 4.2.0, at includes/ImportExportService.php

1,254 lines 51.0 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 * Handles redirect CSV import/export behavior.
9 *
10 * Kept intentionally focused so PluginLogic stays readable.
11 */
12 class ABJ_404_Solution_ImportExportService {
13
14 /**
15 * Option key that stores resumable-import progress. Keyed by sha256
16 * content hash of the uploaded CSV so a different file (or a different
17 * version of the same file) does not falsely resume from stale state.
18 * See `doImportFile()` and the timeout-resume contract in
19 * tests/ImportExportTimeoutResumeTest.php.
20 */
21 const IMPORT_PROGRESS_OPTION = 'abj404_import_progress';
22
23 /**
24 * Persist progress every N data rows so a hard PHP timeout (where no
25 * exception can be caught) still leaves a usable checkpoint. Trade-off:
26 * higher N is fewer option-writes but loses more rows on hard kill; lower
27 * N writes more but keeps the resume window tight. 50 keeps writes
28 * around once per second at typical row-processing rates.
29 */
30 const IMPORT_PROGRESS_CHECKPOINT_INTERVAL = 50;
31
32 /** @var ABJ_404_Solution_ViewReadServiceInterface */
33 private $viewReadService;
34
35 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
36 private $redirectsRepository;
37
38 /** @var ABJ_404_Solution_ContentRepositoryInterface */
39 private $contentRepository;
40
41 /** @var ABJ_404_Solution_Logging */
42 private $logger;
43
44 /**
45 * Constructor supports two signatures for backward compatibility:
46 * (1) New: (ViewReadService, RedirectsRepository, ContentRepository, Logging)
47 * (2) Legacy: (DataAccess, Logging) -- DataAccess delegates to modules
48 *
49 * @param mixed $viewReadServiceOrDataAccess ViewReadService or legacy DataAccess facade
50 * @param mixed $redirectsRepoOrLogging RedirectsRepository or legacy Logging
51 * @param ABJ_404_Solution_ContentRepositoryInterface|null $contentRepository
52 * @param ABJ_404_Solution_Logging|null $logging
53 */
54 function __construct($viewReadServiceOrDataAccess, $redirectsRepoOrLogging, $contentRepository = null, $logging = null) {
55 if ($contentRepository === null && $logging === null) {
56 // Legacy 2-arg signature: (DataAccess, Logging)
57 // DataAccess is a facade that delegates to the modules, so
58 // reuse the same object for all three module interfaces.
59 /** @var ABJ_404_Solution_ViewReadServiceInterface&ABJ_404_Solution_RedirectsRepositoryInterface&ABJ_404_Solution_ContentRepositoryInterface $viewReadServiceOrDataAccess */
60 $this->viewReadService = $viewReadServiceOrDataAccess;
61 $this->redirectsRepository = $viewReadServiceOrDataAccess;
62 $this->contentRepository = $viewReadServiceOrDataAccess;
63 /** @var ABJ_404_Solution_Logging $redirectsRepoOrLogging */
64 $this->logger = $redirectsRepoOrLogging;
65 } else {
66 // New 4-arg signature
67 /** @var ABJ_404_Solution_ViewReadServiceInterface $viewReadServiceOrDataAccess */
68 $this->viewReadService = $viewReadServiceOrDataAccess;
69 /** @var ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepoOrLogging */
70 $this->redirectsRepository = $redirectsRepoOrLogging;
71 /** @var ABJ_404_Solution_ContentRepositoryInterface $contentRepository */
72 $this->contentRepository = $contentRepository;
73 /** @var ABJ_404_Solution_Logging $logging */
74 $this->logger = $logging;
75 }
76 }
77
78 /**
79 * @param string $format
80 * @return string
81 */
82 function getExportFilename($format = 'native') {
83 if ($format === 'redirection') {
84 return abj404_getUploadsDir() . 'export-redirection.csv';
85 }
86 return abj404_getUploadsDir() . 'export.csv';
87 }
88
89 /** @return void */
90 function doExport() {
91 $format = isset($_REQUEST['export_format']) ? sanitize_text_field((string)$_REQUEST['export_format']) : 'native';
92
93 $serverFormats = array('htaccess', 'nginx', 'cloudflare', 'netlify', 'vercel');
94 if (in_array($format, $serverFormats, true)) {
95 $this->doServerFormatExport($format);
96 return;
97 }
98
99 $tempFile = $this->getExportFilename($format);
100
101 if ($format === 'redirection') {
102 $nativeExportFile = $this->getExportFilename('native');
103 $this->viewReadService->doRedirectsExport($nativeExportFile);
104 $error = $this->convertExportCsvToRedirectionFormat($nativeExportFile, $tempFile);
105 if ($error !== '') {
106 $this->logger->warn($error);
107 return;
108 }
109 } else {
110 $this->viewReadService->doRedirectsExport($tempFile);
111 }
112
113 if (file_exists($tempFile)) {
114 header('Content-Description: File Transfer');
115 header('Content-Disposition: attachment; filename=' . basename($tempFile));
116 header('Expires: 0');
117 header('Cache-Control: must-revalidate');
118 header('Pragma: public');
119 header('Content-Length: ' . filesize($tempFile));
120 header('Content-Type: text/csv; charset=utf-8');
121 readfile($tempFile);
122 exit();
123 }
124
125 $this->logger->infoMessage("I don't see any data to export.");
126 }
127
128 /**
129 * Serve a server-level or edge/CDN format export directly (no temp file needed).
130 *
131 * @param string $format One of: htaccess, nginx, cloudflare, netlify, vercel.
132 * @return void
133 */
134 private function doServerFormatExport($format) {
135 switch ($format) {
136 case 'htaccess':
137 $content = $this->generateHtaccessRules();
138 $filename = 'redirects.htaccess';
139 $mime = 'text/plain; charset=utf-8';
140 break;
141 case 'nginx':
142 $content = $this->generateNginxRules();
143 $filename = 'redirects-nginx.conf';
144 $mime = 'text/plain; charset=utf-8';
145 break;
146 case 'cloudflare':
147 $content = $this->generateCloudflareWorkerScript();
148 $filename = 'redirects-worker.js';
149 $mime = 'application/javascript; charset=utf-8';
150 break;
151 case 'netlify':
152 $content = $this->generateNetlifyRedirects();
153 $filename = '_redirects';
154 $mime = 'text/plain; charset=utf-8';
155 break;
156 case 'vercel':
157 $content = $this->generateVercelRedirects();
158 $filename = 'vercel-redirects.json';
159 $mime = 'application/json; charset=utf-8';
160 break;
161 default:
162 $this->logger->warn('Unknown server export format: ' . $format);
163 return;
164 }
165
166 header('Content-Description: File Transfer');
167 header('Content-Disposition: attachment; filename=' . $filename);
168 header('Expires: 0');
169 header('Cache-Control: must-revalidate');
170 header('Pragma: public');
171 header('Content-Length: ' . strlen($content));
172 header('Content-Type: ' . $mime);
173 echo $content;
174 exit();
175 }
176
177 /**
178 * Fetch all exportable (manual + regex, non-trashed) redirects and resolve
179 * destination URLs.
180 *
181 * Each returned element has:
182 * source string The from-URL stored in the DB (relative path or full URL).
183 * dest string Resolved destination URL or path.
184 * code int HTTP status code (301, 302, 410, …).
185 * is_regex bool Whether this is a regex redirect.
186 *
187 * @return array<int, array{source: string, dest: string, code: int, is_regex: bool}>
188 */
189 function getExportableRedirects() {
190 $dbCore = abj_service('db_core');
191 $redirectsTable = $dbCore->doTableNameReplacements('{wp_abj404_redirects}');
192 $cacheTable = $dbCore->doTableNameReplacements('{wp_abj404_permalink_cache}');
193
194 $manualStatus = defined('ABJ404_STATUS_MANUAL') ? (int)ABJ404_STATUS_MANUAL : 1;
195 $regexStatus = defined('ABJ404_STATUS_REGEX') ? (int)ABJ404_STATUS_REGEX : 6;
196 $typeExternal = defined('ABJ404_TYPE_EXTERNAL') ? (int)ABJ404_TYPE_EXTERNAL : 4;
197 $typeHome = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5;
198
199 $queryResult = $dbCore->queryAndGetResults(
200 "SELECT r.url, r.status, r.type, r.final_dest, r.code, r.disabled,
201 pc.url AS cached_url
202 FROM {$redirectsTable} r
203 LEFT JOIN {$cacheTable} pc ON r.final_dest = pc.id
204 WHERE r.status IN (%d, %d)
205 AND (r.disabled IS NULL OR r.disabled = 0)
206 AND r.url IS NOT NULL AND r.url != ''
207 ORDER BY r.url",
208 ['query_params' => [$manualStatus, $regexStatus]]
209 );
210
211 $rows = $queryResult['rows'] ?? [];
212 if (!is_array($rows) || empty($rows)) {
213 return array();
214 }
215
216 $result = array();
217 foreach ($rows as $row) {
218 if (!is_array($row)) {
219 continue;
220 }
221
222 $source = isset($row['url']) ? (string)$row['url'] : '';
223 $isRegex = (isset($row['status']) && (int)$row['status'] === $regexStatus);
224 $code = isset($row['code']) ? (int)$row['code'] : 301;
225 $type = isset($row['type']) ? (int)$row['type'] : 0;
226 $finalDest = isset($row['final_dest']) ? (string)$row['final_dest'] : '';
227
228 // Resolve destination
229 if ($code === 410 || $code === 451) {
230 $dest = $source;
231 } elseif (!empty($row['cached_url'])) {
232 $dest = (string)$row['cached_url'];
233 } elseif ($type === $typeExternal) {
234 $dest = $finalDest;
235 } elseif ($type === $typeHome) {
236 $dest = function_exists('home_url') ? home_url('/') : '/';
237 } elseif (is_numeric($finalDest) && (int)$finalDest > 0) {
238 // Post/page/term ID — try get_permalink
239 if (function_exists('get_permalink')) {
240 $url = get_permalink((int)$finalDest);
241 $dest = ($url !== false && is_string($url)) ? $url : ('/?p=' . $finalDest);
242 } else {
243 $dest = '/?p=' . $finalDest;
244 }
245 } elseif ($finalDest !== '') {
246 $dest = $finalDest;
247 } else {
248 // No destination — skip
249 continue;
250 }
251
252 $result[] = array(
253 'source' => $source,
254 'dest' => $dest,
255 'code' => $code,
256 'is_regex' => $isRegex,
257 );
258 }
259
260 return $result;
261 }
262
263 /**
264 * Generate Apache .htaccess redirect rules.
265 *
266 * @return string
267 */
268 function generateHtaccessRules() {
269 $redirects = $this->getExportableRedirects();
270 $lines = array('# 404 Solution redirects', 'RewriteEngine On', '');
271
272 foreach ($redirects as $r) {
273 $source = $r['source'];
274 $dest = $r['dest'];
275 $code = $r['code'];
276
277 // Strip leading slash for RewriteRule pattern (anchored with ^)
278 $pattern = ltrim($source, '/');
279
280 if (!$r['is_regex']) {
281 // Escape regex metacharacters in literal paths
282 $pattern = preg_quote($pattern, '/');
283 $pattern = $pattern . '/?';
284 }
285
286 if ($code === 410 || $code === 451) {
287 // Apache [G] flag sends a 410 Gone response; it is the closest equivalent for 451.
288 $lines[] = 'RewriteRule ^' . $pattern . '$ - [G,L]';
289 } elseif ($code === 0) {
290 // Meta Refresh requires serving an HTML response — not supported in .htaccess.
291 $lines[] = '# Meta Refresh: ' . $source . ' → ' . $dest . ' (serve HTML; not representable as a RewriteRule)';
292 } else {
293 $flag = ($code === 301) ? 'R=301' : 'R=' . $code;
294 $lines[] = 'RewriteRule ^' . $pattern . '$ ' . $dest . ' [' . $flag . ',L]';
295 }
296 }
297
298 if (count($redirects) === 0) {
299 $lines[] = '# No manual redirects found.';
300 }
301
302 return implode("\n", $lines) . "\n";
303 }
304
305 /**
306 * Generate Nginx location block redirect rules.
307 *
308 * @return string
309 */
310 function generateNginxRules() {
311 $redirects = $this->getExportableRedirects();
312 $lines = array('# 404 Solution redirects', '');
313
314 foreach ($redirects as $r) {
315 $source = $r['source'];
316 $dest = $r['dest'];
317 $code = $r['code'];
318
319 if ($r['is_regex']) {
320 $directive = 'location ~* ' . $source;
321 } else {
322 $directive = 'location = ' . $source;
323 }
324
325 if ($code === 410 || $code === 451) {
326 $lines[] = $directive . ' { return ' . $code . '; }';
327 } elseif ($code === 0) {
328 // Meta Refresh requires serving an HTML response — not representable as a return directive.
329 $lines[] = '# Meta Refresh: ' . $source . ' → ' . $dest . ' (serve HTML; not representable as a return directive)';
330 } else {
331 $lines[] = $directive . ' { return ' . $code . ' ' . $dest . '; }';
332 }
333 }
334
335 if (count($redirects) === 0) {
336 $lines[] = '# No manual redirects found.';
337 }
338
339 return implode("\n", $lines) . "\n";
340 }
341
342 /**
343 * Generate a Cloudflare Workers JavaScript snippet for handling redirects.
344 *
345 * @return string
346 */
347 function generateCloudflareWorkerScript() {
348 $redirects = $this->getExportableRedirects();
349
350 $entries = array();
351 foreach ($redirects as $r) {
352 $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
353 $sourceJson = json_encode($r['source'], $jsonFlags);
354 $destJson = json_encode($r['dest'], $jsonFlags);
355 // Fall back to UTF-8 sanitization if json_encode fails on invalid bytes
356 if ($sourceJson === false) {
357 $sourceJson = json_encode(mb_convert_encoding($r['source'], 'UTF-8', 'UTF-8'), $jsonFlags);
358 }
359 if ($destJson === false) {
360 $destJson = json_encode(mb_convert_encoding($r['dest'], 'UTF-8', 'UTF-8'), $jsonFlags);
361 }
362 if ($sourceJson === false || $destJson === false) {
363 continue;
364 }
365 $code = (int)$r['code'];
366 $entries[] = " " . $sourceJson . ": { dest: " . $destJson . ", status: " . $code . " }";
367 }
368
369 $map = implode(",\n", $entries);
370
371 $script = "const REDIRECTS = {\n";
372 $script .= ($map !== '' ? $map . "\n" : '');
373 $script .= "};\n";
374 $script .= "\n";
375 $script .= "addEventListener('fetch', event => {\n";
376 $script .= " event.respondWith(handleRequest(event.request));\n";
377 $script .= "});\n";
378 $script .= "\n";
379 $script .= "async function handleRequest(request) {\n";
380 $script .= " const url = new URL(request.url);\n";
381 $script .= " const rule = REDIRECTS[url.pathname] || REDIRECTS[url.pathname.replace(/\\/$/, '')];\n";
382 $script .= " if (rule) {\n";
383 $script .= " if (rule.status === 410 || rule.status === 451) return new Response(null, { status: rule.status });\n";
384 $script .= " if (rule.status === 0) return new Response('<meta http-equiv=\"refresh\" content=\"0;url=' + rule.dest + '\">', { status: 200, headers: { 'Content-Type': 'text/html' } });\n";
385 $script .= " return Response.redirect(rule.dest.startsWith('http') ? rule.dest : url.origin + rule.dest, rule.status);\n";
386 $script .= " }\n";
387 $script .= " return fetch(request);\n";
388 $script .= "}\n";
389
390 return $script;
391 }
392
393 /**
394 * Generate a Netlify _redirects file.
395 *
396 * @return string
397 */
398 function generateNetlifyRedirects() {
399 $redirects = $this->getExportableRedirects();
400 $lines = array('# 404 Solution redirects');
401
402 foreach ($redirects as $r) {
403 $source = $r['source'];
404 $dest = $r['dest'];
405 $code = (int)$r['code'];
406 $lines[] = $source . ' ' . $dest . ' ' . $code;
407 }
408
409 if (count($redirects) === 0) {
410 $lines[] = '# No manual redirects found.';
411 }
412
413 return implode("\n", $lines) . "\n";
414 }
415
416 /**
417 * Generate a Vercel redirects JSON array (for use in vercel.json).
418 *
419 * Note: Vercel does not natively support 410 Gone responses; those redirects
420 * are omitted from this export.
421 *
422 * @return string JSON array string.
423 */
424 function generateVercelRedirects() {
425 $redirects = $this->getExportableRedirects();
426 $entries = array();
427
428 foreach ($redirects as $r) {
429 if ($r['code'] === 410 || $r['code'] === 451 || $r['code'] === 0) {
430 // Vercel has no native 410/451/meta-refresh support; skip.
431 continue;
432 }
433 $entries[] = array(
434 'source' => $r['source'],
435 'destination' => $r['dest'],
436 'permanent' => ($r['code'] === 301),
437 );
438 }
439
440 $encoded = json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
441 return is_string($encoded) ? $encoded : '[]';
442 }
443
444 /**
445 * Convert native export format to a Redirection-compatible CSV shape.
446 *
447 * @param string $sourceFile Native export file path.
448 * @param string $destinationFile Output file path.
449 * @return string Empty string on success, error message otherwise.
450 */
451 function convertExportCsvToRedirectionFormat($sourceFile, $destinationFile) {
452 if (!file_exists($sourceFile)) {
453 return __('Error: Native export file does not exist.', '404-solution');
454 }
455
456 $in = fopen($sourceFile, 'r');
457 if ($in === false) {
458 return __('Error: Could not read native export file.', '404-solution');
459 }
460
461 $out = fopen($destinationFile, 'w');
462 if ($out === false) {
463 fclose($in);
464 return __('Error: Could not create Redirection export file.', '404-solution');
465 }
466
467 fputcsv($out, array('source', 'target', 'regex', 'code'), ',', '"', '\\');
468 fgetcsv($in, 0, ',', '"', '\\');
469 while (($row = fgetcsv($in, 0, ',', '"', '\\')) !== false) {
470 if (!is_array($row) || count($row) < 4) {
471 continue;
472 }
473 $from = trim((string)$row[0]);
474 $status = trim((string)$row[1]);
475 $to = trim((string)$row[3]);
476 if ($from === '' || $to === '') {
477 continue;
478 }
479
480 $regexFlag = (strtolower($status) === 'regex') ? '1' : '0';
481 $code = isset($row[6]) ? trim((string)$row[6]) : '301';
482 if ($code === '' || !is_numeric($code)) {
483 $code = '301';
484 }
485 fputcsv($out, array($from, $to, $regexFlag, $code), ',', '"', '\\');
486 }
487
488 fclose($in);
489 fclose($out);
490 return '';
491 }
492
493
494 /**
495 * Validate the uploaded import file (extension, size, MIME type).
496 *
497 * @return string Empty on success, error message on failure.
498 */
499 private function validateImportFile(): string {
500 $allowed_extensions = array('csv', 'txt');
501 $file_ext = strtolower(pathinfo($_FILES['import_file']['name'], PATHINFO_EXTENSION));
502 if (!in_array($file_ext, $allowed_extensions)) {
503 return __('Error: Invalid file type. Only CSV/TXT files are allowed.', '404-solution');
504 }
505
506 $max_file_size = 5 * 1024 * 1024;
507 if ($_FILES['import_file']['size'] > $max_file_size) {
508 return __('Error: File too large. Maximum size is 5MB.', '404-solution');
509 }
510
511 $allowed_mime_types = array('text/csv', 'text/plain', 'application/csv', 'text/comma-separated-values', 'application/vnd.ms-excel');
512 $finfo = finfo_open(FILEINFO_MIME_TYPE);
513 if ($finfo === false) {
514 return __('Error: Unable to determine file type.', '404-solution');
515 }
516 $mime_type = finfo_file($finfo, $_FILES['import_file']['tmp_name']);
517 if (!in_array($mime_type, $allowed_mime_types)) {
518 return __('Error: Invalid file type. Only CSV files are allowed.', '404-solution');
519 }
520
521 return '';
522 }
523 /**
524 * Expected formats:
525 * - from_url,status,type,to_url,wp_type
526 * - from_url,to_url
527 *
528 * @return string
529 */
530 function doImportFile() {
531 $anyIssuesToNote = array();
532 if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] != UPLOAD_ERR_OK) {
533 return __('File upload error.', '404-solution');
534 }
535
536 $dryRun = isset($_POST['dry_run']) && sanitize_text_field((string)$_POST['dry_run']) === '1';
537 $overwriteExisting = isset($_POST['overwrite_existing']) && sanitize_text_field((string)$_POST['overwrite_existing']) === '1';
538 $processedRows = 0;
539 $validRows = 0;
540 $invalidRows = 0;
541 $overwrittenRows = 0;
542
543 $validationError = $this->validateImportFile();
544 if ($validationError !== '') {
545 return $validationError;
546 }
547
548 $file_handle = fopen($_FILES['import_file']['tmp_name'], 'r');
549 if (!$file_handle) {
550 return __('Error opening the file.', '404-solution');
551 }
552
553 // Resume support: content-hash-keyed checkpoint. If a prior import
554 // of the SAME file content (same sha256) paused mid-stream, pick up
555 // at the recorded row count instead of restarting from row 1.
556 // Dry runs never write to the DB, so they never resume / persist.
557 $hashResult = hash_file('sha256', $_FILES['import_file']['tmp_name']);
558 $contentHash = ($dryRun || !is_string($hashResult)) ? '' : $hashResult;
559 $resumeFromDataRow = 0;
560 if (!$dryRun) {
561 $existingProgress = $this->getResumeProgress($contentHash);
562 if ($existingProgress !== null) {
563 $resumeFromDataRow = self::progressInt($existingProgress, 'rows_processed', 0);
564 $processedRows = self::progressInt($existingProgress, 'processed_count', $resumeFromDataRow);
565 $validRows = self::progressInt($existingProgress, 'valid_count', 0);
566 $invalidRows = self::progressInt($existingProgress, 'invalid_count', 0);
567 $overwrittenRows = self::progressInt($existingProgress, 'overwritten_count', 0);
568 if (isset($existingProgress['issues']) && is_array($existingProgress['issues'])) {
569 /** @var array<int, string> $persistedIssues */
570 $persistedIssues = $existingProgress['issues'];
571 $anyIssuesToNote = $persistedIssues;
572 }
573 }
574 }
575
576 $delimiter = $this->detectCsvDelimiterFromFile($file_handle);
577 rewind($file_handle);
578
579 $headerColumns = null;
580 $dataRowsSeen = 0;
581 while (($row = fgetcsv($file_handle, 0, $delimiter, '"', '\\')) !== false) {
582 $data = array_map(function($v) {
583 return trim((string)$v);
584 }, $row);
585
586 if (count($data) === 1 && $data[0] === '') {
587 continue;
588 }
589
590 if ($headerColumns === null && $this->isCompatibleImportHeaderRow($data)) {
591 $headerColumns = $this->normalizeImportHeaders($data);
592 continue;
593 }
594
595 // Resume: skip data rows already processed in a prior (paused) run.
596 // The header has just been parsed above, so the skip applies only
597 // to data rows (the unit the resume counter is keyed on).
598 if ($dataRowsSeen < $resumeFromDataRow) {
599 $dataRowsSeen++;
600 continue;
601 }
602
603 if ($headerColumns !== null) {
604 $dataArray = $this->mapImportRowByHeaders($data, $headerColumns);
605 } else {
606 $dataArray = $this->mapImportRowWithoutHeaders($data);
607 }
608
609 if (isset($dataArray['error'])) {
610 fclose($file_handle);
611 return $dataArray['error'];
612 }
613
614 if (isset($dataArray['from_url']) &&
615 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request')) {
616 $dataRowsSeen++;
617 continue;
618 }
619
620 try {
621 $processedRows++;
622 $wasOverwrite = false;
623 if ($overwriteExisting && isset($dataArray['from_url']) && is_string($dataArray['from_url'])) {
624 $existing = $this->redirectsRepository->getExistingRedirectForURL($dataArray['from_url']);
625 $wasOverwrite = (is_array($existing) && isset($existing['id']) && (int)$existing['id'] !== 0);
626 }
627 // Surface the data-row line number so loadDataArrayFromFile()
628 // can build "Invalid regex pattern at line N: ..." messages
629 // that point the user at the row to fix.
630 $dataArray['__line_number'] = $dataRowsSeen + 1;
631 $issues = $this->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting);
632 if (count($issues) > 0) {
633 $invalidRows++;
634 } else {
635 $validRows++;
636 if ($wasOverwrite) {
637 $overwrittenRows++;
638 }
639 }
640 $anyIssuesToNote = array_merge($anyIssuesToNote, $issues);
641 $dataRowsSeen++;
642 } catch (\Throwable $e) {
643 // Mid-import failure (PHP timeout exception, DB error, etc.).
644 // Persist what we completed so the next call with the same
645 // file content resumes from $dataRowsSeen (the failed row
646 // gets retried; its from_url is the resume frontier). The
647 // row we were processing did NOT succeed, so processedRows
648 // is rolled back by 1 to keep counters truthful.
649 if (!$dryRun) {
650 $this->persistImportProgress($contentHash, array(
651 'rows_processed' => $dataRowsSeen,
652 'processed_count' => max(0, $processedRows - 1),
653 'valid_count' => $validRows,
654 'invalid_count' => $invalidRows,
655 'overwritten_count' => $overwrittenRows,
656 'issues' => $anyIssuesToNote,
657 'last_error' => $e->getMessage(),
658 'paused_at' => time(),
659 ));
660 }
661 fclose($file_handle);
662 $this->logger->warn(sprintf(
663 'Import paused at row %d of %d due to: %s',
664 $dataRowsSeen + 1,
665 $dataRowsSeen + 1,
666 $e->getMessage()
667 ));
668 return sprintf(
669 __('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'),
670 $dataRowsSeen + 1,
671 $validRows
672 );
673 }
674
675 // Periodic checkpoint so a hard PHP timeout (uncatchable fatal)
676 // still leaves a recent progress marker. Skipped during dry runs
677 // since they perform no DB writes.
678 if (!$dryRun && ($dataRowsSeen % self::IMPORT_PROGRESS_CHECKPOINT_INTERVAL) === 0) {
679 $this->persistImportProgress($contentHash, array(
680 'rows_processed' => $dataRowsSeen,
681 'processed_count' => $processedRows,
682 'valid_count' => $validRows,
683 'invalid_count' => $invalidRows,
684 'overwritten_count' => $overwrittenRows,
685 'issues' => $anyIssuesToNote,
686 ));
687 }
688 }
689 fclose($file_handle);
690
691 // Full traversal completed successfully: clear any prior progress
692 // so a future unrelated upload of byte-identical content (e.g.
693 // re-applying the same exports) starts fresh, not "resumes" from
694 // the file's end.
695 if (!$dryRun) {
696 $this->clearImportProgress();
697 }
698
699 if ($dryRun) {
700 $msg = sprintf(
701 __('Dry run complete. Valid redirects: %d. Invalid rows: %d. Total rows processed: %d.', '404-solution'),
702 $validRows,
703 $invalidRows,
704 $processedRows
705 );
706 if (count($anyIssuesToNote) > 0) {
707 $msg .= ' ' . __('Preview issues:', '404-solution') . ' ' .
708 implode(", <BR/>\n", array_slice($anyIssuesToNote, 0, 20));
709 }
710 return $msg;
711 }
712
713 if (count($anyIssuesToNote) > 0) {
714 return __('Error:', '404-solution') . ' ' . implode(", <BR/>\n", $anyIssuesToNote);
715 }
716
717 if ($overwriteExisting && $overwrittenRows > 0) {
718 return sprintf(
719 __('The file seems to have loaded okay. %d existing redirect(s) were overwritten. Please check the redirects page.', '404-solution'),
720 $overwrittenRows
721 );
722 }
723
724 return __('The file seems to have loaded okay. Please check the redirects page.', '404-solution');
725 }
726
727 /**
728 * Type-narrowing helper for `mixed` values pulled out of the persisted
729 * progress array. Keeps PHPStan level 9 happy without sprinkling
730 * `is_int / is_numeric` guards through `doImportFile()`.
731 *
732 * @param array<string, mixed> $progress
733 * @param string $key
734 * @param int $default
735 * @return int
736 */
737 private static function progressInt(array $progress, string $key, int $default): int {
738 if (!isset($progress[$key])) {
739 return $default;
740 }
741 $v = $progress[$key];
742 if (is_int($v)) {
743 return $v;
744 }
745 if (is_numeric($v)) {
746 return (int)$v;
747 }
748 return $default;
749 }
750
751 /**
752 * Look up resumable-import progress for the supplied content hash.
753 * Returns the persisted progress array only when the recorded hash
754 * matches; mismatches (different file content) and absent records both
755 * return null so the caller can begin a fresh import.
756 *
757 * @param string $contentHash sha256 of the current upload's contents.
758 * @return array<string, mixed>|null
759 */
760 private function getResumeProgress($contentHash) {
761 if ($contentHash === '' || !function_exists('get_option')) {
762 return null;
763 }
764 $progress = get_option(self::IMPORT_PROGRESS_OPTION, null);
765 if (!is_array($progress) || !isset($progress['hash']) || !is_string($progress['hash'])) {
766 return null;
767 }
768 if ($progress['hash'] !== $contentHash) {
769 return null;
770 }
771 /** @var array<string, mixed> $progress */
772 return $progress;
773 }
774
775 /**
776 * Persist a resumable-import checkpoint keyed by the file's sha256
777 * hash. Writes are idempotent (latest wins) and small (counters + a
778 * short issue list), so calling this every N rows is cheap.
779 *
780 * @param string $contentHash
781 * @param array<string, mixed> $state
782 * @return void
783 */
784 private function persistImportProgress($contentHash, $state) {
785 if ($contentHash === '' || !function_exists('update_option')) {
786 return;
787 }
788 $state['hash'] = $contentHash;
789 update_option(self::IMPORT_PROGRESS_OPTION, $state);
790 }
791
792 /**
793 * Clear the resume marker after a fully-successful import so a future
794 * upload of byte-identical content starts fresh rather than "resuming"
795 * from end-of-file.
796 *
797 * @return void
798 */
799 private function clearImportProgress() {
800 if (function_exists('delete_option')) {
801 delete_option(self::IMPORT_PROGRESS_OPTION);
802 }
803 }
804
805 /**
806 * @param array<string, mixed> $dataArray
807 * @param bool $dryRun
808 * @param bool $overwriteExisting When true, an existing redirect with the
809 * same from_url is updated instead of being skipped. Default false
810 * preserves historical safe-by-default behavior.
811 * @return array<int, string>
812 */
813 function loadDataArrayFromFile($dataArray, $dryRun = false, $overwriteExisting = false) {
814 $fromURL = isset($dataArray['from_url']) && is_string($dataArray['from_url']) ? $dataArray['from_url'] : '';
815 if ($fromURL === 'from_url' || $fromURL === 'request') {
816 return array();
817 }
818
819 // Explicit regex signal from the CSV takes priority over the narrow
820 // URL-chars sniff further down. Recognized signals (any one wins):
821 // 1. Native CSV `status` column literal 'Regex' (case-insensitive)
822 // 2. Native CSV `status` numeric ABJ404_STATUS_REGEX value
823 // 3. Redirection-plugin `regex` column '1' / 'true' / 'yes'
824 $explicitRegex = $this->isExplicitRegexRow($dataArray);
825 $status = $explicitRegex ? ABJ404_STATUS_REGEX : ABJ404_STATUS_MANUAL;
826 $final_dest = isset($dataArray['to_url']) && is_string($dataArray['to_url']) ? $dataArray['to_url'] : '';
827 $anyIssuesToNote = array();
828
829 // Server-side regex auto-promote sniff. When the CSV row does not
830 // carry an explicit regex signal but the from_url contains
831 // unambiguous regex metachars (`* [ ] | ^ \ { }`), flip the
832 // status to REGEX and apply the bare-`*` to `.*` glob fixup so the
833 // stored pattern compiles at runtime. Applied regardless of
834 // destination type because the canonical case (Troy's 55-row
835 // import) imports `/sales/*` to internal pages, not just external
836 // destinations as the legacy narrow sniff assumed. Done BEFORE
837 // the existing-URL check so re-imports of the same CSV idempotently
838 // resolve to the same canonical rewritten pattern.
839 if (!$explicitRegex
840 && ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($fromURL)) {
841 $status = ABJ404_STATUS_REGEX;
842 $glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($fromURL);
843 $fromURL = $glob['url'];
844 }
845
846 // Validate at the boundary: if the row is explicitly flagged as a regex
847 // redirect, refuse to persist a from_url that is not a syntactically
848 // valid PHP pattern. Without this guard, the bad pattern reaches
849 // SpellCheckerTrait_URLMatching::getPermalinkUsingRegEx() at runtime
850 // and emits a PHP warning per 404 request.
851 if ($explicitRegex) {
852 $patternError = $this->validateRegexPattern($fromURL);
853 if ($patternError !== '') {
854 $lineNumber = isset($dataArray['__line_number']) && is_numeric($dataArray['__line_number'])
855 ? (int)$dataArray['__line_number'] : 0;
856 $msg = $lineNumber > 0
857 ? sprintf(__('Invalid regex pattern at line %d: %s (%s)', '404-solution'),
858 $lineNumber, $fromURL, $patternError)
859 : sprintf(__('Invalid regex pattern: %s (%s)', '404-solution'),
860 $fromURL, $patternError);
861 $this->logger->warn($msg);
862 $anyIssuesToNote[] = $msg;
863 return $anyIssuesToNote;
864 }
865 }
866
867 $maybeExisting2 = $this->redirectsRepository->getExistingRedirectForURL($fromURL);
868 $existingId = (count($maybeExisting2) > 0 && isset($maybeExisting2['id'])) ? (int)$maybeExisting2['id'] : 0;
869 if ($existingId !== 0 && !$overwriteExisting) {
870 $msg = __('Ignored importing redirect because a redirect with the same from URL already exists. URL:', '404-solution') . ' ' . $fromURL;
871 $this->logger->warn($msg);
872 $anyIssuesToNote[] = $msg;
873 return $anyIssuesToNote;
874 }
875
876 $typePost = defined('ABJ404_TYPE_POST') ? constant('ABJ404_TYPE_POST') : 1;
877 $typeCat = defined('ABJ404_TYPE_CAT') ? constant('ABJ404_TYPE_CAT') : 2;
878 $typeTag = defined('ABJ404_TYPE_TAG') ? constant('ABJ404_TYPE_TAG') : 3;
879
880 if (empty($final_dest)) {
881 $type = ABJ404_TYPE_404_DISPLAYED;
882 } else if ($final_dest == '5') {
883 $type = ABJ404_TYPE_HOME;
884 } else if (strpos($final_dest, 'http') !== false) {
885 $type = ABJ404_TYPE_EXTERNAL;
886 } else if (strpos($final_dest, '/') === 0) {
887 $type = $typePost;
888 } else {
889 $msg = __('Unrecognized destination type while importing file. Destination:', '404-solution') . ' ' . $final_dest;
890 $this->logger->warn($msg);
891 $anyIssuesToNote[] = $msg;
892 return $anyIssuesToNote;
893 }
894
895 if ($type == ABJ404_TYPE_404_DISPLAYED) {
896 $final_dest = ABJ404_TYPE_404_DISPLAYED;
897 } else if (strpos($final_dest, 'http') !== false) {
898 $type = ABJ404_TYPE_EXTERNAL;
899 } else if ($type == ABJ404_TYPE_HOME) {
900 $final_dest = ABJ404_TYPE_HOME;
901 } else {
902 $slug = trim($final_dest, '/');
903 $postsFromSlugRows = $this->contentRepository->getPublishedPagesAndPostsIDs($slug);
904 $postsFromCategoryRows = $this->contentRepository->getPublishedCategories(null, $slug);
905 $postsFromTagRows = $this->contentRepository->getPublishedTags($slug);
906
907 /** @var object{id?: int|string, term_id?: int|string}|null $postFromSlug */
908 $postFromSlug = isset($postsFromSlugRows[0]) ? $postsFromSlugRows[0] : null;
909 /** @var object{term_id?: int|string}|null $postFromCategory */
910 $postFromCategory = isset($postsFromCategoryRows[0]) ? $postsFromCategoryRows[0] : null;
911 /** @var object{term_id?: int|string}|null $postFromTag */
912 $postFromTag = isset($postsFromTagRows[0]) ? $postsFromTagRows[0] : null;
913
914 if ($postFromSlug && isset($postFromSlug->id)) {
915 $type = $typePost;
916 $final_dest = (string)$postFromSlug->id;
917 } else if ($postFromCategory && isset($postFromCategory->term_id)) {
918 $type = $typeCat;
919 $final_dest = (string)$postFromCategory->term_id;
920 } else if ($postFromTag && isset($postFromTag->term_id)) {
921 $type = $typeTag;
922 $final_dest = (string)$postFromTag->term_id;
923 } else {
924 // Slug doesn't resolve to any post/category/tag (use EXTERNAL
925 // so the path is used as-is by the redirect pipeline). Storing
926 // a non-numeric final_dest with TYPE_POST would cause the
927 // redirect to silently 404 (get_permalink() expects an ID).
928 $type = ABJ404_TYPE_EXTERNAL;
929 $this->logger->warn(__("Couldn't find post from slug. slug:", '404-solution') . ' ' . $slug);
930 }
931 }
932
933 if (!$dryRun) {
934 $engine = isset($dataArray['engine']) && is_string($dataArray['engine']) && $dataArray['engine'] !== ''
935 ? $dataArray['engine'] : 'import';
936 $code = isset($dataArray['code']) && is_numeric($dataArray['code'])
937 ? (string)(int)$dataArray['code'] : '301';
938
939 if ($existingId !== 0 && $overwriteExisting) {
940 // Overwrite path: mutate the existing row so the user's bulk
941 // CSV edit (e.g. Manual to Regex on 55 city patterns) lands
942 // without per-row admin clicks.
943 $this->redirectsRepository->updateRedirect((int)$type, (string)$final_dest, $fromURL, $existingId, $code, (int)$status);
944 } else {
945 $this->redirectsRepository->setupRedirect($fromURL, (string)$status, (string)$type, (string)$final_dest, $code, 0, $engine);
946 }
947 }
948
949 return $anyIssuesToNote;
950 }
951
952 /**
953 * Run the same pattern preparation SpellCheckerTrait_URLMatching uses
954 * (forward-slashes escaped, then wrapped with `{` `}` or an alt delimiter)
955 * and ask preg_match whether the result compiles. Returns the empty string
956 * when the pattern is valid, or a short error message when it is not.
957 *
958 * Note: this validates the pattern shape only. It does not test against a
959 * sample URL because preg_match returning 0 (no match) is still a "valid
960 * pattern" outcome.
961 *
962 * @param string $fromUrl raw from_url from the CSV row
963 * @return string '' when valid; a short error message otherwise
964 */
965 private function validateRegexPattern(string $fromUrl): string {
966 if ($fromUrl === '') {
967 return __('pattern is empty', '404-solution');
968 }
969
970 // Mirror SpellCheckerTrait_URLMatching::getPreparedRegexPattern and
971 // FunctionsPreg::regexMatch so we test exactly what runs at request
972 // time.
973 $prepared = str_replace('/', '\/', $fromUrl);
974 $delimA = '{';
975 $delimB = '}';
976 if (strpos($prepared, '}') !== false) {
977 // Mirror FunctionsPreg::findADelimiter for the alt-delimiter path.
978 $candidates = array('`', '^', '|', '~', '!', ';', ':', ',', '@', "'", '/');
979 $picked = null;
980 foreach ($candidates as $c) {
981 if (strpos($prepared, $c) === false) { $picked = $c; break; }
982 }
983 if ($picked === null) {
984 return __('cannot find a safe delimiter character', '404-solution');
985 }
986 $delimA = $delimB = $picked;
987 }
988
989 $compiled = $delimA . $prepared . $delimB;
990 $result = @preg_match($compiled, '');
991 if ($result === false) {
992 $errMsg = function_exists('preg_last_error_msg')
993 ? preg_last_error_msg()
994 : 'preg_match compilation failed';
995 return $errMsg;
996 }
997 return '';
998 }
999
1000 /**
1001 * Decide whether a parsed CSV row explicitly asks for STATUS_REGEX, based
1002 * on the `status` column (native format) or `regex` column (Redirection
1003 * format). Case-insensitive; tolerant of common truthy spellings.
1004 *
1005 * @param array<string, mixed> $dataArray
1006 * @return bool
1007 */
1008 private function isExplicitRegexRow(array $dataArray): bool {
1009 if (isset($dataArray['status']) && is_scalar($dataArray['status'])) {
1010 $raw = strtolower(trim((string)$dataArray['status']));
1011 if ($raw === 'regex') {
1012 return true;
1013 }
1014 if (is_numeric($raw) && (int)$raw === (int)ABJ404_STATUS_REGEX) {
1015 return true;
1016 }
1017 }
1018 if (isset($dataArray['regex']) && is_scalar($dataArray['regex'])) {
1019 $raw = strtolower(trim((string)$dataArray['regex']));
1020 if ($raw === '1' || $raw === 'true' || $raw === 'yes') {
1021 return true;
1022 }
1023 }
1024 return false;
1025 }
1026
1027 /**
1028 * @param mixed $line
1029 * @return array<string, string>
1030 */
1031 function splitCsvLine($line) {
1032 if (!is_string($line)) {
1033 $line = is_scalar($line) ? (string)$line : '';
1034 }
1035
1036 $data = array_map(function($v) {
1037 return trim((string)$v);
1038 }, str_getcsv($line, ',', '"', '\\'));
1039
1040 if (count($data) === 5) {
1041 return array(
1042 'from_url' => $data[0],
1043 'status' => $data[1],
1044 'type' => $data[2],
1045 'to_url' => $data[3],
1046 'wp_type' => $data[4]
1047 );
1048 } else if (count($data) === 2) {
1049 return array(
1050 'from_url' => $data[0],
1051 'to_url' => $data[1]
1052 );
1053 }
1054
1055 return array('error' => sprintf(__('Invalid CSV format. %d columns found but 2 or 5 expected.', '404-solution'), count($data)));
1056 }
1057
1058 /**
1059 * @param array<int, string> $columns
1060 * @return bool
1061 */
1062 function isCompatibleImportHeaderRow($columns) {
1063 $normalized = $this->normalizeImportHeaders($columns);
1064 $fromIndex = $this->findImportHeaderIndex($normalized, array('from_url', 'request', 'source', 'url', 'match_url'));
1065 $toIndex = $this->findImportHeaderIndex($normalized, array('to_url', 'target', 'destination', 'action_data', 'redirect_to', 'url_to'));
1066 return ($fromIndex !== -1 && $toIndex !== -1);
1067 }
1068
1069 /**
1070 * @param array<int, string> $columns
1071 * @return array<int, string|null>
1072 */
1073 function normalizeImportHeaders($columns) {
1074 return array_map(function($value) {
1075 $value = preg_replace('/^\xEF\xBB\xBF/', '', (string)$value);
1076 $value = trim(strtolower((string)$value));
1077 return preg_replace('/[^a-z0-9_]/', '', str_replace(' ', '_', $value));
1078 }, $columns);
1079 }
1080
1081 /**
1082 * Best-effort format detection for import UX and diagnostics.
1083 *
1084 * @param array<int, string> $columns Raw header row.
1085 * @return string One of: native, redirection, safe_redirect_manager, simple_301, unknown.
1086 */
1087 function detectImportFormatFromHeaders($columns) {
1088 $normalized = $this->normalizeImportHeaders($columns);
1089
1090 if (in_array('source', $normalized, true) &&
1091 in_array('target', $normalized, true) &&
1092 in_array('regex', $normalized, true)) {
1093 return 'redirection';
1094 }
1095
1096 if (in_array('redirect_from', $normalized, true) &&
1097 in_array('redirect_to', $normalized, true)) {
1098 return 'safe_redirect_manager';
1099 }
1100
1101 if (in_array('request', $normalized, true) &&
1102 in_array('destination', $normalized, true)) {
1103 return 'simple_301';
1104 }
1105
1106 if ((in_array('from_url', $normalized, true) &&
1107 in_array('to_url', $normalized, true)) ||
1108 (in_array('from_url', $normalized, true) &&
1109 in_array('status', $normalized, true) &&
1110 in_array('type', $normalized, true) &&
1111 in_array('to_url', $normalized, true))) {
1112 return 'native';
1113 }
1114
1115 return 'unknown';
1116 }
1117
1118 /**
1119 * @param array<int, string> $row
1120 * @param array<int, string|null> $normalizedHeaders
1121 * @return array<string, string>
1122 */
1123 function mapImportRowByHeaders($row, $normalizedHeaders) {
1124 $fromIndex = $this->findImportHeaderIndex($normalizedHeaders, array('from_url', 'request', 'source', 'url', 'match_url'));
1125 $toIndex = $this->findImportHeaderIndex($normalizedHeaders, array('to_url', 'target', 'destination', 'action_data', 'redirect_to', 'url_to'));
1126
1127 if ($fromIndex === -1 || $toIndex === -1) {
1128 return array('error' => __('Invalid CSV format. Could not map source/destination columns.', '404-solution'));
1129 }
1130
1131 $from = array_key_exists($fromIndex, $row) ? trim((string)$row[$fromIndex]) : '';
1132 $to = array_key_exists($toIndex, $row) ? trim((string)$row[$toIndex]) : '';
1133
1134 if ($from === '' && $to === '') {
1135 return array('from_url' => '', 'to_url' => '');
1136 }
1137
1138 $result = array(
1139 'from_url' => $from,
1140 'to_url' => $to,
1141 );
1142
1143 $engineIndex = $this->findImportHeaderIndex($normalizedHeaders, array('engine'));
1144 if ($engineIndex !== -1 && array_key_exists($engineIndex, $row)) {
1145 $result['engine'] = trim((string)$row[$engineIndex]);
1146 }
1147
1148 $codeIndex = $this->findImportHeaderIndex($normalizedHeaders, array('code', 'redirect_code', 'http_code'));
1149 if ($codeIndex !== -1 && array_key_exists($codeIndex, $row)) {
1150 $result['code'] = trim((string)$row[$codeIndex]);
1151 }
1152
1153 // Native CSV: textual status (Manual / Regex / Auto / Captured / Ignored / Later).
1154 $statusIndex = $this->findImportHeaderIndex($normalizedHeaders, array('status', 'redirect_status'));
1155 if ($statusIndex !== -1 && array_key_exists($statusIndex, $row)) {
1156 $result['status'] = trim((string)$row[$statusIndex]);
1157 }
1158
1159 // Redirection-plugin CSV: explicit `regex` flag column (0/1).
1160 $regexIndex = $this->findImportHeaderIndex($normalizedHeaders, array('regex', 'is_regex'));
1161 if ($regexIndex !== -1 && array_key_exists($regexIndex, $row)) {
1162 $result['regex'] = trim((string)$row[$regexIndex]);
1163 }
1164
1165 return $result;
1166 }
1167
1168 /**
1169 * @param array<int, string|null> $headers
1170 * @param array<int, string> $candidates
1171 * @return int
1172 */
1173 private function findImportHeaderIndex($headers, $candidates) {
1174 foreach ($candidates as $candidate) {
1175 $idx = array_search($candidate, $headers, true);
1176 if ($idx !== false) {
1177 return (int)$idx;
1178 }
1179 }
1180 return -1;
1181 }
1182
1183 /**
1184 * @param array<int, string> $columns Already parsed CSV columns for one row.
1185 * @return array<string, string>
1186 */
1187 function mapImportRowWithoutHeaders($columns) {
1188 $columns = array_values($columns);
1189 if (count($columns) === 7) {
1190 return array(
1191 'from_url' => trim((string)$columns[0]),
1192 'status' => trim((string)$columns[1]),
1193 'type' => trim((string)$columns[2]),
1194 'to_url' => trim((string)$columns[3]),
1195 'wp_type' => trim((string)$columns[4]),
1196 'engine' => trim((string)$columns[5]),
1197 'code' => trim((string)$columns[6]),
1198 );
1199 }
1200 if (count($columns) === 6) {
1201 return array(
1202 'from_url' => trim((string)$columns[0]),
1203 'status' => trim((string)$columns[1]),
1204 'type' => trim((string)$columns[2]),
1205 'to_url' => trim((string)$columns[3]),
1206 'wp_type' => trim((string)$columns[4]),
1207 'engine' => trim((string)$columns[5]),
1208 );
1209 }
1210 if (count($columns) === 5) {
1211 return array(
1212 'from_url' => trim((string)$columns[0]),
1213 'status' => trim((string)$columns[1]),
1214 'type' => trim((string)$columns[2]),
1215 'to_url' => trim((string)$columns[3]),
1216 'wp_type' => trim((string)$columns[4]),
1217 );
1218 }
1219 if (count($columns) === 2) {
1220 return array(
1221 'from_url' => trim((string)$columns[0]),
1222 'to_url' => trim((string)$columns[1]),
1223 );
1224 }
1225 return array('error' => sprintf(__('Invalid CSV format. %d columns found but 2, 5, 6, or 7 expected.', '404-solution'), count($columns)));
1226 }
1227
1228 /**
1229 * Detect delimiter by inspecting the first non-empty line.
1230 *
1231 * @param resource $fileHandle
1232 * @return string
1233 */
1234 function detectCsvDelimiterFromFile($fileHandle) {
1235 while (($line = fgets($fileHandle)) !== false) {
1236 if (trim($line) === '') {
1237 continue;
1238 }
1239 $comma = count(str_getcsv($line, ',', '"', '\\'));
1240 $semicolon = count(str_getcsv($line, ';', '"', '\\'));
1241 $tab = count(str_getcsv($line, "\t", '"', '\\'));
1242
1243 if ($semicolon > $comma && $semicolon >= $tab) {
1244 return ';';
1245 }
1246 if ($tab > $comma && $tab > $semicolon) {
1247 return "\t";
1248 }
1249 return ',';
1250 }
1251 return ',';
1252 }
1253 }
1254