| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once __DIR__ . '/../admin/RedirectsTableColumns.php'; |
| 8 |
require_once __DIR__ . '/../admin/RedirectDestinationWarningPolicy.php'; |
| 9 |
require_once __DIR__ . '/../admin/RedirectDestinationLinkResolver.php'; |
| 10 |
require_once __DIR__ . '/../admin/RedirectRowActionsPresenter.php'; |
| 11 |
require_once __DIR__ . '/../admin/RedirectRowPresenter.php'; |
| 12 |
require_once __DIR__ . '/../admin/RedirectAddModalPresenter.php'; |
| 13 |
require_once __DIR__ . '/../admin/RedirectsTablePagePresenter.php'; |
| 14 |
|
| 15 |
/** |
| 16 |
* Page Redirects admin page renderer. Owns the page wrapper, the table shell, |
| 17 |
* and the modern Add Redirect modal. Row presentation, columns, destination |
| 18 |
* warnings, and destination link resolution live in admin presenter/policy |
| 19 |
* classes. |
| 20 |
* |
| 21 |
* Outside callers (via the View facade __call dispatch): |
| 22 |
* - includes/ajax/ViewUpdater.php (pagination AJAX -> getAdminRedirectsPageTable) |
| 23 |
* - PluginLogic admin page entry points (echoAdminRedirectsPage) |
| 24 |
* - tests/BugProof_ReleaseReadinessTest greps buildRedirectsColumnDefs source |
| 25 |
*/ |
| 26 |
class ABJ_404_Solution_View_RedirectsTable extends ABJ_404_Solution_ViewComponent { |
| 27 |
|
| 28 |
/** @var ABJ_404_Solution_RedirectsTableColumns|null */ |
| 29 |
private $redirectsTableColumns = null; |
| 30 |
|
| 31 |
/** @var ABJ_404_Solution_RedirectDestinationWarningPolicy|null */ |
| 32 |
private $destinationWarningPolicy = null; |
| 33 |
|
| 34 |
/** @var ABJ_404_Solution_RedirectDestinationLinkResolver|null */ |
| 35 |
private $destinationLinkResolver = null; |
| 36 |
|
| 37 |
/** @var ABJ_404_Solution_RedirectRowPresenter|null */ |
| 38 |
private $redirectRowPresenter = null; |
| 39 |
|
| 40 |
/** @var ABJ_404_Solution_RedirectAddModalPresenter|null */ |
| 41 |
private $redirectAddModalPresenter = null; |
| 42 |
|
| 43 |
/** @var ABJ_404_Solution_RedirectsTablePagePresenter|null */ |
| 44 |
private $redirectsTablePagePresenter = null; |
| 45 |
|
| 46 |
/** Load a template file from includes/html/ and trim its trailing newline. */ |
| 47 |
private function tpl(string $name): string { |
| 48 |
$raw = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name, false); |
| 49 |
return rtrim((string)$raw, "\n"); |
| 50 |
} |
| 51 |
|
| 52 |
/** |
| 53 |
* @return void |
| 54 |
*/ |
| 55 |
public function echoAdminRedirectsPage() { |
| 56 |
echo $this->pagePresenter()->render(); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Echo the modern Add Redirect modal. |
| 61 |
* |
| 62 |
* @param array<string, mixed> $tableOptions |
| 63 |
* @return void |
| 64 |
*/ |
| 65 |
public function echoAddRedirectModal($tableOptions) { |
| 66 |
echo $this->addModalPresenter()->render($tableOptions); |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Compute (and remember) the table-data signature for the redirects or |
| 71 |
* captured tab WITHOUT rendering the table HTML, status counts, or |
| 72 |
* pagination. |
| 73 |
* |
| 74 |
* This is the renderless, READ-ONLY path the background detect-only refresh |
| 75 |
* uses (report.md Finding 3, report3.md Finding 2). It is not metadata-only: |
| 76 |
* it runs the SAME one-page read the full render runs -- getTableOptions -> |
| 77 |
* getRedirectsForView -> rememberTableDataSignature -- so the signature it |
| 78 |
* returns is byte-identical to the one a full render would stamp (same rows, |
| 79 |
* same live resolution). What it skips is all the foreground work an idle |
| 80 |
* poll has no use for: the HTML build, the status-count aggregates, the two |
| 81 |
* pagination-link builds, AND the live denorm write-back (suppressed via the |
| 82 |
* _abj404_suppress_denorm_writeback flag below) -- a change-detection probe |
| 83 |
* must observe, never mutate. The full render still persists, so the stored |
| 84 |
* denorm values stay fresh. Both tabs read through getRedirectsForView, so |
| 85 |
* this one method serves both subpages. |
| 86 |
* |
| 87 |
* @param string $sub 'abj404_redirects' or 'abj404_captured'. |
| 88 |
* @param array<string, mixed> $tableOptionOverrides Internal per-request read options. |
| 89 |
* @return string The remembered signature for $sub. |
| 90 |
*/ |
| 91 |
public function computeTableDataSignature($sub, array $tableOptionOverrides = array()) { |
| 92 |
$tableOptions = $this->logic->settingsUpdate()->getTableOptions($sub); |
| 93 |
$tableOptions = array_replace($tableOptions, $tableOptionOverrides); |
| 94 |
$tableOptions['_abj404_suppress_denorm_writeback'] = true; |
| 95 |
$rows = $this->viewReadService->getRedirectsForView($sub, $tableOptions); |
| 96 |
/** @var array<int, array<string, mixed>> $typedRows */ |
| 97 |
$typedRows = array_values(array_filter($rows, 'is_array')); |
| 98 |
$this->shared->rememberTableDataSignature($sub, $typedRows); |
| 99 |
return $this->shared->getCurrentTableDataSignature($sub); |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* @param string $sub |
| 104 |
* @param array<string, mixed> $tableOptionOverrides Internal per-request read options. |
| 105 |
* @return string |
| 106 |
*/ |
| 107 |
public function getAdminRedirectsPageTable($sub, array $tableOptionOverrides = array()) { |
| 108 |
$tableOptions = $this->logic->settingsUpdate()->getTableOptions($sub); |
| 109 |
$tableOptions = array_replace($tableOptions, $tableOptionOverrides); |
| 110 |
$columns = $this->buildRedirectsColumnDefs($tableOptions); |
| 111 |
|
| 112 |
$headerColumns = ABJ_404_Solution_TableRenderTranslationTracer::traceScope( |
| 113 |
'redirect_column_headers', |
| 114 |
'select all|sort URL|tooltip|header templates', |
| 115 |
fn(): string => $this->logs->getTableColumns($sub, $columns) |
| 116 |
); |
| 117 |
|
| 118 |
$rows = $this->viewReadService->getRedirectsForView($sub, $tableOptions); |
| 119 |
/** @var array<int, array<string, mixed>> $typedRedirectRows */ |
| 120 |
$typedRedirectRows = array_values(array_filter($rows, 'is_array')); |
| 121 |
$this->shared->rememberTableDataSignature($sub, $typedRedirectRows); |
| 122 |
|
| 123 |
// Bounded dead-destination check: pass only the ids on this page so the |
| 124 |
// rollup read scales with the page size (~25), never the whole table. |
| 125 |
$displayedRedirectIds = array(); |
| 126 |
foreach ($typedRedirectRows as $row) { |
| 127 |
if (isset($row['id']) && is_scalar($row['id'])) { |
| 128 |
$displayedRedirectIds[] = (int) $row['id']; |
| 129 |
} |
| 130 |
} |
| 131 |
$deadDestIds = abj_service('redirect_dead_destination_checker')->findDeadDestinationIds($displayedRedirectIds); |
| 132 |
|
| 133 |
$displayed = 0; |
| 134 |
$y = 1; |
| 135 |
$bodyRows = ''; |
| 136 |
$progress = ABJ_404_Solution_AjaxRowLoopProgress::begin('redirects_rows', count($typedRedirectRows)); |
| 137 |
foreach ($typedRedirectRows as $row) { |
| 138 |
$progress->tick($row); |
| 139 |
$bodyRows .= $this->buildRedirectRowHTML($row, $sub, $tableOptions, $deadDestIds, $y); |
| 140 |
$y = ($y === 0) ? 1 : 0; |
| 141 |
$displayed++; |
| 142 |
} |
| 143 |
$progress->finish(); |
| 144 |
if ($displayed == 0) { |
| 145 |
// The single-table denorm read (denorm Step 3b) is always complete |
| 146 |
// and serveable, so zero displayed rows is a genuinely empty |
| 147 |
// listing. There is no "still preparing" state to distinguish. |
| 148 |
$bodyRows .= ABJ_404_Solution_TableRenderTranslationTracer::traceScope( |
| 149 |
'redirect_empty_state', |
| 150 |
'No Redirect Records To Display|Redirects will appear here once created', |
| 151 |
fn(): string => $this->f->str_replace( |
| 152 |
array('{title}', '{help}'), |
| 153 |
array( |
| 154 |
__('No Redirect Records To Display', '404-solution'), |
| 155 |
__('Redirects will appear here once created.', '404-solution'), |
| 156 |
), |
| 157 |
$this->tpl('viewRedirectsTableRedirectsEmptyState.html') |
| 158 |
) |
| 159 |
); |
| 160 |
} |
| 161 |
|
| 162 |
return $this->f->str_replace( |
| 163 |
array('{header_columns}', '{body_rows}'), |
| 164 |
array($headerColumns, $bodyRows), |
| 165 |
$this->tpl('viewRedirectsTableRedirectsTableShell.html') |
| 166 |
); |
| 167 |
} |
| 168 |
|
| 169 |
/** |
| 170 |
* @param array<string, mixed> $tableOptions |
| 171 |
* @return array<string, array<string, string>> |
| 172 |
*/ |
| 173 |
public function buildRedirectsColumnDefs(array $tableOptions): array { |
| 174 |
return $this->columns()->build($tableOptions); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* @param array<string, mixed> $row |
| 179 |
* @param string $sub |
| 180 |
* @param array<string, mixed> $tableOptions |
| 181 |
* @param array<mixed> $deadDestIds |
| 182 |
* @param int $y |
| 183 |
* @return string |
| 184 |
*/ |
| 185 |
public function buildRedirectRowHTML(array $row, string $sub, array $tableOptions, array $deadDestIds, int $y): string { |
| 186 |
return $this->rowPresenter()->render($row, $sub, $tableOptions, $deadDestIds, $y); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* @param ABJ_404_Solution_RedirectDestinationWarningContext $ctx |
| 191 |
* @return array{exists: string, notExists: string, text: string, destForView: string} |
| 192 |
*/ |
| 193 |
public function resolveDestinationWarnings(ABJ_404_Solution_RedirectDestinationWarningContext $ctx): array { |
| 194 |
return $this->warningPolicy()->resolve($ctx->row, $ctx->rowType, $ctx->rowFinalDest, |
| 195 |
$ctx->destForView, $ctx->destinationIsMissing, $ctx->deadDestIds); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* @param array<string, string> $replacements |
| 200 |
* @return string |
| 201 |
*/ |
| 202 |
public function fillRedirectRowTemplate(array $replacements): string { |
| 203 |
return $this->rowPresenter()->fillRedirectRowTemplate($replacements); |
| 204 |
} |
| 205 |
|
| 206 |
/** |
| 207 |
* @param mixed $rawScore |
| 208 |
* @param string $rowEngine |
| 209 |
* @param int $rowStatus |
| 210 |
* @return string |
| 211 |
*/ |
| 212 |
public function buildScoreCell($rawScore, string $rowEngine, int $rowStatus): string { |
| 213 |
return $this->rowPresenter()->buildScoreCell($rawScore, $rowEngine, $rowStatus); |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* @param mixed $rowType |
| 218 |
* @param string $rowFinalDest |
| 219 |
* @return array{link: string, title: string} |
| 220 |
*/ |
| 221 |
public function resolveRedirectDestLink($rowType, string $rowFinalDest): array { |
| 222 |
return $this->linkResolver()->resolve($rowType, $rowFinalDest); |
| 223 |
} |
| 224 |
|
| 225 |
private function columns(): ABJ_404_Solution_RedirectsTableColumns { |
| 226 |
if (!($this->redirectsTableColumns instanceof ABJ_404_Solution_RedirectsTableColumns)) { |
| 227 |
$this->redirectsTableColumns = new ABJ_404_Solution_RedirectsTableColumns(); |
| 228 |
} |
| 229 |
return $this->redirectsTableColumns; |
| 230 |
} |
| 231 |
|
| 232 |
private function warningPolicy(): ABJ_404_Solution_RedirectDestinationWarningPolicy { |
| 233 |
if (!($this->destinationWarningPolicy instanceof ABJ_404_Solution_RedirectDestinationWarningPolicy)) { |
| 234 |
$this->destinationWarningPolicy = new ABJ_404_Solution_RedirectDestinationWarningPolicy(); |
| 235 |
} |
| 236 |
return $this->destinationWarningPolicy; |
| 237 |
} |
| 238 |
|
| 239 |
private function linkResolver(): ABJ_404_Solution_RedirectDestinationLinkResolver { |
| 240 |
if (!($this->destinationLinkResolver instanceof ABJ_404_Solution_RedirectDestinationLinkResolver)) { |
| 241 |
$this->destinationLinkResolver = new ABJ_404_Solution_RedirectDestinationLinkResolver($this->logger); |
| 242 |
} |
| 243 |
return $this->destinationLinkResolver; |
| 244 |
} |
| 245 |
|
| 246 |
private function rowPresenter(): ABJ_404_Solution_RedirectRowPresenter { |
| 247 |
if (!($this->redirectRowPresenter instanceof ABJ_404_Solution_RedirectRowPresenter)) { |
| 248 |
$this->redirectRowPresenter = new ABJ_404_Solution_RedirectRowPresenter( |
| 249 |
$this->f, |
| 250 |
$this->warningPolicy(), |
| 251 |
$this->linkResolver(), |
| 252 |
new ABJ_404_Solution_RedirectRowActionsPresenter($this->f, $this->shared) |
| 253 |
); |
| 254 |
} |
| 255 |
return $this->redirectRowPresenter; |
| 256 |
} |
| 257 |
|
| 258 |
private function addModalPresenter(): ABJ_404_Solution_RedirectAddModalPresenter { |
| 259 |
if (!($this->redirectAddModalPresenter instanceof ABJ_404_Solution_RedirectAddModalPresenter)) { |
| 260 |
$this->redirectAddModalPresenter = new ABJ_404_Solution_RedirectAddModalPresenter( |
| 261 |
$this->f, |
| 262 |
$this->optionsPresenter, |
| 263 |
$this->redirectTypeUI, |
| 264 |
$this->redirectConditions |
| 265 |
); |
| 266 |
} |
| 267 |
return $this->redirectAddModalPresenter; |
| 268 |
} |
| 269 |
|
| 270 |
private function pagePresenter(): ABJ_404_Solution_RedirectsTablePagePresenter { |
| 271 |
if (!($this->redirectsTablePagePresenter instanceof ABJ_404_Solution_RedirectsTablePagePresenter)) { |
| 272 |
$this->redirectsTablePagePresenter = new ABJ_404_Solution_RedirectsTablePagePresenter( |
| 273 |
$this->f, |
| 274 |
$this->logic, |
| 275 |
$this->listTableChrome, |
| 276 |
$this->addModalPresenter() |
| 277 |
); |
| 278 |
} |
| 279 |
return $this->redirectsTablePagePresenter; |
| 280 |
} |
| 281 |
} |
| 282 |
|