PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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 / view / View_Shared.php

View_Shared.php in 404 Solution 4.3.3, at includes/view/View_Shared.php

420 lines 15.5 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 * 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