| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* ViewTrait_Shared methods. |
| 9 |
*/ |
| 10 |
class ABJ_404_Solution_View_Shared extends ABJ_404_Solution_ViewComponent { |
| 11 |
|
| 12 |
/** @var array<string,string> Latest table data signatures by subpage. */ |
| 13 |
protected $tableDataSignatures = array(); |
| 14 |
|
| 15 |
/** |
| 16 |
* Sanitize a GET or POST parameter. |
| 17 |
* |
| 18 |
* View-layer callers go through this method rather than |
| 19 |
* ABJ_404_Solution_RequestInputNormalizer directly so the View classes |
| 20 |
* stay decoupled from where request-parameter reading lives. |
| 21 |
* |
| 22 |
* @param string $name The parameter name. |
| 23 |
* @param string|null $defaultValue Default value when not found. |
| 24 |
* @return string |
| 25 |
*/ |
| 26 |
public function viewGetPostOrGetSanitize($name, $defaultValue = null) { |
| 27 |
$result = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize($name, $defaultValue); |
| 28 |
return is_string($result) ? $result : (is_scalar($result) ? (string)$result : ''); |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Normalize a scalar value for table signature comparisons. |
| 33 |
* |
| 34 |
* @param mixed $value |
| 35 |
* @return string |
| 36 |
*/ |
| 37 |
public function normalizeSignatureValue($value) { |
| 38 |
if ($value === null) { |
| 39 |
return ''; |
| 40 |
} |
| 41 |
if (is_bool($value)) { |
| 42 |
return $value ? '1' : '0'; |
| 43 |
} |
| 44 |
if (is_int($value) || is_float($value)) { |
| 45 |
return (string)$value; |
| 46 |
} |
| 47 |
if (is_array($value)) { |
| 48 |
$value = implode(',', array_map(array($this, 'normalizeSignatureValue'), $value)); |
| 49 |
} |
| 50 |
$text = is_scalar($value) ? (string)$value : ''; |
| 51 |
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 52 |
$text = preg_replace('/\s+/', ' ', $text); |
| 53 |
return trim((string)$text); |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* Build a deterministic row signature payload for a specific admin list subpage. |
| 58 |
* |
| 59 |
* @param string $sub |
| 60 |
* @param array<string, mixed> $row |
| 61 |
* @return array<string,string> |
| 62 |
*/ |
| 63 |
public function getSignatureFieldsForSubpage($sub, $row) { |
| 64 |
$sub = (string)$sub; |
| 65 |
|
| 66 |
if ($sub === 'abj404_redirects') { |
| 67 |
return array( |
| 68 |
'id' => $this->normalizeSignatureValue($row['id'] ?? ''), |
| 69 |
'url' => $this->normalizeSignatureValue($row['url'] ?? ''), |
| 70 |
'status' => $this->normalizeSignatureValue($row['status'] ?? ''), |
| 71 |
'type' => $this->normalizeSignatureValue($row['type'] ?? ''), |
| 72 |
'final_dest' => $this->normalizeSignatureValue($row['final_dest'] ?? ''), |
| 73 |
'dest_for_view' => $this->normalizeSignatureValue($row['dest_for_view'] ?? ''), |
| 74 |
'code' => $this->normalizeSignatureValue($row['code'] ?? ''), |
| 75 |
'logshits' => $this->normalizeSignatureValue($row['logshits'] ?? 0), |
| 76 |
'timestamp' => $this->normalizeSignatureValue($row['timestamp'] ?? 0), |
| 77 |
'last_used' => $this->normalizeSignatureValue($row['last_used'] ?? 0), |
| 78 |
); |
| 79 |
} |
| 80 |
|
| 81 |
if ($sub === 'abj404_captured') { |
| 82 |
$hits = array_key_exists('logshits', $row) ? $row['logshits'] : ($row['hit_count'] ?? 0); |
| 83 |
$timestamp = array_key_exists('timestamp', $row) ? $row['timestamp'] : ($row['created'] ?? 0); |
| 84 |
return array( |
| 85 |
'id' => $this->normalizeSignatureValue($row['id'] ?? ''), |
| 86 |
'url' => $this->normalizeSignatureValue($row['url'] ?? ''), |
| 87 |
'status' => $this->normalizeSignatureValue($row['status'] ?? ''), |
| 88 |
'logshits' => $this->normalizeSignatureValue($hits), |
| 89 |
'timestamp' => $this->normalizeSignatureValue($timestamp), |
| 90 |
'last_used' => $this->normalizeSignatureValue($row['last_used'] ?? 0), |
| 91 |
); |
| 92 |
} |
| 93 |
|
| 94 |
if ($sub === 'abj404_logs') { |
| 95 |
return array( |
| 96 |
'id' => $this->normalizeSignatureValue($row['id'] ?? ''), |
| 97 |
'url' => $this->normalizeSignatureValue($row['url'] ?? ''), |
| 98 |
'url_detail' => $this->normalizeSignatureValue($row['url_detail'] ?? ''), |
| 99 |
'remote_host' => $this->normalizeSignatureValue($row['remote_host'] ?? ''), |
| 100 |
'referrer' => $this->normalizeSignatureValue($row['referrer'] ?? ''), |
| 101 |
'action' => $this->normalizeSignatureValue($row['action'] ?? ''), |
| 102 |
'timestamp' => $this->normalizeSignatureValue($row['timestamp'] ?? 0), |
| 103 |
'username' => $this->normalizeSignatureValue($row['username'] ?? ''), |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
$normalized = array(); |
| 108 |
foreach ($row as $k => $v) { |
| 109 |
if (is_scalar($v) || is_array($v) || $v === null) { |
| 110 |
$normalized[(string)$k] = $this->normalizeSignatureValue($v); |
| 111 |
} |
| 112 |
} |
| 113 |
ksort($normalized); |
| 114 |
return $normalized; |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Compute and remember a deterministic table signature for detect-only refresh checks. |
| 119 |
* |
| 120 |
* @param string $sub |
| 121 |
* @param array<int, array<string, mixed>> $rows |
| 122 |
* @return void |
| 123 |
*/ |
| 124 |
public function rememberTableDataSignature($sub, $rows) { |
| 125 |
$sub = (string)$sub; |
| 126 |
if (!is_array($rows)) { |
| 127 |
$this->tableDataSignatures[$sub] = sha1($sub . '|0'); |
| 128 |
return; |
| 129 |
} |
| 130 |
|
| 131 |
$rowSignatures = array(); |
| 132 |
foreach ($rows as $row) { |
| 133 |
$fields = $this->getSignatureFieldsForSubpage($sub, $row); |
| 134 |
$parts = array(); |
| 135 |
foreach ($fields as $k => $v) { |
| 136 |
$parts[] = $k . '=' . $v; |
| 137 |
} |
| 138 |
$rowSignatures[] = implode("\x1f", $parts); |
| 139 |
} |
| 140 |
sort($rowSignatures, SORT_STRING); |
| 141 |
$payload = $sub . '|' . count($rowSignatures) . '|' . implode("\n", $rowSignatures); |
| 142 |
$this->tableDataSignatures[$sub] = sha1($payload); |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Get the most recently computed table data signature for a subpage. |
| 147 |
* |
| 148 |
* @param string $sub |
| 149 |
* @return string |
| 150 |
*/ |
| 151 |
public function getCurrentTableDataSignature($sub) { |
| 152 |
$sub = (string)$sub; |
| 153 |
return (string)($this->tableDataSignatures[$sub] ?? ''); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Build shared sort state for table headers. |
| 158 |
* |
| 159 |
* @param array<string, mixed> $tableOptions |
| 160 |
* @param string $orderby |
| 161 |
* @param bool $preferDescOnFirstClick |
| 162 |
* @return array{isSortable:bool,thClass:string,nextOrder:string,indicator:string} |
| 163 |
*/ |
| 164 |
public function getHeaderSortState($tableOptions, $orderby, $preferDescOnFirstClick = false) { |
| 165 |
$result = array( |
| 166 |
'isSortable' => false, |
| 167 |
'thClass' => '', |
| 168 |
'nextOrder' => 'ASC', |
| 169 |
'indicator' => '', |
| 170 |
); |
| 171 |
|
| 172 |
$orderby = (string)$orderby; |
| 173 |
if ($orderby === '') { |
| 174 |
return $result; |
| 175 |
} |
| 176 |
|
| 177 |
$result['isSortable'] = true; |
| 178 |
$rawCurrentOrderby = $tableOptions['orderby'] ?? ''; |
| 179 |
$currentOrderby = is_string($rawCurrentOrderby) ? $rawCurrentOrderby : ''; |
| 180 |
$rawCurrentOrder = $tableOptions['order'] ?? 'ASC'; |
| 181 |
$currentOrder = strtoupper(is_string($rawCurrentOrder) ? $rawCurrentOrder : 'ASC'); |
| 182 |
if ($currentOrder !== 'DESC') { |
| 183 |
$currentOrder = 'ASC'; |
| 184 |
} |
| 185 |
|
| 186 |
if ($currentOrderby === $orderby) { |
| 187 |
$result['thClass'] = 'sorted ' . strtolower($currentOrder); |
| 188 |
$result['nextOrder'] = ($currentOrder === 'ASC') ? 'DESC' : 'ASC'; |
| 189 |
$result['indicator'] = ($currentOrder === 'ASC') ? ' ↑' : ' ↓'; |
| 190 |
return $result; |
| 191 |
} |
| 192 |
|
| 193 |
$result['thClass'] = 'sortable ' . ($preferDescOnFirstClick ? 'asc' : 'desc'); |
| 194 |
$result['nextOrder'] = $preferDescOnFirstClick ? 'DESC' : 'ASC'; |
| 195 |
return $result; |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* The hover-tooltip text for a URL / Destination header whose narrow sort key |
| 200 |
* cannot yet be served index-ordered, or '' when the sort is available now, |
| 201 |
* the column is not sort-key-backed, or no readiness service was supplied. |
| 202 |
* |
| 203 |
* Single source of truth for the pending-sort message, shared by both header |
| 204 |
* renderers (ABJ_404_Solution_AdminTableColumnHeaders for the Page Redirects |
| 205 |
* tab and ABJ_404_Solution_View_CapturedURLsTable for the captured tab) so the |
| 206 |
* two cannot drift. Each renderer still decides WHICH tabs the gate applies to |
| 207 |
* (the Logs tab sorts a different table) and how to render the non-sortable |
| 208 |
* cell; this only owns the readiness + percentage + message string. Readiness |
| 209 |
* and the build percentage come from the centralized predicate |
| 210 |
* (RedirectsDenormSchemaReadiness::sortKeyReadyForColumn via the read service). |
| 211 |
* |
| 212 |
* @param string $orderby UI orderby alias (url, dest, final_dest, ...). |
| 213 |
* @param ABJ_404_Solution_ViewReadServiceInterface|null $viewReadService |
| 214 |
* @return string |
| 215 |
*/ |
| 216 |
public function pendingSortTooltipText(string $orderby, $viewReadService): string { |
| 217 |
if ($viewReadService === null) { |
| 218 |
return ''; |
| 219 |
} |
| 220 |
if ($orderby !== 'url' && $orderby !== 'dest' && $orderby !== 'final_dest') { |
| 221 |
return ''; |
| 222 |
} |
| 223 |
$status = method_exists($viewReadService, 'sortReadinessStatusForOrderby') |
| 224 |
? $viewReadService->sortReadinessStatusForOrderby($orderby) |
| 225 |
: ($viewReadService->isSortReadyForOrderby($orderby) |
| 226 |
? ABJ_404_Solution_ViewReadServiceInterface::SORT_READINESS_READY |
| 227 |
: ABJ_404_Solution_ViewReadServiceInterface::SORT_READINESS_BACKFILL_PENDING); |
| 228 |
if ($status === ABJ_404_Solution_ViewReadServiceInterface::SORT_READINESS_READY) { |
| 229 |
return ''; |
| 230 |
} |
| 231 |
if ($status === ABJ_404_Solution_ViewReadServiceInterface::SORT_READINESS_SCHEMA_UNAVAILABLE) { |
| 232 |
return __('Sorting by this column is unavailable on this site. The list shows newest first.', '404-solution'); |
| 233 |
} |
| 234 |
$percent = $viewReadService->sortBackfillPercentForOrderby($orderby); |
| 235 |
return sprintf( |
| 236 |
/* translators: %d: index-build completion percentage */ |
| 237 |
__('Sorting by this column is being prepared for your number of URLs (%d%% complete). The list shows newest first until it is ready.', '404-solution'), |
| 238 |
$percent |
| 239 |
); |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Build action links for table rows (edit, logs, trash, delete, etc.) |
| 244 |
* |
| 245 |
* @param array<string, mixed> $row The data row from the database |
| 246 |
* @param string $sub The subpage parameter value |
| 247 |
* @param array<string, mixed> $tableOptions Table options including filter, orderby, order |
| 248 |
* @param bool $isCapturedPage True for captured URLs page, false for redirects page |
| 249 |
* @return array<string, string> Array of links and titles |
| 250 |
*/ |
| 251 |
public function buildTableActionLinks($row, $sub, $tableOptions, $isCapturedPage = false) { |
| 252 |
$sub = rawurlencode($sub); |
| 253 |
$ids = $this->resolveTableActionIds($row, $isCapturedPage); |
| 254 |
$result = $this->buildBaseTableActionLinks($ids['id'], $ids['logsId'], $ids['rawId'], $sub, $isCapturedPage); |
| 255 |
$options = $this->extractTableActionOptions($tableOptions); |
| 256 |
|
| 257 |
$result = $this->applyTrashAction($result, $options['filter']); |
| 258 |
if ($isCapturedPage) { |
| 259 |
$result = $this->applyCapturedPageActionLinks($result, $ids['id'], $sub, $options['filter']); |
| 260 |
} |
| 261 |
|
| 262 |
$result = $this->appendTableActionQueryArgs($result, $options, $isCapturedPage); |
| 263 |
return $this->applyTableActionNonces($result, $options['filter'], $isCapturedPage); |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* @param array<string, mixed> $row |
| 268 |
* @return array{id: mixed, logsId: mixed, rawId: mixed} |
| 269 |
*/ |
| 270 |
private function resolveTableActionIds(array $row, bool $isCapturedPage): array { |
| 271 |
$rawId = $row['id'] ?? 0; |
| 272 |
$rawLogsId = $row['logsid'] ?? 0; |
| 273 |
if ($isCapturedPage) { |
| 274 |
return ['id' => $rawId, 'logsId' => $rawLogsId, 'rawId' => $rawId]; |
| 275 |
} |
| 276 |
|
| 277 |
return [ |
| 278 |
'id' => absint(is_scalar($rawId) ? $rawId : 0), |
| 279 |
'logsId' => absint(is_scalar($rawLogsId) ? $rawLogsId : 0), |
| 280 |
'rawId' => $rawId, |
| 281 |
]; |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* @param mixed $id |
| 286 |
* @param mixed $logsId |
| 287 |
* @param mixed $rawId |
| 288 |
* @return array<string, string> |
| 289 |
*/ |
| 290 |
private function buildBaseTableActionLinks($id, $logsId, $rawId, string $sub, bool $isCapturedPage): array { |
| 291 |
$result = []; |
| 292 |
$result['editlink'] = "?page=" . ABJ404_PP . "&subpage=abj404_edit&id=" . $id . "&source_page=" . $sub; |
| 293 |
$result['logslink'] = "?page=" . ABJ404_PP . "&subpage=abj404_logs&id=" . $logsId; |
| 294 |
$result['trashlink'] = "?page=" . ABJ404_PP . "&id=" . $id . "&subpage=" . $sub; |
| 295 |
$result['deletelink'] = "?page=" . ABJ404_PP . "&remove=1&id=" . $id . "&subpage=" . $sub; |
| 296 |
$ajaxId = $isCapturedPage ? absint(is_scalar($rawId) ? $rawId : 0) : $id; |
| 297 |
$result['ajaxTrashLink'] = "admin-ajax.php?action=trashLink&id=" . $ajaxId . "&subpage=" . $sub; |
| 298 |
return $result; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* @param array<string, mixed> $tableOptions |
| 303 |
* @return array{orderby: string, order: string, filter: mixed, paged: mixed} |
| 304 |
*/ |
| 305 |
private function extractTableActionOptions(array $tableOptions): array { |
| 306 |
$rawFilter = array_key_exists('filter', $tableOptions) ? $tableOptions['filter'] : 0; |
| 307 |
$rawPaged = array_key_exists('paged', $tableOptions) ? $tableOptions['paged'] : 0; |
| 308 |
return [ |
| 309 |
'orderby' => array_key_exists('orderby', $tableOptions) && is_string($tableOptions['orderby']) ? $tableOptions['orderby'] : '', |
| 310 |
'order' => array_key_exists('order', $tableOptions) && is_string($tableOptions['order']) ? $tableOptions['order'] : '', |
| 311 |
'filter' => is_scalar($rawFilter) ? $rawFilter : 0, |
| 312 |
'paged' => is_scalar($rawPaged) ? max(0, intval($rawPaged)) : 0, |
| 313 |
]; |
| 314 |
} |
| 315 |
|
| 316 |
/** |
| 317 |
* @param array<string, string> $result |
| 318 |
* @param mixed $toFilter |
| 319 |
* @return array<string, string> |
| 320 |
*/ |
| 321 |
private function applyTrashAction(array $result, $toFilter): array { |
| 322 |
if ($toFilter == ABJ404_TRASH_FILTER) { |
| 323 |
$result['trashlink'] .= "&trash=0"; |
| 324 |
$result['ajaxTrashLink'] .= "&trash=0"; |
| 325 |
$result['trashtitle'] = __('Restore', '404-solution'); |
| 326 |
return $result; |
| 327 |
} |
| 328 |
|
| 329 |
$result['trashlink'] .= "&trash=1"; |
| 330 |
$result['ajaxTrashLink'] .= "&trash=1"; |
| 331 |
$result['trashtitle'] = __('Trash', '404-solution'); |
| 332 |
return $result; |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* @param array<string, string> $result |
| 337 |
* @param mixed $id |
| 338 |
* @param mixed $toFilter |
| 339 |
* @return array<string, string> |
| 340 |
*/ |
| 341 |
private function applyCapturedPageActionLinks(array $result, $id, string $sub, $toFilter): array { |
| 342 |
$result['ignorelink'] = "?page=" . ABJ404_PP . "&id=" . $id . "&subpage=" . $sub; |
| 343 |
$result['laterlink'] = "?page=" . ABJ404_PP . "&id=" . $id . "&subpage=" . $sub; |
| 344 |
$result['ignoretitle'] = $toFilter == ABJ404_STATUS_IGNORED ? __('Remove Ignore Status', '404-solution') : __('Ignore 404 Error', '404-solution'); |
| 345 |
$result['ignorelink'] .= $toFilter == ABJ404_STATUS_IGNORED ? "&ignore=0" : "&ignore=1"; |
| 346 |
$result['latertitle'] = $toFilter == ABJ404_STATUS_LATER ? __('Remove Later Status', '404-solution') : __('Organize Later', '404-solution'); |
| 347 |
$result['laterlink'] .= $toFilter == ABJ404_STATUS_LATER ? "&later=0" : "&later=1"; |
| 348 |
return $result; |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* @param array<string, string> $result |
| 353 |
* @param array{orderby: string, order: string, filter: mixed, paged: mixed} $options |
| 354 |
* @return array<string, string> |
| 355 |
*/ |
| 356 |
private function appendTableActionQueryArgs(array $result, array $options, bool $isCapturedPage): array { |
| 357 |
$sortArgs = $this->buildSortQueryArgs($options['orderby'], $options['order']); |
| 358 |
if ($sortArgs !== '') { |
| 359 |
foreach (['trashlink', 'deletelink', 'editlink'] as $key) { |
| 360 |
$result[$key] .= $sortArgs; |
| 361 |
} |
| 362 |
if ($isCapturedPage) { |
| 363 |
$result['ignorelink'] .= $sortArgs; |
| 364 |
$result['laterlink'] .= $sortArgs; |
| 365 |
} |
| 366 |
} |
| 367 |
|
| 368 |
if ($options['filter'] != 0) { |
| 369 |
$result = $this->appendFilterQueryArgs($result, $options['filter'], $isCapturedPage); |
| 370 |
} |
| 371 |
if ($options['paged'] > 1) { |
| 372 |
$result['editlink'] .= "&paged=" . $options['paged']; |
| 373 |
} |
| 374 |
return $result; |
| 375 |
} |
| 376 |
|
| 377 |
private function buildSortQueryArgs(string $toOrderby, string $toOrder): string { |
| 378 |
if ($toOrderby === '' || $toOrder === '' || ($toOrderby == "url" && $toOrder == "ASC")) { |
| 379 |
return ''; |
| 380 |
} |
| 381 |
return "&orderby=" . sanitize_text_field($toOrderby) . "&order=" . sanitize_text_field($toOrder); |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* @param array<string, string> $result |
| 386 |
* @param mixed $toFilter |
| 387 |
* @return array<string, string> |
| 388 |
*/ |
| 389 |
private function appendFilterQueryArgs(array $result, $toFilter, bool $isCapturedPage): array { |
| 390 |
foreach (['trashlink', 'deletelink', 'editlink'] as $key) { |
| 391 |
$result[$key] .= "&filter=" . $toFilter; |
| 392 |
} |
| 393 |
if ($isCapturedPage) { |
| 394 |
$result['ignorelink'] .= "&filter=" . $toFilter; |
| 395 |
$result['laterlink'] .= "&filter=" . $toFilter; |
| 396 |
} |
| 397 |
return $result; |
| 398 |
} |
| 399 |
|
| 400 |
/** |
| 401 |
* @param array<string, string> $result |
| 402 |
* @param mixed $toFilter |
| 403 |
* @return array<string, string> |
| 404 |
*/ |
| 405 |
private function applyTableActionNonces(array $result, $toFilter, bool $isCapturedPage): array { |
| 406 |
$result['trashlink'] = wp_nonce_url($result['trashlink'], "abj404_trashRedirect"); |
| 407 |
$result['ajaxTrashLink'] = wp_nonce_url($result['ajaxTrashLink'], "abj404_ajaxTrash"); |
| 408 |
if ($toFilter == ABJ404_TRASH_FILTER) { |
| 409 |
$result['deletelink'] = wp_nonce_url($result['deletelink'], "abj404_removeRedirect"); |
| 410 |
} |
| 411 |
if ($isCapturedPage) { |
| 412 |
$result['ignorelink'] = wp_nonce_url($result['ignorelink'], "abj404_ignore404"); |
| 413 |
$result['laterlink'] = wp_nonce_url($result['laterlink'], "abj404_organizeLater"); |
| 414 |
} |
| 415 |
return $result; |
| 416 |
} |
| 417 |
|
| 418 |
|
| 419 |
} |
| 420 |
|