PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / WPCLICommands.php

WPCLICommands.php in 404 Solution 4.1.19, at includes/WPCLICommands.php

835 lines 30.2 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 * WP-CLI command group for 404 Solution.
9 *
10 * Registered as: wp abj404 <subcommand>
11 *
12 * Subcommands:
13 * list — list redirects
14 * create — create a manual redirect
15 * delete — move a redirect to trash
16 * stats — show summary statistics
17 * purge — purge captured 404s
18 * import — import redirects from a CSV file
19 * export — export redirects to stdout or a file
20 * flush-cache — clear one or more caches
21 * test — test which redirect would fire for a URL
22 */
23 class ABJ_404_Solution_WPCLICommands extends \WP_CLI_Command {
24
25 /**
26 * List redirects.
27 *
28 * ## OPTIONS
29 *
30 * [--status=<status>]
31 * : Filter by status. One of: manual, auto, captured, regex, ignored, later.
32 *
33 * [--format=<format>]
34 * : Output format. One of: table, csv, json. Default: table.
35 *
36 * ## EXAMPLES
37 *
38 * wp abj404 list
39 * wp abj404 list --status=manual --format=json
40 * wp abj404 list --status=captured --format=csv
41 *
42 * @subcommand list
43 *
44 * @param array<int, string> $args
45 * @param array<string, string> $assocArgs
46 * @return void
47 */
48 public function list_redirects($args, $assocArgs) {
49 require_once __DIR__ . '/DataAccess.php';
50
51 $dao = abj_service('data_access');
52 $status = isset($assocArgs['status']) ? strtolower(trim($assocArgs['status'])) : '';
53 $format = isset($assocArgs['format']) ? strtolower(trim($assocArgs['format'])) : 'table';
54
55 $validStatuses = array('', 'manual', 'auto', 'captured', 'regex', 'ignored', 'later');
56 if ($status !== '' && !in_array($status, $validStatuses, true)) {
57 \WP_CLI::error('Invalid --status value. Choose one of: ' . implode(', ', array_filter($validStatuses)));
58 return;
59 }
60
61 // Map status string to numeric constant.
62 $types = $this->statusStringToTypes($status);
63
64 // Fetch all matching rows (max 2000 rows for CLI safety).
65 $rows = $this->fetchRedirectRows($dao, $types, 2000);
66
67 if (empty($rows)) {
68 \WP_CLI::line('No redirects found.');
69 return;
70 }
71
72 // Humanize the integer status column so output is readable.
73 foreach ($rows as &$row) {
74 $rawStatus = $row['status'] ?? 0;
75 $row['status'] = $this->statusIntToLabel(is_numeric($rawStatus) ? (int)$rawStatus : 0);
76 }
77 unset($row);
78
79 $fields = array('id', 'url', 'status', 'type', 'final_dest', 'code', 'disabled', 'timestamp');
80 \WP_CLI\Utils\format_items($format, $rows, $fields);
81 }
82
83 /**
84 * Create a manual redirect.
85 *
86 * ## OPTIONS
87 *
88 * --from=<url>
89 * : The source URL (relative path starting with /, e.g. /old-page).
90 * Quote URLs containing & or ? to prevent shell interpretation:
91 * wp abj404 create --from='/search?q=foo' --to=/results
92 *
93 * [--to=<url>]
94 * : The destination URL or path. Required unless --code is 410 or 451.
95 *
96 * [--code=<code>]
97 * : HTTP redirect code. One of: 301, 302, 307, 308, 410, 451. Default: 301.
98 * 410 and 451 serve a "Gone" / "Unavailable for Legal Reasons" page with no destination.
99 *
100 * [--regex]
101 * : Treat the source URL as a regular expression.
102 *
103 * ## EXAMPLES
104 *
105 * wp abj404 create --from=/old-page --to=/new-page
106 * wp abj404 create --from=/old-page --to=https://example.com/new --code=302
107 * wp abj404 create --from=/deleted-product --code=410
108 * wp abj404 create --from='/search?q=old' --to='/search?q=new'
109 *
110 * @param array<int, string> $args
111 * @param array<string, string> $assocArgs
112 * @return void
113 */
114 public function create($args, $assocArgs) {
115 require_once __DIR__ . '/DataAccess.php';
116
117 $dao = abj_service('data_access');
118
119 $from = isset($assocArgs['from']) ? trim($assocArgs['from']) : '';
120 $to = isset($assocArgs['to']) ? trim($assocArgs['to']) : '';
121 $code = isset($assocArgs['code']) ? (int)$assocArgs['code'] : 301;
122 $regex = isset($assocArgs['regex']);
123
124 if ($from === '') {
125 \WP_CLI::error('--from is required.');
126 return;
127 }
128
129 // Warn about missing leading slash — it's a common mistake that creates unmatchable rules.
130 if ($from !== '' && $from[0] !== '/' && !preg_match('#^https?://#i', $from)) {
131 \WP_CLI::warning("--from '{$from}' does not start with '/'. Incoming requests are matched against the path (e.g. /old-page), so this redirect may never fire.");
132 }
133
134 $validCodes = array(301, 302, 307, 308, 410, 451);
135 if (!in_array($code, $validCodes, true)) {
136 \WP_CLI::warning('Invalid redirect code; defaulting to 301. Valid codes: ' . implode(', ', $validCodes));
137 $code = 301;
138 }
139
140 // 410 Gone and 451 Unavailable For Legal Reasons serve a terminal page — no destination needed.
141 $isTerminalCode = in_array($code, array(410, 451), true);
142 if ($to === '' && !$isTerminalCode) {
143 \WP_CLI::error('--to is required (omit only when --code is 410 or 451).');
144 return;
145 }
146
147 if ($isTerminalCode) {
148 $dest = '0';
149 $type = (string)ABJ404_TYPE_404_DISPLAYED;
150 } else {
151 $resolved = $this->resolveDestinationType($to);
152 $type = $resolved['type'];
153 $dest = $resolved['dest'];
154 }
155
156 $status = $regex ? (string)ABJ404_STATUS_REGEX : (string)ABJ404_STATUS_MANUAL;
157 $insertedId = $dao->setupRedirect($from, $status, $type, $dest, (string)$code, 0, 'wp-cli');
158 if ($insertedId) {
159 $dao->markViewDoneInvalidatedByAdminMutation();
160 $displayDest = $isTerminalCode ? "(none — {$code})" : "{$to}";
161 \WP_CLI::success("Redirect created (ID: {$insertedId}): {$from}{$displayDest} [{$code}]");
162 } else {
163 \WP_CLI::error('Failed to create redirect. Check that the source URL is unique.');
164 }
165 }
166
167 /**
168 * Move a redirect to the trash.
169 *
170 * Accepts either a numeric ID or a source URL. When a URL is given the
171 * plugin looks up the matching redirect and resolves it to an ID first.
172 *
173 * ## OPTIONS
174 *
175 * <id-or-url>
176 * : The numeric ID of the redirect, or the source URL (e.g. /old-page).
177 *
178 * ## EXAMPLES
179 *
180 * wp abj404 delete 42
181 * wp abj404 delete /old-page
182 * wp abj404 delete '/search?q=old'
183 *
184 * @param array<int, string> $args
185 * @param array<string, string> $assocArgs
186 * @return void
187 */
188 public function delete($args, $assocArgs) {
189 require_once __DIR__ . '/DataAccess.php';
190
191 if (empty($args[0])) {
192 \WP_CLI::error('Please provide a redirect ID or source URL.');
193 return;
194 }
195
196 $dao = abj_service('data_access');
197 $arg = trim($args[0]);
198
199 if (ctype_digit($arg)) {
200 // Numeric argument — treat as ID.
201 $id = (int)$arg;
202 if ($id === 0) {
203 \WP_CLI::error('Invalid redirect ID.');
204 return;
205 }
206 } else {
207 // Non-numeric — look up by source URL.
208 $redirect = $dao->getExistingRedirectForURL($arg);
209 if (!isset($redirect['id']) || (int)(is_scalar($redirect['id']) ? $redirect['id'] : 0) === 0) {
210 \WP_CLI::error("No redirect found for URL: {$arg}");
211 return;
212 }
213 $id = (int)(is_scalar($redirect['id']) ? $redirect['id'] : 0);
214 \WP_CLI::line("Resolved '{$arg}' to redirect ID {$id}.");
215 }
216
217 $error = $dao->moveRedirectsToTrash($id, 1);
218
219 if ($error === '') {
220 $dao->markViewDoneInvalidatedByAdminMutation();
221 \WP_CLI::success("Redirect ID {$id} moved to trash.");
222 } else {
223 \WP_CLI::error("No redirect with ID {$id} found, or database error: {$error}");
224 }
225 }
226
227 /**
228 * Show summary statistics.
229 *
230 * ## EXAMPLES
231 *
232 * wp abj404 stats
233 *
234 * @param array<int, string> $args
235 * @param array<string, string> $assocArgs
236 * @return void
237 */
238 public function stats($args, $assocArgs) {
239 require_once __DIR__ . '/DataAccess.php';
240
241 $dao = abj_service('data_access');
242 $snapshot = $dao->getStatsDashboardSnapshot(false);
243 // getStatsDashboardSnapshot always returns array{refreshed_at, hash, data}.
244 $data = is_array($snapshot['data']) ? $snapshot['data'] : array();
245
246 /** @var array<string, mixed> $dataAsStrMap */
247 $dataAsStrMap = $data;
248 $redirects = isset($dataAsStrMap['redirects']) && is_array($dataAsStrMap['redirects']) ? $dataAsStrMap['redirects'] : array();
249 $captured = isset($dataAsStrMap['captured']) && is_array($dataAsStrMap['captured']) ? $dataAsStrMap['captured'] : array();
250
251 $rows = array(
252 array('metric' => 'Auto (301)', 'count' => intval($redirects['auto301'] ?? 0)),
253 array('metric' => 'Auto (302)', 'count' => intval($redirects['auto302'] ?? 0)),
254 array('metric' => 'Manual (301)', 'count' => intval($redirects['manual301'] ?? 0)),
255 array('metric' => 'Manual (302)', 'count' => intval($redirects['manual302'] ?? 0)),
256 array('metric' => 'Trashed', 'count' => intval($redirects['trashed'] ?? 0)),
257 array('metric' => 'Captured 404s', 'count' => intval($captured['captured'] ?? 0)),
258 array('metric' => 'Ignored', 'count' => intval($captured['ignored'] ?? 0)),
259 array('metric' => 'Captured trash', 'count' => intval($captured['trashed'] ?? 0)),
260 );
261
262 \WP_CLI\Utils\format_items('table', $rows, array('metric', 'count'));
263 }
264
265 /**
266 * Purge captured 404s or other data sets.
267 *
268 * ## OPTIONS
269 *
270 * <type>
271 * : What to purge. Currently only "captured" is supported.
272 *
273 * [--yes]
274 * : Skip the confirmation prompt.
275 *
276 * ## EXAMPLES
277 *
278 * wp abj404 purge captured
279 * wp abj404 purge captured --yes
280 *
281 * @param array<int, string> $args
282 * @param array<string, string> $assocArgs
283 * @return void
284 */
285 public function purge($args, $assocArgs) {
286 $type = isset($args[0]) ? strtolower(trim($args[0])) : '';
287
288 if ($type !== 'captured') {
289 \WP_CLI::error('Only "captured" is a valid purge target. Usage: wp abj404 purge captured');
290 return;
291 }
292
293 require_once __DIR__ . '/DataAccess.php';
294
295 $dao = abj_service('data_access');
296
297 $table = $dao->doTableNameReplacements('{wp_abj404_redirects}');
298 $statusIn = implode(', ', array(
299 ABJ404_STATUS_CAPTURED,
300 ABJ404_STATUS_IGNORED,
301 ABJ404_STATUS_LATER,
302 ));
303
304 // Count before confirming so the user knows the blast radius.
305 $count = $dao->queryScalarInt(
306 "SELECT COUNT(*) AS c FROM `{$table}` WHERE status IN ({$statusIn}) AND disabled = 0"
307 );
308
309 if ($count === 0) {
310 \WP_CLI::line('No captured 404 entries to purge.');
311 return;
312 }
313
314 \WP_CLI::confirm("This will permanently delete {$count} captured 404 entr" . ($count === 1 ? 'y' : 'ies') . '. Continue?', $assocArgs);
315
316 $deleteResult = $dao->queryAndGetResults(
317 "DELETE FROM `{$table}` WHERE status IN ({$statusIn}) AND disabled = 0"
318 );
319
320 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
321 if ($deleteError !== '') {
322 \WP_CLI::error('Database error: ' . $deleteError);
323 return;
324 }
325
326 $deleted = isset($deleteResult['rows_affected']) && is_scalar($deleteResult['rows_affected']) ? (int)$deleteResult['rows_affected'] : $count;
327 \WP_CLI::success("Purged {$deleted} captured 404 entries.");
328 }
329
330 /**
331 * Import redirects from a CSV file.
332 *
333 * ## OPTIONS
334 *
335 * <file>
336 * : Path to the CSV file to import.
337 *
338 * [--dry-run]
339 * : Preview the import without writing to the database.
340 *
341 * ## EXAMPLES
342 *
343 * wp abj404 import redirects.csv
344 * wp abj404 import redirects.csv --dry-run
345 *
346 * @subcommand import
347 *
348 * @param array<int, string> $args
349 * @param array<string, string> $assocArgs
350 * @return void
351 */
352 public function import_redirects($args, $assocArgs) {
353 if (empty($args[0])) {
354 \WP_CLI::error('Please provide a path to the CSV file. Usage: wp abj404 import <file>');
355 return;
356 }
357
358 $filePath = $args[0];
359 if (!file_exists($filePath)) {
360 \WP_CLI::error("File not found: {$filePath}");
361 return;
362 }
363
364 $dryRun = isset($assocArgs['dry-run']);
365
366 require_once __DIR__ . '/DataAccess.php';
367 require_once __DIR__ . '/ImportExportService.php';
368
369 $dao = abj_service('data_access');
370 $logging = abj_service('logging');
371 $svc = new ABJ_404_Solution_ImportExportService($dao, $logging);
372
373 $fileHandle = fopen($filePath, 'r');
374 if ($fileHandle === false) {
375 \WP_CLI::error("Could not open file: {$filePath}");
376 return;
377 }
378
379 // Detect delimiter by reading a sample then rewinding.
380 $delimiter = $svc->detectCsvDelimiterFromFile($fileHandle);
381 rewind($fileHandle);
382
383 $headerColumns = null;
384 $processedRows = 0;
385 $validRows = 0;
386 $invalidRows = 0;
387 $anyIssuesToNote = array();
388
389 // Wrap the per-row loop in the deferred-invalidation window so
390 // each setupRedirect call's invalidateStatusCountsCache short-
391 // circuits. Without this, a 10K-row import fires ~60K transient
392 // / option / watermark queries; the one end-of-loop markView
393 // bump below covers the entire batch.
394 $rowResult = $dao->runWithDeferredInvalidation(function () use (
395 $svc, $fileHandle, $delimiter, $dryRun) {
396 $local = array(
397 'headerColumns' => null,
398 'processedRows' => 0,
399 'validRows' => 0,
400 'invalidRows' => 0,
401 'anyIssuesToNote' => array(),
402 'error' => null,
403 );
404 while (($row = fgetcsv($fileHandle, 0, $delimiter, '"', '\\')) !== false) {
405 $data = array_map(function($v) {
406 return trim((string)$v);
407 }, $row);
408
409 // Skip blank lines.
410 if (count($data) === 1 && $data[0] === '') {
411 continue;
412 }
413
414 // Detect and consume the header row.
415 if ($local['headerColumns'] === null && $svc->isCompatibleImportHeaderRow($data)) {
416 $local['headerColumns'] = $svc->normalizeImportHeaders($data);
417 continue;
418 }
419
420 $dataArray = ($local['headerColumns'] !== null)
421 ? $svc->mapImportRowByHeaders($data, $local['headerColumns'])
422 : $svc->mapImportRowWithoutHeaders($data);
423
424 if (isset($dataArray['error'])) {
425 $local['error'] = $dataArray['error'];
426 return $local;
427 }
428
429 // Skip header-literal rows that slipped through.
430 if (isset($dataArray['from_url']) &&
431 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request')) {
432 continue;
433 }
434
435 $local['processedRows']++;
436 $issues = $svc->loadDataArrayFromFile($dataArray, $dryRun);
437 if (count($issues) > 0) {
438 $local['invalidRows']++;
439 } else {
440 $local['validRows']++;
441 }
442 $local['anyIssuesToNote'] = array_merge($local['anyIssuesToNote'], $issues);
443 }
444 return $local;
445 });
446 fclose($fileHandle);
447 if (!empty($rowResult['error'])) {
448 \WP_CLI::error($rowResult['error']);
449 return;
450 }
451 $headerColumns = $rowResult['headerColumns'];
452 $processedRows = $rowResult['processedRows'];
453 $validRows = $rowResult['validRows'];
454 $invalidRows = $rowResult['invalidRows'];
455 $anyIssuesToNote = $rowResult['anyIssuesToNote'];
456
457 if ($dryRun) {
458 \WP_CLI::line("Dry run: valid={$validRows}, invalid={$invalidRows}, total={$processedRows}");
459 foreach (array_slice($anyIssuesToNote, 0, 20) as $issue) {
460 \WP_CLI::warning($issue);
461 }
462 return;
463 }
464
465 if (count($anyIssuesToNote) > 0) {
466 foreach (array_slice($anyIssuesToNote, 0, 20) as $issue) {
467 \WP_CLI::warning($issue);
468 }
469 }
470 // CLI-initiated bulk mutation: force a fresh view_done rebuild before
471 // the next admin tab read so imported rows appear immediately, matching
472 // the admin form path (handleActionImportFile in
473 // PluginLogicTrait_AdminActions.php).
474 $dao->markViewDoneInvalidatedByAdminMutation();
475 \WP_CLI::success("Import complete. Valid={$validRows}, invalid={$invalidRows}, total={$processedRows}");
476 }
477
478 /**
479 * Export redirects to stdout or a file.
480 *
481 * ## OPTIONS
482 *
483 * [--format=<format>]
484 * : Output format. One of: native, redirection, htaccess, nginx, cloudflare, netlify, vercel.
485 * Default: native.
486 *
487 * [--output=<file>]
488 * : Write output to this file path instead of stdout.
489 *
490 * ## EXAMPLES
491 *
492 * wp abj404 export
493 * wp abj404 export --format=htaccess
494 * wp abj404 export --format=native --output=redirects.csv
495 *
496 * @subcommand export
497 *
498 * @param array<int, string> $args
499 * @param array<string, string> $assocArgs
500 * @return void
501 */
502 public function export_redirects($args, $assocArgs) {
503 require_once __DIR__ . '/DataAccess.php';
504 require_once __DIR__ . '/ImportExportService.php';
505
506 $format = isset($assocArgs['format']) ? strtolower(trim($assocArgs['format'])) : 'native';
507 $output = isset($assocArgs['output']) ? trim($assocArgs['output']) : '';
508
509 $dao = abj_service('data_access');
510 $logging = abj_service('logging');
511 $svc = new ABJ_404_Solution_ImportExportService($dao, $logging);
512
513 $serverFormats = array('htaccess', 'nginx', 'cloudflare', 'netlify', 'vercel');
514 if (in_array($format, $serverFormats, true)) {
515 switch ($format) {
516 case 'htaccess':
517 $content = $svc->generateHtaccessRules();
518 break;
519 case 'nginx':
520 $content = $svc->generateNginxRules();
521 break;
522 case 'cloudflare':
523 $content = $svc->generateCloudflareWorkerScript();
524 break;
525 case 'netlify':
526 $content = $svc->generateNetlifyRedirects();
527 break;
528 default: // vercel
529 $content = $svc->generateVercelRedirects();
530 break;
531 }
532
533 if ($output !== '') {
534 if (file_put_contents($output, $content) === false) {
535 \WP_CLI::error("Could not write to file: {$output}");
536 return;
537 }
538 \WP_CLI::success("Exported {$format} rules to: {$output}");
539 } else {
540 echo $content;
541 }
542 return;
543 }
544
545 // CSV-based formats (native, redirection).
546 $tempFile = sys_get_temp_dir() . '/abj404_export_' . time() . '.csv';
547
548 if ($format === 'redirection') {
549 $nativeTemp = sys_get_temp_dir() . '/abj404_export_native_' . time() . '.csv';
550 $dao->doRedirectsExport($nativeTemp);
551 $error = $svc->convertExportCsvToRedirectionFormat($nativeTemp, $tempFile);
552 @unlink($nativeTemp);
553 if ($error !== '') {
554 \WP_CLI::error("Export conversion failed: {$error}");
555 return;
556 }
557 } else {
558 $dao->doRedirectsExport($tempFile);
559 }
560
561 if (!file_exists($tempFile)) {
562 \WP_CLI::line('No redirects to export.');
563 @unlink($tempFile);
564 return;
565 }
566
567 if ($output !== '') {
568 if (!rename($tempFile, $output)) {
569 // rename may fail across filesystems; fall back to copy+delete.
570 if (!copy($tempFile, $output)) {
571 @unlink($tempFile);
572 \WP_CLI::error("Could not write to file: {$output}");
573 return;
574 }
575 @unlink($tempFile);
576 }
577 \WP_CLI::success("Exported {$format} redirects to: {$output}");
578 } else {
579 $csv = file_get_contents($tempFile);
580 @unlink($tempFile);
581 if ($csv === false) {
582 \WP_CLI::error('Could not read export temp file.');
583 return;
584 }
585 echo $csv;
586 }
587 }
588
589 /**
590 * Flush one or more internal caches.
591 *
592 * ## OPTIONS
593 *
594 * [--type=<type>]
595 * : Which cache to flush. One of: spelling, ngram, permalink, all. Default: all.
596 *
597 * ## EXAMPLES
598 *
599 * wp abj404 flush-cache
600 * wp abj404 flush-cache --type=spelling
601 * wp abj404 flush-cache --type=permalink
602 *
603 * @subcommand flush-cache
604 *
605 * @param array<int, string> $args
606 * @param array<string, string> $assocArgs
607 * @return void
608 */
609 public function flush_cache($args, $assocArgs) {
610 require_once __DIR__ . '/DataAccess.php';
611
612 $type = isset($assocArgs['type']) ? strtolower(trim($assocArgs['type'])) : 'all';
613
614 $validTypes = array('spelling', 'ngram', 'permalink', 'all');
615 if (!in_array($type, $validTypes, true)) {
616 \WP_CLI::error("Invalid type. Choose one of: " . implode(', ', $validTypes));
617 return;
618 }
619
620 global $wpdb;
621 $dao = abj_service('data_access');
622 $flushed = array();
623
624 if ($type === 'spelling' || $type === 'all') {
625 $dao->deleteSpellingCache();
626 $flushed[] = 'spelling';
627 }
628
629 if ($type === 'permalink' || $type === 'all') {
630 $dao->truncatePermalinkCacheTable();
631 $flushed[] = 'permalink';
632 }
633
634 if ($type === 'ngram' || $type === 'all') {
635 $ngramTable = $dao->doTableNameReplacements('{wp_abj404_ngram_cache}');
636 // skip_repair: TRUNCATE itself is the recovery path during cache flush;
637 // we must not recurse into the missing-table repairer here.
638 $dao->queryAndGetResults(
639 "TRUNCATE TABLE `{$ngramTable}`",
640 ['skip_repair' => true]
641 );
642 // Reset the initialized flag so the cache is rebuilt on the next request.
643 delete_option('abj404_ngram_cache_initialized');
644 delete_option('abj404_ngram_rebuild_offset');
645 $flushed[] = 'ngram';
646 }
647
648 \WP_CLI::success('Flushed caches: ' . implode(', ', $flushed));
649 }
650
651 /**
652 * Test which stored redirect would fire for a given URL.
653 *
654 * Checks manual, auto, and regex redirects stored in the database.
655 * Does NOT simulate the full spelling/suggestion matching pipeline —
656 * a result of "No redirect found" means no stored rule matches, but
657 * the plugin might still generate a page-suggestion redirect at runtime.
658 *
659 * ## OPTIONS
660 *
661 * <url>
662 * : The URL to test (relative path, e.g. /old-page, or absolute URL).
663 * Quote URLs containing & or ? to prevent shell expansion:
664 * wp abj404 test '/search?q=old&page=2'
665 *
666 * ## EXAMPLES
667 *
668 * wp abj404 test /old-page
669 * wp abj404 test https://example.com/old-page
670 * wp abj404 test '/products?id=42'
671 *
672 * @subcommand test
673 *
674 * @param array<int, string> $args
675 * @param array<string, string> $assocArgs
676 * @return void
677 */
678 public function test_redirect($args, $assocArgs) {
679 if (empty($args[0])) {
680 \WP_CLI::error('Please provide a URL to test. Usage: wp abj404 test <url>');
681 return;
682 }
683
684 require_once __DIR__ . '/DataAccess.php';
685 require_once __DIR__ . '/Functions.php';
686
687 $url = trim($args[0]);
688 $dao = abj_service('data_access');
689
690 // Check for an exact match (manual or auto redirect).
691 $exact = $dao->getExistingRedirectForURL($url);
692 if (isset($exact['id']) && (int)(is_scalar($exact['id']) ? $exact['id'] : 0) !== 0) {
693 $dest = isset($exact['final_dest']) && is_scalar($exact['final_dest']) ? (string)$exact['final_dest'] : '';
694 $code = isset($exact['code']) && is_scalar($exact['code']) ? (string)$exact['code'] : '301';
695 $exactId = is_scalar($exact['id']) ? (string)$exact['id'] : '?';
696 \WP_CLI::success("Exact match found (ID: {$exactId}): {$url}{$dest} [{$code}]");
697 return;
698 }
699
700 // Check for a regex match.
701 $regexRedirects = $dao->getRedirectsWithRegEx();
702 $f = abj_service('functions');
703 foreach ($regexRedirects as $row) {
704 $pattern = isset($row['url']) && is_scalar($row['url']) ? (string)$row['url'] : '';
705 if ($pattern === '') {
706 continue;
707 }
708 $matches = array();
709 if ($f->regexMatch($pattern, $url, $matches)) {
710 $dest = isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '';
711 $code = isset($row['code']) && is_scalar($row['code']) ? (string)$row['code'] : '301';
712 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '?';
713 \WP_CLI::success("Regex match found (ID: {$id}, pattern: {$pattern}): {$url}{$dest} [{$code}]");
714 return;
715 }
716 }
717
718 \WP_CLI::line("No redirect found for: {$url}");
719 }
720
721 // -----------------------------------------------------------------------
722 // Private helpers
723 // -----------------------------------------------------------------------
724
725 /**
726 * Fetch redirect rows for the given status types (max $limit rows).
727 *
728 * @param ABJ_404_Solution_DataAccess $dao
729 * @param array<int, int> $types Numeric status constants; empty = all redirects.
730 * @param int $limit
731 * @return array<int, array<string, mixed>>
732 */
733 private function fetchRedirectRows($dao, array $types, $limit) {
734 $table = $dao->doTableNameReplacements('{wp_abj404_redirects}');
735 $limit = absint($limit);
736
737 if (!empty($types)) {
738 $statusIn = implode(', ', array_map('absint', $types));
739 $where = "WHERE status IN ({$statusIn})";
740 } else {
741 $where = '';
742 }
743
744 $query = "SELECT id, url, status, type, final_dest, code, disabled, timestamp
745 FROM `{$table}`
746 {$where}
747 ORDER BY url ASC
748 LIMIT {$limit}";
749
750 $result = $dao->queryAndGetResults($query);
751 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
752 $output = [];
753 foreach ($rows as $row) {
754 if (is_array($row)) {
755 $output[] = $row;
756 }
757 }
758 return $output;
759 }
760
761 /**
762 * Map a status string to an array of numeric type constants.
763 *
764 * @param string $status
765 * @return array<int, int>
766 */
767 private function statusStringToTypes($status) {
768 switch ($status) {
769 case 'manual':
770 return array(ABJ404_STATUS_MANUAL);
771 case 'auto':
772 return array(ABJ404_STATUS_AUTO);
773 case 'captured':
774 return array(ABJ404_STATUS_CAPTURED);
775 case 'ignored':
776 return array(ABJ404_STATUS_IGNORED);
777 case 'later':
778 return array(ABJ404_STATUS_LATER);
779 case 'regex':
780 return array(ABJ404_STATUS_REGEX);
781 default:
782 return array();
783 }
784 }
785
786 /**
787 * Map a numeric status constant to a human-readable label for list output.
788 *
789 * @param int $status
790 * @return string
791 */
792 private function statusIntToLabel($status) {
793 switch ($status) {
794 case ABJ404_STATUS_MANUAL: return 'manual';
795 case ABJ404_STATUS_AUTO: return 'auto';
796 case ABJ404_STATUS_CAPTURED: return 'captured';
797 case ABJ404_STATUS_IGNORED: return 'ignored';
798 case ABJ404_STATUS_LATER: return 'later';
799 case ABJ404_STATUS_REGEX: return 'regex';
800 default: return (string)$status;
801 }
802 }
803
804 /**
805 * Resolve the redirect type and final destination for a given URL.
806 *
807 * ABJ404_TYPE_HOME means "redirect to the home page" — the stored
808 * final_dest is ignored. Internal paths must be resolved to a post ID
809 * (ABJ404_TYPE_POST) or stored as ABJ404_TYPE_EXTERNAL so the URL is
810 * preserved and used as-is by the redirect pipeline.
811 *
812 * @param string $to
813 * @return array{type: string, dest: string}
814 */
815 private function resolveDestinationType($to) {
816 if (strncasecmp($to, 'http://', 7) === 0 || strncasecmp($to, 'https://', 8) === 0) {
817 return array('type' => (string)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
818 }
819
820 $trimmed = trim($to, '/ ');
821 if ($trimmed === '') {
822 return array('type' => (string)ABJ404_TYPE_HOME, 'dest' => (string)ABJ404_TYPE_HOME);
823 }
824
825 if (function_exists('url_to_postid') && function_exists('home_url')) {
826 $postId = url_to_postid(home_url($to));
827 if ($postId > 0) {
828 return array('type' => (string)ABJ404_TYPE_POST, 'dest' => (string)$postId);
829 }
830 }
831
832 return array('type' => (string)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
833 }
834 }
835