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

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

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