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 / ViewTrait_RedirectsTable.php

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

1,624 lines 90.0 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 * Redirects and captured URLs table/list pages.
9 */
10 trait ViewTrait_RedirectsTable {
11
12 /** @return void */
13 function echoAdminCapturedURLsPage() {
14 $sub = 'abj404_captured';
15
16 $tableOptions = $this->logic->getTableOptions($sub);
17
18 // Modern page wrapper
19 echo '<div class="abj404-table-page">';
20
21 // Header with page title
22 $isSimpleMode = $this->logic->getSettingsMode() === 'simple';
23 echo '<div class="abj404-table-header">';
24 echo '<div class="abj404-table-header-text">';
25 echo '<h2>' . __('Captured 404 URLs', '404-solution') . '</h2>';
26 if ($isSimpleMode) {
27 echo '<p class="abj404-guidance-subtitle">'
28 . esc_html__('Broken links visitors tried to reach. Create Redirect for important ones, Dismiss the rest.', '404-solution')
29 . '</p>';
30 }
31 echo '</div>';
32 echo '</div>';
33
34 // Content tabs — counts are placeholders, populated via AJAX
35 echo '<div class="abj404-content-tabs" data-tab-counts-placeholder="1">';
36 if ($isSimpleMode) {
37 // Simple mode: two tabs — "Needs Review" and "Handled"
38 $this->echoContentTab('abj404_captured', ABJ404_STATUS_CAPTURED, __('Needs Review', '404-solution'), '…', $tableOptions);
39 $this->echoContentTab('abj404_captured', ABJ404_HANDLED_FILTER, __('Handled', '404-solution'), '…', $tableOptions);
40 } else {
41 // Advanced mode: full 5-tab layout
42 $this->echoContentTab('abj404_captured', 0, __('All', '404-solution'), '…', $tableOptions);
43 $this->echoContentTab('abj404_captured', ABJ404_STATUS_CAPTURED, __('Captured', '404-solution'), '…', $tableOptions);
44 $this->echoContentTab('abj404_captured', ABJ404_STATUS_IGNORED, __('Ignored', '404-solution'), '…', $tableOptions);
45 $this->echoContentTab('abj404_captured', ABJ404_STATUS_LATER, __('Later', '404-solution'), '…', $tableOptions);
46 $this->echoContentTab('abj404_captured', ABJ404_TRASH_FILTER, __('Trash', '404-solution'), '…', $tableOptions);
47 }
48 echo '</div>';
49
50 // Filter bar with server-side search
51 $filterText = is_string($tableOptions['filterText'] ?? '') ? (string)($tableOptions['filterText'] ?? '') : '';
52 $perPage = isset($tableOptions['perpage']) ? $tableOptions['perpage'] : 25;
53
54 $paginationNonce = wp_create_nonce('abj404_updatePaginationLink');
55 $inflightNonce = wp_create_nonce('abj404_fetchInflightStage');
56 $autoRefresh = '1'; // $sub is always 'abj404_captured' here
57 $rawFilter = $tableOptions['filter'] ?? 0;
58 $currentFilter = is_scalar($rawFilter) ? $rawFilter : 0;
59 $rawOrderBy = $tableOptions['orderby'] ?? '';
60 $currentOrderBy = is_string($rawOrderBy) ? $rawOrderBy : 'url';
61 $rawOrder = $tableOptions['order'] ?? '';
62 $currentOrder = is_string($rawOrder) ? $rawOrder : 'ASC';
63 $rawPaged = $tableOptions['paged'] ?? 1;
64 $currentPaged = is_scalar($rawPaged) ? intval($rawPaged) : 1;
65 if ($currentPaged < 1) {
66 $currentPaged = 1;
67 }
68 $rawScoreRange = $tableOptions['score_range'] ?? 'all';
69 $currentScoreRange = is_string($rawScoreRange) ? $rawScoreRange : 'all';
70
71 echo '<div class="abj404-filter-bar tablenav"'
72 . ' data-pagination-ajax-url="' . esc_attr(admin_url('admin-ajax.php')) . '"'
73 . ' data-pagination-ajax-action="ajaxUpdatePaginationLinks"'
74 . ' data-pagination-ajax-subpage="' . esc_attr($sub) . '"'
75 . ' data-pagination-ajax-nonce="' . esc_attr($paginationNonce) . '"'
76 . ' data-pagination-inflight-nonce="' . esc_attr($inflightNonce) . '"'
77 . ' data-pagination-current-signature=""'
78 . ' data-pagination-current-orderby="' . esc_attr($currentOrderBy) . '"'
79 . ' data-pagination-current-order="' . esc_attr($currentOrder) . '"'
80 . ' data-pagination-current-filter="' . esc_attr((string)$currentFilter) . '"'
81 . ' data-pagination-current-paged="' . esc_attr((string)$currentPaged) . '"'
82 . ' data-pagination-current-score-range="' . esc_attr($currentScoreRange) . '"'
83 . ' data-pagination-current-logsid=""'
84 . ' data-pagination-initial-load="1"'
85 . ' data-pagination-auto-refresh="' . esc_attr($autoRefresh) . '"'
86 . ' data-pagination-refresh-started-text="' . esc_attr(__('Refreshing data in background…', '404-solution')) . '"'
87 . ' data-pagination-refresh-finished-text="' . esc_attr(__('Data refreshed', '404-solution')) . '"'
88 . ' data-pagination-refresh-available-text="' . esc_attr(__('Refresh available', '404-solution')) . '">';
89 echo '<div class="abj404-search-box">';
90 echo '<input type="search" name="searchFilter" placeholder="' . esc_attr__('Type to filter URLs... (press Enter)', '404-solution') . '" value="' . esc_attr($filterText) . '" data-lpignore="true">';
91 echo '</div>';
92 echo '<div class="abj404-rows-per-page">';
93 echo '<span>' . esc_html__('Rows per page:', '404-solution') . '</span>';
94 echo '<select class="abj404-filter-select perpage" name="perpage" onchange="paginationLinksChange(this);">';
95 foreach ([10, 25, 50, 100, 200] as $opt) {
96 $selected = ($perPage == $opt) ? ' selected' : '';
97 echo '<option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
98 }
99 echo '</select>';
100 echo '</div>';
101
102 // Empty trash button
103 if ($currentFilter == ABJ404_TRASH_FILTER) {
104 $eturl = "?page=" . ABJ404_PP . "&subpage=abj404_captured&filter=" . ABJ404_TRASH_FILTER;
105 $eturl = wp_nonce_url($eturl, 'abj404_bulkProcess');
106 echo '<a href="' . esc_url($eturl) . '&abj404action=emptyCapturedTrash" class="button abj404-empty-trash-btn" onclick="return confirm(\'' . esc_js(__('Are you sure you want to permanently delete all items in trash?', '404-solution')) . '\');">';
107 echo esc_html__('Empty Trash', '404-solution');
108 echo '</a>';
109 }
110 echo '<span class="abj404-refresh-status" aria-live="polite"></span>';
111 echo '</div>';
112
113 // Bulk actions bar
114 $url = $this->getBulkOperationsFormURL($sub, $tableOptions);
115 echo '<div class="abj404-bulk-actions">';
116 echo '<div class="abj404-selection-info"><strong>0</strong> ' . __('selected', '404-solution') . '</div>';
117 echo '<div class="abj404-bulk-buttons">';
118
119 // Bulk action buttons based on current filter and mode
120 if ($isSimpleMode) {
121 // Simple mode: just "Dismiss" and "Create Redirect"
122 echo '<button type="submit" name="abj404action" value="bulkignore" form="bulk-action-form" class="button">' . __('Dismiss', '404-solution') . '</button>';
123 echo '<button type="submit" name="abj404action" value="editRedirect" form="bulk-action-form" class="button">' . __('Create Redirect', '404-solution') . '</button>';
124 } else {
125 // Advanced mode: full set of bulk actions
126 if ($currentFilter != ABJ404_STATUS_CAPTURED) {
127 echo '<button type="submit" name="abj404action" value="bulkcaptured" form="bulk-action-form" class="button">' . __('Mark Captured', '404-solution') . '</button>';
128 }
129 if ($currentFilter != ABJ404_STATUS_IGNORED) {
130 echo '<button type="submit" name="abj404action" value="bulkignore" form="bulk-action-form" class="button">' . __('Mark Ignored', '404-solution') . '</button>';
131 }
132 if ($currentFilter != ABJ404_STATUS_LATER) {
133 echo '<button type="submit" name="abj404action" value="bulklater" form="bulk-action-form" class="button">' . __('Organize Later', '404-solution') . '</button>';
134 }
135 if ($currentFilter != ABJ404_TRASH_FILTER) {
136 echo '<button type="submit" name="abj404action" value="bulktrash" form="bulk-action-form" class="button">' . __('Move to Trash', '404-solution') . '</button>';
137 }
138 echo '<button type="submit" name="abj404action" value="editRedirect" form="bulk-action-form" class="button">' . __('Create Redirect', '404-solution') . '</button>';
139 }
140
141 echo '</div>';
142 echo '<button type="button" class="abj404-clear-selection" onclick="abj404ClearSelection()">' . __('Clear', '404-solution') . '</button>';
143 echo '</div>';
144
145 // Hidden form for bulk actions
146 echo '<form id="bulk-action-form" method="POST" action="' . esc_url($url) . '">';
147 wp_nonce_field('abj404_bulkProcess');
148
149 // Table + pagination placeholders. Full data is loaded via AJAX
150 // so initial page render is not blocked on heavy queries.
151 echo '<table class="abj404-table" data-table-awaiting-load="1">';
152 echo '<thead><tr><th>' . esc_html__('Loading captured URLs…', '404-solution') . '</th></tr></thead>';
153 echo '<tbody><tr><td class="abj404-empty-message">' . esc_html__('Loading captured URLs…', '404-solution') . '</td></tr></tbody>';
154 echo '</table>';
155
156 // Pagination placeholder (bottom only, matching original layout).
157 echo '<div class="abj404-pagination tablenav abj404-pagination-right">';
158 echo '<span class="abj404-refresh-status" aria-live="polite">' . esc_html__('Loading…', '404-solution') . '</span>';
159 echo '</div>';
160
161 echo '</form>';
162 echo '</div><!-- .abj404-table-page -->';
163 }
164
165 /**
166 * @param string $sub
167 * @return string
168 */
169 function getCapturedURLSPageTable($sub) {
170
171 $tableOptions = $this->logic->getTableOptions($sub);
172
173 // Build column headers with sorting
174 $hitsTooltip = $this->getHitsColumnTooltip($tableOptions);
175 $columns = array(
176 'url' => array('title' => __('URL', '404-solution'), 'orderby' => 'url'),
177 'status' => array('title' => __('Status', '404-solution'), 'orderby' => 'status'),
178 'hits' => array('title' => __('Hits', '404-solution'), 'orderby' => 'logshits', 'title_attr_html' => $hitsTooltip),
179 'timestamp' => array('title' => __('Created', '404-solution'), 'orderby' => 'timestamp', 'class' => 'hide-on-tablet'),
180 'last_used' => array('title' => __('Last Used', '404-solution'), 'orderby' => 'last_used', 'title_attr_html' => $hitsTooltip),
181 );
182
183 $html = '<table class="abj404-table">';
184 $html .= '<thead><tr>';
185 $html .= '<th scope="col"><input type="checkbox" id="select-all-captured" aria-label="' . esc_attr__('Select all', '404-solution') . '"></th>';
186
187 // Generate sortable column headers
188 foreach ($columns as $key => $col) {
189 $sortUrl = "?page=" . ABJ404_PP . "&subpage=abj404_captured&filter=" . ($tableOptions['filter'] ?? 0);
190 $sortUrl .= "&orderby=" . $col['orderby'];
191 $sortState = $this->getHeaderSortState($tableOptions, (string)$col['orderby'], false);
192 $newOrder = $sortState['nextOrder'];
193 $sortUrl .= "&order=" . $newOrder;
194
195 $extraClass = isset($col['class']) ? ' ' . esc_attr($col['class']) : '';
196 $sortClass = trim($sortState['thClass'] . $extraClass);
197 $sortIndicator = $sortState['indicator'];
198
199 $classAttr = $sortClass ? ' class="' . trim($sortClass) . '"' : '';
200
201 // Build tooltip HTML if present
202 $tooltipHtml = '';
203 if (isset($col['title_attr_html']) && !empty($col['title_attr_html'])) {
204 // Raw HTML (already escaped where needed)
205 $tooltipHtml = '<span class="abj404-header-tooltip lefty-tooltip" aria-label="' . esc_attr__('More info', '404-solution') . '">' .
206 '<span class="abj404-header-tooltip-icon" aria-hidden="true">?</span>' .
207 '<span class="lefty-tooltiptext">' . $col['title_attr_html'] . '</span>' .
208 '</span>';
209 }
210
211 $html .= '<th scope="col"' . $classAttr . '><a href="' . esc_url($sortUrl) . '">' . esc_html($col['title']) . $sortIndicator . '</a>' . $tooltipHtml . '</th>';
212 }
213
214 $html .= '</tr></thead>';
215 $html .= '<tbody id="the-list">';
216
217 $rows = $this->dao->getRedirectsForView($sub, $tableOptions);
218 /** @var array<int, array<string, mixed>> $typedRows */
219 $typedRows = array_values(array_filter($rows, 'is_array'));
220 $this->rememberTableDataSignature($sub, $typedRows);
221 $displayed = 0;
222
223 foreach ($typedRows as $row) {
224 $displayed++;
225
226 $hits = is_scalar($row['logshits'] ?? 0) ? (int)($row['logshits'] ?? 0) : 0;
227
228 $last_used = is_scalar($row['last_used'] ?? 0) ? (int)($row['last_used'] ?? 0) : 0;
229 $lastUsedClass = '';
230 if ($last_used != 0) {
231 $last = (string)wp_date("Y/m/d h:i:s A", abs($last_used));
232 } else {
233 $last = __('Never', '404-solution');
234 $lastUsedClass = 'abj404-never-used';
235 }
236
237 // Build action links using helper method
238 /** @var array<string, mixed> $row */
239 $links = $this->buildTableActionLinks($row, $sub, $tableOptions, true);
240 $editlink = '';
241 $logslink = '';
242 $trashlink = '';
243 $trashtitle = '';
244 $deletelink = '';
245 $ignorelink = '';
246 $ignoretitle = '';
247 $laterlink = '';
248 $latertitle = '';
249 $ajaxTrashLink = '';
250 extract($links);
251
252 // Determine status badge
253 $statusBadgeClass = 'abj404-badge-captured';
254 $statusText = __('Captured', '404-solution');
255 $statusTitle = __('Captured 404 URL', '404-solution');
256
257 if ($row['status'] == ABJ404_STATUS_IGNORED) {
258 $statusBadgeClass = 'abj404-badge-ignored';
259 $statusText = __('Ignored', '404-solution');
260 $statusTitle = __('Ignored URL - will not be suggested', '404-solution');
261 } else if ($row['status'] == ABJ404_STATUS_LATER) {
262 $statusBadgeClass = 'abj404-badge-later';
263 $statusText = __('Later', '404-solution');
264 $statusTitle = __('Organize Later', '404-solution');
265 }
266
267 // Build row action buttons
268 $editBtnHTML = '';
269 $logsBtnHTML = '';
270 $trashBtnHTML = '';
271 $deleteBtnHTML = '';
272 $ignoreBtnHTML = '';
273 $laterBtnHTML = '';
274
275 $currentFilter = $tableOptions['filter'] ?? 0;
276 $isSimpleModeRow = $this->logic->getSettingsMode() === 'simple';
277
278 if ($isSimpleModeRow) {
279 // Simple mode: just "Create Redirect" and "Dismiss"
280 $editBtnHTML = '<a href="' . esc_url($editlink) . '" class="abj404-action-link" title="' . esc_attr__('Create Redirect', '404-solution') . '">'
281 . '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg> '
282 . esc_html__('Create Redirect', '404-solution') . '</a>';
283 if ($row['status'] != ABJ404_STATUS_IGNORED) {
284 $ignoreBtnHTML = ' | <a href="' . esc_url($ignorelink) . '" class="abj404-action-link" title="' . esc_attr__('Dismiss', '404-solution') . '">'
285 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"/></svg> '
286 . esc_html__('Dismiss', '404-solution') . '</a>';
287 }
288 } else {
289 // Advanced mode: full action links
290 if ($currentFilter != ABJ404_TRASH_FILTER) {
291 $editBtnHTML = '<a href="' . esc_url($editlink) . '" class="abj404-action-link" title="' . esc_attr__('Edit', '404-solution') . '">'
292 . '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg> '
293 . esc_html__('Edit', '404-solution') . '</a>';
294 }
295
296 if (($row['logsid'] ?? 0) > 0) {
297 $logsBtnHTML = '<a href="' . esc_url($logslink) . '" class="abj404-action-link" title="' . esc_attr__('View Logs', '404-solution') . '">'
298 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg> '
299 . esc_html__('Logs', '404-solution') . '</a>';
300 }
301
302 if ($currentFilter != ABJ404_TRASH_FILTER) {
303 $trashBtnHTML = '<a href="' . esc_url($trashlink) . '" class="abj404-action-link danger" title="' . esc_attr($trashtitle) . '">'
304 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> '
305 . esc_html__('Trash', '404-solution') . '</a>';
306 }
307
308 if ($currentFilter == ABJ404_TRASH_FILTER) {
309 // Show Restore button
310 $trashBtnHTML = '<a href="' . esc_url($trashlink) . '" class="abj404-action-link" title="' . esc_attr__('Restore', '404-solution') . '">'
311 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg> '
312 . esc_html__('Restore', '404-solution') . '</a>';
313 $deleteBtnHTML = ' | <a href="' . esc_url($deletelink) . '" class="abj404-action-link danger" title="' . esc_attr__('Delete Permanently', '404-solution') . '" onclick="return confirm(\'' . esc_js(__('Are you sure you want to permanently delete this item?', '404-solution')) . '\');">'
314 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg> '
315 . esc_html__('Delete', '404-solution') . '</a>';
316 } else {
317 // Show Ignore and Later buttons (with separators)
318 if ($row['status'] != ABJ404_STATUS_IGNORED) {
319 $ignoreBtnHTML = ' | <a href="' . esc_url($ignorelink) . '" class="abj404-action-link" title="' . esc_attr($ignoretitle) . '">'
320 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"/></svg> '
321 . esc_html__('Ignore', '404-solution') . '</a>';
322 }
323 if ($row['status'] != ABJ404_STATUS_LATER) {
324 $laterBtnHTML = ' | <a href="' . esc_url($laterlink) . '" class="abj404-action-link" title="' . esc_attr($latertitle) . '">'
325 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg> '
326 . esc_html__('Later', '404-solution') . '</a>';
327 }
328 }
329 }
330
331 // Build full URL for visiting
332 $capturedRowUrl = is_string($row['url'] ?? '') ? (string)($row['url'] ?? '') : '';
333 $capturedRowId = is_scalar($row['id'] ?? '') ? (string)($row['id'] ?? '') : '';
334 $fullVisitorURL = esc_url(home_url($capturedRowUrl));
335
336 $tempHtml = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/tableRowCapturedURLs.html");
337 $tempHtml = $this->f->str_replace('{rowid}', $capturedRowId, $tempHtml);
338 $tempHtml = $this->f->str_replace('{rowClass}', '', $tempHtml);
339 $tempHtml = $this->f->str_replace('{visitorURL}', $fullVisitorURL, $tempHtml);
340 $tempHtml = $this->f->str_replace('{url}', esc_html($capturedRowUrl), $tempHtml);
341 $tempHtml = $this->f->str_replace('{statusBadgeClass}', $statusBadgeClass, $tempHtml);
342 $tempHtml = $this->f->str_replace('{statusTitle}', esc_attr($statusTitle), $tempHtml);
343 $tempHtml = $this->f->str_replace('{status}', $statusText, $tempHtml);
344 $capturedEngine = is_string($row['engine'] ?? '') ? trim((string)($row['engine'] ?? '')) : '';
345 $capturedEngineHTML = ($capturedEngine !== '') ? '<br><span class="abj404-engine-label">' . esc_html($capturedEngine) . '</span>' : '';
346 $tempHtml = $this->f->str_replace('{engineHTML}', $capturedEngineHTML, $tempHtml);
347 $tempHtml = $this->f->str_replace('{hits}', esc_html((string)$hits), $tempHtml);
348 $tempHtml = $this->f->str_replace('{created_date}',
349 esc_html((string)wp_date("Y/m/d h:i:s A", abs(is_scalar($row['timestamp'] ?? 0) ? intval($row['timestamp'] ?? 0) : 0))), $tempHtml);
350 $tempHtml = $this->f->str_replace('{last_used_date}', esc_html($last), $tempHtml);
351 $tempHtml = $this->f->str_replace('{lastUsedClass}', $lastUsedClass, $tempHtml);
352 $tempHtml = $this->f->str_replace('{editBtnHTML}', $editBtnHTML, $tempHtml);
353 $tempHtml = $this->f->str_replace('{logsBtnHTML}', $logsBtnHTML, $tempHtml);
354 $tempHtml = $this->f->str_replace('{trashBtnHTML}', $trashBtnHTML, $tempHtml);
355 $tempHtml = $this->f->str_replace('{deleteBtnHTML}', $deleteBtnHTML, $tempHtml);
356 $tempHtml = $this->f->str_replace('{ignoreBtnHTML}', $ignoreBtnHTML, $tempHtml);
357 $tempHtml = $this->f->str_replace('{laterBtnHTML}', $laterBtnHTML, $tempHtml);
358
359 $tempHtml = $this->f->doNormalReplacements($tempHtml);
360 $html .= $tempHtml;
361 }
362
363 if ($displayed == 0) {
364 $html .= '<tr><td colspan="6" class="abj404-empty-message">' . __('No Captured 404 Records To Display', '404-solution') . '</td></tr>';
365 }
366
367 $html .= '</tbody></table>';
368
369 return $html;
370 }
371
372 /**
373 * @return void
374 */
375 function echoAdminRedirectsPage() {
376
377 $sub = 'abj404_redirects';
378
379 $tableOptions = $this->logic->getTableOptions($sub);
380
381 // Sanitizing unchecked table options
382 $tableOptions = $this->logic->sanitizePostData($tableOptions);
383 $rawFilter = $tableOptions['filter'] ?? 0;
384 $currentFilter = is_scalar($rawFilter) ? $rawFilter : 0;
385
386 // Modern table page wrapper
387 echo '<div class="abj404-table-page">';
388
389 // Health status summary — placeholder, populated via a dedicated AJAX call
390 // (ajaxRefreshHealthBar) so the slow getHighImpactCapturedCount() query
391 // never blocks the table's first paint. JS reads the attrs below to fire
392 // an independent request as soon as the placeholder is in the DOM.
393 $healthBarNonce = wp_create_nonce('abj404_refreshHealthBar');
394 echo '<div class="abj404-health-bar" data-health-bar-placeholder="1"'
395 . ' data-health-bar-ajax-url="' . esc_attr(admin_url('admin-ajax.php')) . '"'
396 . ' data-health-bar-ajax-action="ajaxRefreshHealthBar"'
397 . ' data-health-bar-nonce="' . esc_attr($healthBarNonce) . '">';
398 echo '<span class="abj404-health-dot"></span>';
399 echo esc_html__('Loading status…', '404-solution');
400 echo '</div>';
401
402 // Page header with Add Redirect button
403 echo '<div class="abj404-table-header">';
404 echo '<div class="abj404-table-header-text">';
405 echo '<h1>' . esc_html__('Page Redirects', '404-solution') . '</h1>';
406 if ($this->logic->getSettingsMode() === 'simple') {
407 echo '<p class="abj404-guidance-subtitle">'
408 . esc_html__('The plugin creates these automatically. You only need to act when the status bar above says so.', '404-solution')
409 . '</p>';
410 }
411 echo '</div>';
412 if ($currentFilter != ABJ404_TRASH_FILTER) {
413 echo '<button type="button" class="abj404-btn abj404-btn-primary" data-modal-open="abj404-add-redirect-modal">';
414 echo '+ ' . esc_html__('Add Redirect', '404-solution');
415 echo '</button>';
416 }
417 echo '</div>';
418
419 // Content tabs — counts are placeholders, populated via AJAX
420 echo '<div class="abj404-content-tabs" data-tab-counts-placeholder="1">';
421 $this->echoContentTab($sub, 0, __('All', '404-solution'), '…', $tableOptions);
422 $this->echoContentTab($sub, ABJ404_STATUS_MANUAL, __('Manual', '404-solution'), '…', $tableOptions);
423 $this->echoContentTab($sub, ABJ404_STATUS_AUTO, __('Automatic', '404-solution'), '…', $tableOptions);
424 $this->echoContentTab($sub, ABJ404_TRASH_FILTER, __('Trash', '404-solution'), '…', $tableOptions);
425 echo '</div>';
426
427 // Filter bar with server-side search
428 $filterText = is_string($tableOptions['filterText'] ?? '') ? (string)($tableOptions['filterText'] ?? '') : '';
429 $perPage = isset($tableOptions['perpage']) ? $tableOptions['perpage'] : 25;
430
431 $paginationNonce = wp_create_nonce('abj404_updatePaginationLink');
432 $inflightNonce = wp_create_nonce('abj404_fetchInflightStage');
433 $autoRefresh = '1'; // $sub is always 'abj404_redirects' here
434 $rawOrderBy = $tableOptions['orderby'] ?? '';
435 $currentOrderBy = is_string($rawOrderBy) ? $rawOrderBy : 'url';
436 $rawOrder = $tableOptions['order'] ?? '';
437 $currentOrder = is_string($rawOrder) ? $rawOrder : 'ASC';
438 $rawPaged = $tableOptions['paged'] ?? 1;
439 $currentPaged = is_scalar($rawPaged) ? intval($rawPaged) : 1;
440 if ($currentPaged < 1) {
441 $currentPaged = 1;
442 }
443 $rawScoreRangeForAttr = $tableOptions['score_range'] ?? 'all';
444 $currentScoreRangeForAttr = is_string($rawScoreRangeForAttr) ? $rawScoreRangeForAttr : 'all';
445 echo '<div class="abj404-filter-bar tablenav"'
446 . ' data-pagination-ajax-url="' . esc_attr(admin_url('admin-ajax.php')) . '"'
447 . ' data-pagination-ajax-action="ajaxUpdatePaginationLinks"'
448 . ' data-pagination-ajax-subpage="' . esc_attr($sub) . '"'
449 . ' data-pagination-ajax-nonce="' . esc_attr($paginationNonce) . '"'
450 . ' data-pagination-inflight-nonce="' . esc_attr($inflightNonce) . '"'
451 . ' data-pagination-current-signature=""'
452 . ' data-pagination-current-orderby="' . esc_attr($currentOrderBy) . '"'
453 . ' data-pagination-current-order="' . esc_attr($currentOrder) . '"'
454 . ' data-pagination-current-filter="' . esc_attr((string)$currentFilter) . '"'
455 . ' data-pagination-current-paged="' . esc_attr((string)$currentPaged) . '"'
456 . ' data-pagination-current-score-range="' . esc_attr($currentScoreRangeForAttr) . '"'
457 . ' data-pagination-current-logsid=""'
458 . ' data-pagination-initial-load="1"'
459 . ' data-pagination-auto-refresh="' . esc_attr($autoRefresh) . '"'
460 . ' data-pagination-refresh-started-text="' . esc_attr(__('Refreshing data in background…', '404-solution')) . '"'
461 . ' data-pagination-refresh-finished-text="' . esc_attr(__('Data refreshed', '404-solution')) . '"'
462 . ' data-pagination-refresh-available-text="' . esc_attr(__('Refresh available', '404-solution')) . '">';
463 echo '<div class="abj404-search-box">';
464 echo '<input type="search" name="searchFilter" placeholder="' . esc_attr__('Type to filter redirects... (press Enter)', '404-solution') . '" value="' . esc_attr($filterText) . '" data-lpignore="true">';
465 echo '</div>';
466 echo '<div class="abj404-rows-per-page">';
467 echo '<span>' . esc_html__('Rows per page:', '404-solution') . '</span>';
468 echo '<select class="abj404-filter-select perpage" name="perpage" onchange="paginationLinksChange(this);">';
469 foreach ([10, 25, 50, 100, 200] as $opt) {
470 $selected = ($perPage == $opt) ? ' selected' : '';
471 echo '<option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
472 }
473 echo '</select>';
474 echo '</div>';
475
476 // Confidence filter dropdown
477 $rawScoreRange = $tableOptions['score_range'] ?? 'all';
478 $currentScoreRange = is_string($rawScoreRange) ? $rawScoreRange : 'all';
479 $rawFilter = $tableOptions['filter'] ?? 0;
480 $scoreRangeBaseUrl = '?page=' . ABJ404_PP . '&subpage=' . esc_attr($sub) . '&filter=' . (int)(is_scalar($rawFilter) ? $rawFilter : 0);
481 echo '<div class="abj404-rows-per-page">';
482 echo '<span>' . esc_html__('Confidence:', '404-solution') . '</span>';
483 $scoreRangeBaseUrlJs = esc_js(esc_url($scoreRangeBaseUrl));
484 echo '<select class="abj404-filter-select" name="score_range_filter" onchange="window.location=\'' . $scoreRangeBaseUrlJs . '&score_range=\'+encodeURIComponent(this.value);">';
485 $scoreRangeOptions = array(
486 'all' => __('All', '404-solution'),
487 'high' => __('High (≥80%)', '404-solution'),
488 'medium' => __('Medium (50–79%)', '404-solution'),
489 'low' => __('Low (<50%)', '404-solution'),
490 'manual' => __('Manual (no score)', '404-solution'),
491 );
492 foreach ($scoreRangeOptions as $val => $label) {
493 $sel = ($currentScoreRange === $val) ? ' selected' : '';
494 echo '<option value="' . esc_attr($val) . '"' . $sel . '>' . esc_html($label) . '</option>';
495 }
496 echo '</select>';
497 echo '</div>';
498
499 echo '<span class="abj404-refresh-status" aria-live="polite"></span>';
500 echo '</div>';
501
502 // Bulk actions bar (hidden by default, shown when items selected)
503 $url = $this->getBulkOperationsFormURL($sub, $tableOptions);
504
505 // Form must wrap both bulk actions and table so dropdown values are submitted
506 echo '<form method="POST" action="' . esc_url($url) . '">';
507
508 echo '<div class="abj404-bulk-actions" id="abj404-bulk-actions">';
509 echo '<span class="abj404-selection-info"><strong>0</strong> ' . esc_html__('redirects selected', '404-solution') . '</span>';
510 echo '<select class="abj404-filter-select" name="abj404action">';
511 echo '<option value="">' . esc_html__('Bulk Actions', '404-solution') . '</option>';
512 if ($currentFilter != ABJ404_STATUS_AUTO) {
513 echo '<option value="editRedirect">' . esc_html__('Edit Redirects', '404-solution') . '</option>';
514 }
515 if ($currentFilter != ABJ404_TRASH_FILTER) {
516 echo '<option value="bulktrash">' . esc_html__('Move to Trash', '404-solution') . '</option>';
517 }
518 if ($currentFilter == ABJ404_TRASH_FILTER) {
519 echo '<option value="bulk_trash_restore">' . esc_html__('Restore Redirects', '404-solution') . '</option>';
520 echo '<option value="bulk_trash_delete_permanently">' . esc_html__('Delete Permanently', '404-solution') . '</option>';
521 }
522 echo '</select>';
523 echo '<button type="submit" class="abj404-btn abj404-btn-primary">' . esc_html__('Apply', '404-solution') . '</button>';
524 echo '<button type="button" class="abj404-btn abj404-btn-secondary abj404-clear-selection" onclick="abj404ClearSelection()">' . esc_html__('Clear Selection', '404-solution') . '</button>';
525 echo '</div>';
526
527 // Table container
528 echo '<div class="abj404-table-container">';
529 echo '<table class="abj404-table" data-table-awaiting-load="1">';
530 echo '<thead><tr><th>' . esc_html__('Loading redirects…', '404-solution') . '</th></tr></thead>';
531 echo '<tbody><tr><td class="abj404-empty-message">' . esc_html__('Loading redirects…', '404-solution') . '</td></tr></tbody>';
532 echo '</table>';
533 echo '</div>';
534
535 // Pagination placeholder (bottom only, matching original layout).
536 echo '<div class="abj404-pagination tablenav abj404-pagination-right">';
537 echo '<span class="abj404-refresh-status" aria-live="polite">' . esc_html__('Loading…', '404-solution') . '</span>';
538 echo '</div>';
539
540 echo '</form>';
541
542 // Empty trash button (within page container but outside form)
543 if ($currentFilter == ABJ404_TRASH_FILTER) {
544 $eturl = "?page=" . ABJ404_PP . "&filter=" . ABJ404_TRASH_FILTER . "&subpage=" . $sub;
545 $eturl = wp_nonce_url($eturl, "abj404_bulkProcess");
546 echo '<div style="padding: 0 32px 20px;">';
547 echo '<form method="POST" action="' . esc_url($eturl) . '">';
548 echo '<input type="hidden" name="action" value="emptyRedirectTrash">';
549 echo '<button type="submit" class="abj404-btn abj404-btn-secondary" onclick="return confirm(\'' . esc_js(__('Are you sure you want to permanently delete all items in the trash?', '404-solution')) . '\')">';
550 echo esc_html__('Empty Trash', '404-solution');
551 echo '</button>';
552 echo '</form>';
553 echo '</div>';
554 }
555
556 echo '</div>'; // end abj404-table-page
557
558 // Add redirect modal (outside the main container)
559 if (($tableOptions['filter'] ?? 0) != ABJ404_TRASH_FILTER) {
560 $this->echoAddRedirectModal($tableOptions);
561 }
562 }
563
564 /**
565 * Echo a content tab for the table pages
566 */
567 /**
568 * @param string $sub
569 * @param int|string $filter
570 * @param string $label
571 * @param int|string $count
572 * @param array<string, mixed> $tableOptions
573 * @return void
574 */
575 function echoContentTab($sub, $filter, $label, $count, $tableOptions) {
576 $currentFilter = $tableOptions['filter'] ?? 0;
577 $isActive = ($currentFilter == $filter) ? 'active' : '';
578 $url = "?page=" . ABJ404_PP . "&subpage=" . $sub;
579 if ($filter != 0) {
580 $url .= "&filter=" . $filter;
581 }
582 echo '<a href="' . esc_url($url) . '" class="abj404-content-tab ' . $isActive . '" data-tab-filter="' . esc_attr((string)$filter) . '">';
583 echo esc_html($label);
584 echo '<span class="abj404-tab-count">' . esc_html((string)$count) . '</span>';
585 echo '</a>';
586 }
587
588 /**
589 * Echo the modern Add Redirect modal
590 */
591 /**
592 * @param array<string, mixed> $tableOptions
593 * @return void
594 */
595 function echoAddRedirectModal($tableOptions) {
596 $options = $this->getOptionsWithDefaults();
597 $url = "?page=" . ABJ404_PP . "&subpage=abj404_redirects";
598 $orderby = array_key_exists('orderby', $tableOptions) && is_string($tableOptions['orderby']) ? $tableOptions['orderby'] : 'url';
599 $order = array_key_exists('order', $tableOptions) && is_string($tableOptions['order']) ? $tableOptions['order'] : 'ASC';
600 if (!($orderby == "url" && $order == "ASC")) {
601 $url .= "&orderby=" . sanitize_text_field($orderby) . "&order=" . sanitize_text_field($order);
602 }
603 $filter = array_key_exists('filter', $tableOptions) && is_scalar($tableOptions['filter']) ? $tableOptions['filter'] : 0;
604 if ($filter != 0) {
605 $url .= "&filter=" . $filter;
606 }
607 $link = wp_nonce_url($url, "abj404addRedirect");
608 $urlPlaceholder = parse_url(get_home_url(), PHP_URL_PATH) . "/example";
609
610 echo '<div class="abj404-modal" id="abj404-add-redirect-modal">';
611 echo '<div class="abj404-modal-content">';
612 echo '<div class="abj404-modal-header">';
613 echo '<h2>' . esc_html__('Add Manual Redirect', '404-solution') . '</h2>';
614 echo '<button type="button" class="abj404-modal-close" onclick="abj404CloseAddRedirectModal()">&times;</button>';
615 echo '</div>';
616 echo '<form method="POST" action="' . esc_url($link) . '" onsubmit="return validateAddManualRedirectForm(event);">';
617 echo '<input type="hidden" name="action" value="addRedirect">';
618 echo '<div class="abj404-modal-body">';
619
620 // URL field
621 echo '<div class="abj404-form-group">';
622 echo '<label class="abj404-form-label">' . esc_html__('URL', '404-solution') . ' *</label>';
623 echo '<input type="text" name="manual_redirect_url" class="abj404-form-input" placeholder="' . esc_attr($urlPlaceholder) . '" required>';
624 echo '<p class="abj404-form-help">' . esc_html__('The URL path that should be redirected (without domain)', '404-solution') . '</p>';
625 echo '<div class="abj404-checkbox-group" style="margin-top: 12px;">';
626 echo '<input type="checkbox" name="is_regex_url" id="modal_is_regex" class="abj404-checkbox-input" value="1">';
627 echo '<label for="modal_is_regex" class="abj404-checkbox-label">' . esc_html__('Treat this URL as a regular expression', '404-solution') . '</label>';
628 echo ' <a href="#" class="abj404-regex-toggle" onclick="abj404ToggleRegexInfo(event)">' . esc_html__('(Explain)', '404-solution') . '</a>';
629 echo '</div>';
630 echo '<div class="abj404-regex-info" style="display: none;">';
631 echo '<p>' . esc_html__('When checked, the text is treated as a regular expression. Note that including a bad regular expression or one that takes too long will break your website. So please use caution and test them elsewhere before trying them here. If you don\'t know what you\'re doing please don\'t use this option (as it\'s not necessary for the functioning of the plugin).', '404-solution') . '</p>';
632 echo '<p><strong>' . esc_html__('Example:', '404-solution') . '</strong> <code>/events/(.+)</code></p>';
633 echo '<p>' . esc_html__('/events/(.+) will match any URL that begins with /events/ and redirect to the specified page. Since a capture group is used, you can use a $1 replacement in the destination string of an external URL.', '404-solution') . '</p>';
634 echo '<p>' . esc_html__('First, all of the normal "exact match" URLs are checked, then all of the regular expression URLs are checked.', '404-solution') . '</p>';
635 echo '</div>';
636 echo '</div>';
637
638 // Redirect to field - using the existing AJAX autocomplete template.
639 // The template provides the label via {redirect_to_label}; no separate PHP label needed.
640 echo '<div class="abj404-form-group abj404-autocomplete-wrapper">';
641
642 $redirectHtml = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/addManualRedirectPageSearchDropdown.html");
643 $redirectHtml = $this->f->str_replace('{redirect_to_label}', esc_html__('Redirect to', '404-solution') . ' *', $redirectHtml);
644 $redirectHtml = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_EMPTY}',
645 __('(Type a page name or an external URL)', '404-solution'), $redirectHtml);
646 $redirectHtml = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_PAGE}',
647 __('(A page has been selected.)', '404-solution'), $redirectHtml);
648 $redirectHtml = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_CUSTOM_STRING}',
649 __('(A custom string has been entered.)', '404-solution'), $redirectHtml);
650 $redirectHtml = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_URL}',
651 __('(An external URL will be used.)', '404-solution'), $redirectHtml);
652 $redirectHtml = $this->f->str_replace('{REDIRECT_TO_USER_FIELD_WARNING}', '', $redirectHtml);
653 $redirectHtml = $this->f->str_replace('{redirectPageTitle}', '', $redirectHtml);
654 $redirectHtml = $this->f->str_replace('{pageIDAndType}', '', $redirectHtml);
655 $redirectHtml = $this->f->str_replace('{data-url}',
656 "admin-ajax.php?action=echoRedirectToPages&includeDefault404Page=true&includeSpecial=true&nonce=" . wp_create_nonce('abj404_ajax'), $redirectHtml);
657 $redirectHtml = $this->f->doNormalReplacements($redirectHtml);
658 echo $redirectHtml;
659
660 echo '</div>';
661
662 // Redirect type — button grid
663 $rawDefault = $options['default_redirect'] ?? '301';
664 $defaultCode = is_string($rawDefault) ? $rawDefault : '301';
665 $this->echoRedirectTypeButtonGrid($defaultCode);
666
667 // Advanced Options: schedule + conditions
668 echo '<details class="abj404-advanced-options">';
669 echo '<summary class="abj404-advanced-options__summary">' . esc_html__('Advanced Options', '404-solution') . '</summary>';
670 echo '<div class="abj404-advanced-options__body">';
671
672 // Active From
673 echo '<div class="abj404-form-group">';
674 echo '<label class="abj404-form-label" for="redirect_start_date">' . esc_html__('Active From (optional)', '404-solution') . '</label>';
675 echo '<input type="date" name="redirect_start_date" id="redirect_start_date" class="abj404-form-input" value="">';
676 echo '<p class="abj404-form-help">' . esc_html__('Leave blank to activate immediately', '404-solution') . '</p>';
677 echo '</div>';
678
679 // Active Until
680 echo '<div class="abj404-form-group">';
681 echo '<label class="abj404-form-label" for="redirect_end_date">' . esc_html__('Active Until (optional)', '404-solution') . '</label>';
682 echo '<input type="date" name="redirect_end_date" id="redirect_end_date" class="abj404-form-input" value="">';
683 echo '<p class="abj404-form-help">' . esc_html__('Leave blank to never expire', '404-solution') . '</p>';
684 echo '</div>';
685
686 // Conditions
687 $this->echoRedirectConditionsSection();
688
689 echo '</div>'; // end .abj404-advanced-options__body
690 echo '</details>';
691
692 echo '</div>'; // end .abj404-modal-body
693 echo '<div class="abj404-modal-footer">';
694 echo '<button type="button" class="abj404-btn abj404-btn-secondary" onclick="abj404CloseAddRedirectModal()">' . esc_html__('Cancel', '404-solution') . '</button>';
695 echo '<button type="submit" class="abj404-btn abj404-btn-primary">' . esc_html__('Add Redirect', '404-solution') . '</button>';
696 echo '</div>';
697 echo '</form>';
698 echo '</div>';
699 echo '</div>';
700 }
701
702 /**
703 * Render the redirect type button grid, hidden input, and the JS handler.
704 * Used by both the Add Redirect modal and the Edit Redirect page.
705 *
706 * @param string $selectedCode The currently selected code value (e.g. '301').
707 * @return void
708 */
709 /**
710 * Get a plain-language label for an HTTP redirect code.
711 * Used in Simple mode to replace technical numeric codes.
712 *
713 * @param string $code The numeric redirect code (e.g. '301', '302').
714 * @return string Human-readable label.
715 */
716 private static function getPlainLanguageCodeLabel(string $code): string {
717 $labels = array(
718 '301' => __('Permanent', '404-solution'),
719 '308' => __('Permanent', '404-solution'),
720 '302' => __('Temporary', '404-solution'),
721 '307' => __('Temporary', '404-solution'),
722 '410' => __('Gone', '404-solution'),
723 '451' => __('Blocked', '404-solution'),
724 '0' => __('Meta Refresh', '404-solution'),
725 );
726 return isset($labels[$code]) ? $labels[$code] : $code;
727 }
728
729 private function echoRedirectTypeButtonGrid(string $selectedCode): void {
730 $isSimple = $this->logic->getSettingsMode() === 'simple';
731
732 echo '<div class="abj404-form-group">';
733 echo '<label class="abj404-form-label">' . esc_html__('Redirect Type', '404-solution') . '</label>';
734 echo '<input type="hidden" id="code" name="code" value="' . esc_attr($selectedCode) . '">';
735 echo '<div class="abj404-redirect-type-grid">';
736
737 if ($isSimple) {
738 // Simple mode: show only Permanent and Temporary
739 $codeButtons = array(
740 301 => array(__('Permanent', '404-solution'), __('Best for moved pages', '404-solution')),
741 302 => array(__('Temporary', '404-solution'), __('Best for seasonal or test pages', '404-solution')),
742 );
743 } else {
744 $codeButtons = array(
745 301 => array(__('301', '404-solution'), __('Permanent', '404-solution')),
746 302 => array(__('302', '404-solution'), __('Temporary', '404-solution')),
747 307 => array(__('307', '404-solution'), __('Temp, method-safe', '404-solution')),
748 308 => array(__('308', '404-solution'), __('Perm, method-safe', '404-solution')),
749 410 => array(__('410', '404-solution'), __('Gone', '404-solution')),
750 451 => array(__('451', '404-solution'), __('Legal reasons', '404-solution')),
751 0 => array(__('Meta Refresh', '404-solution'), __('HTTP 200 + meta tag', '404-solution')),
752 );
753 }
754
755 foreach ($codeButtons as $code => $labels) {
756 $isActive = ((string)$code === $selectedCode) ? ' abj404-redirect-type-btn--active' : '';
757 $isFull = ($code === 0) ? ' abj404-redirect-type-btn--full' : '';
758 echo '<button type="button"'
759 . ' class="abj404-redirect-type-btn' . $isActive . $isFull . '"'
760 . ' data-code="' . esc_attr((string)$code) . '"'
761 . ' onclick="abj404SelectRedirectType(this)">';
762 echo '<strong>' . esc_html($labels[0]) . '</strong>';
763 echo '<span>' . esc_html($labels[1]) . '</span>';
764 echo '</button>';
765 }
766 echo '</div>';
767 if ($isSimple) {
768 echo '<p class="abj404-form-help">' . esc_html__('Permanent is best for most redirects. Use Temporary if the page may come back.', '404-solution') . '</p>';
769 } else {
770 echo '<p class="abj404-form-help">' . esc_html__('Use 301 for permanent page moves. Use 302 for A/B tests or seasonal pages.', '404-solution') . '</p>';
771 }
772 echo '</div>';
773 echo '<script type="text/javascript">';
774 echo 'if (typeof window.abj404SelectRedirectType === "undefined") {';
775 echo ' window.abj404SelectRedirectType = function(btn) {';
776 echo ' var grid = btn.closest(".abj404-redirect-type-grid");';
777 echo ' grid.querySelectorAll(".abj404-redirect-type-btn").forEach(function(b) {';
778 echo ' b.classList.remove("abj404-redirect-type-btn--active");';
779 echo ' });';
780 echo ' btn.classList.add("abj404-redirect-type-btn--active");';
781 echo ' var hidden = document.getElementById("code");';
782 echo ' if (hidden) {';
783 echo ' hidden.value = btn.dataset.code;';
784 echo ' if (typeof jQuery !== "undefined") { jQuery("#code").trigger("change"); }';
785 echo ' }';
786 echo ' };';
787 echo '}';
788 echo '</script>';
789 }
790
791 /**
792 * Get modern pagination HTML
793 */
794 /**
795 * @param string $sub
796 * @param array<string, mixed> $tableOptions
797 * @return string
798 */
799 function getModernPagination($sub, $tableOptions) {
800 $logsid = isset($tableOptions['logsid']) ? intval(is_scalar($tableOptions['logsid']) ? $tableOptions['logsid'] : 0) : 0;
801 $filter = isset($tableOptions['filter']) ? intval(is_scalar($tableOptions['filter']) ? $tableOptions['filter'] : 0) : 0;
802 $orderby = isset($tableOptions['orderby']) && is_string($tableOptions['orderby']) ? $tableOptions['orderby'] : 'url';
803 $order = isset($tableOptions['order']) && is_string($tableOptions['order']) ? $tableOptions['order'] : 'ASC';
804
805 // Use appropriate count method based on sub type
806 $logsidInt = (int)$logsid;
807 if ($sub == 'abj404_logs') {
808 $totalRows = $this->dao->getLogsCount($logsidInt);
809 } else {
810 $totalRows = $this->dao->getRedirectsForViewCount($sub, $tableOptions);
811 }
812 $rawPerpage = array_key_exists('perpage', $tableOptions) && is_scalar($tableOptions['perpage']) ? $tableOptions['perpage'] : 25;
813 $perPage = intval($rawPerpage);
814 if ($perPage <= 0) {
815 $perPage = 25;
816 }
817 $rawPaged = array_key_exists('paged', $tableOptions) && is_scalar($tableOptions['paged']) ? $tableOptions['paged'] : 1;
818 $currentPage = intval($rawPaged);
819 $totalPages = ceil($totalRows / $perPage);
820
821 if ($totalPages <= 1) {
822 return '';
823 }
824
825 $startItem = (($currentPage - 1) * $perPage) + 1;
826 $endItem = min($currentPage * $perPage, $totalRows);
827
828 $baseUrl = "?page=" . ABJ404_PP . "&subpage=" . $sub;
829 // Include logsid for logs pagination
830 if ($sub == 'abj404_logs' && isset($tableOptions['logsid'])) {
831 $baseUrl .= "&id=" . $tableOptions['logsid'];
832 }
833 if ($filter != 0) {
834 $baseUrl .= "&filter=" . $filter;
835 }
836 if (!( $orderby == "url" && $order == "ASC" )) {
837 $baseUrl .= "&orderby=" . sanitize_text_field($orderby) . "&order=" . sanitize_text_field($order);
838 }
839
840 // Different label for logs vs redirects
841 $itemLabel = ($sub == 'abj404_logs') ? __('logs', '404-solution') : __('redirects', '404-solution');
842
843 $html = '<div class="abj404-pagination">';
844 $html .= '<div class="abj404-pagination-info">';
845 $html .= sprintf(
846 /* translators: %1$d is start item, %2$d is end item, %3$d is total count, %4$s is item type (logs/redirects) */
847 esc_html__('Showing %1$d-%2$d of %3$d %4$s', '404-solution'),
848 $startItem,
849 $endItem,
850 $totalRows,
851 $itemLabel
852 );
853 $html .= '</div>';
854 $html .= '<div class="abj404-pagination-controls">';
855
856 // Previous button
857 if ($currentPage > 1) {
858 $html .= '<a href="' . esc_url($baseUrl . '&paged=' . ($currentPage - 1)) . '" class="abj404-page-btn">&lsaquo;</a>';
859 } else {
860 $html .= '<span class="abj404-page-btn disabled">&lsaquo;</span>';
861 }
862
863 // Page numbers
864 $range = 2;
865 for ($i = 1; $i <= $totalPages; $i++) {
866 if ($i == 1 || $i == $totalPages || ($i >= $currentPage - $range && $i <= $currentPage + $range)) {
867 $activeClass = ($i == $currentPage) ? ' active' : '';
868 $html .= '<a href="' . esc_url($baseUrl . '&paged=' . $i) . '" class="abj404-page-btn' . $activeClass . '">' . $i . '</a>';
869 } elseif ($i == $currentPage - $range - 1 || $i == $currentPage + $range + 1) {
870 $html .= '<span class="abj404-page-ellipsis">&hellip;</span>';
871 }
872 }
873
874 // Next button
875 if ($currentPage < $totalPages) {
876 $html .= '<a href="' . esc_url($baseUrl . '&paged=' . ($currentPage + 1)) . '" class="abj404-page-btn">&rsaquo;</a>';
877 } else {
878 $html .= '<span class="abj404-page-btn disabled">&rsaquo;</span>';
879 }
880
881 $html .= '</div>';
882 $html .= '</div>';
883
884 return $html;
885 }
886
887 /**
888 * @param string $sub
889 * @param array<string, mixed> $tableOptions
890 * @return string
891 */
892 function getBulkOperationsFormURL($sub, $tableOptions) {
893 $url = "?page=" . ABJ404_PP . "&subpage=" . $sub;
894 $filter = array_key_exists('filter', $tableOptions) && is_scalar($tableOptions['filter']) ? $tableOptions['filter'] : 0;
895 if ($filter != 0) {
896 $url .= "&filter=" . $filter;
897 }
898 $orderby = array_key_exists('orderby', $tableOptions) && is_string($tableOptions['orderby']) ? $tableOptions['orderby'] : 'url';
899 $order = array_key_exists('order', $tableOptions) && is_string($tableOptions['order']) ? $tableOptions['order'] : 'ASC';
900 if (!($orderby == "url" && $order == "ASC")) {
901 $url .= "&orderby=" . sanitize_text_field($orderby) . "&order=" . sanitize_text_field($order);
902 }
903 $url = wp_nonce_url($url, 'abj404_bulkProcess');
904 return $url;
905 }
906
907 /**
908 * @param string $sub
909 * @return string
910 */
911 function getAdminRedirectsPageTable($sub) {
912
913 $tableOptions = $this->logic->getTableOptions($sub);
914
915 // these are used for a GET request so they're not translated.
916 $columns = array();
917 $columns['url']['title'] = __('URL', '404-solution');
918 $columns['url']['orderby'] = "url";
919 $columns['url']['width'] = "25%";
920 $columns['status']['title'] = __('Status', '404-solution');
921 $columns['status']['orderby'] = "status";
922 $columns['status']['width'] = "5%";
923 $columns['type']['title'] = __('Type', '404-solution');
924 $columns['type']['orderby'] = "type";
925 $columns['type']['width'] = "10%";
926 $columns['dest']['title'] = __('Destination', '404-solution');;
927 $columns['dest']['orderby'] = "final_dest";
928 $columns['dest']['width'] = "22%";
929 $columns['code']['title'] = __('Redirect', '404-solution');
930 $columns['code']['orderby'] = "code";
931 $columns['code']['width'] = "5%";
932 $columns['confidence']['title'] = __('Confidence', '404-solution');
933 $columns['confidence']['orderby'] = "score";
934 $columns['confidence']['width'] = "7%";
935 $columns['confidence']['class'] = "hide-on-tablet";
936 $columns['hits']['title'] = __('Hits', '404-solution');
937 $columns['hits']['orderby'] = "logshits";
938 $columns['hits']['width'] = "7%";
939 $hitsTooltip = $this->getHitsColumnTooltip($tableOptions);
940 $columns['hits']['title_attr_html'] = $hitsTooltip;
941 $columns['timestamp']['title'] = __('Created', '404-solution');
942 $columns['timestamp']['orderby'] = "timestamp";
943 $columns['timestamp']['width'] = "10%";
944 $columns['timestamp']['class'] = "hide-on-tablet";
945 $columns['last_used']['title'] = __('Last Used', '404-solution');
946 $columns['last_used']['orderby'] = "last_used";
947 $columns['last_used']['width'] = "10%";
948 $columns['last_used']['title_attr_html'] = $hitsTooltip;
949
950 $html = "<table class=\"abj404-table\"><thead>";
951 $html .= $this->getTableColumns($sub, $columns);
952 $html .= "</thead><tbody id=\"the-list\">";
953
954 $deadDestIds = function_exists('get_transient') ? get_transient('abj404_dead_dest_ids') : false;
955 if (!is_array($deadDestIds)) {
956 $deadDestIds = array();
957 }
958
959 $rows = $this->dao->getRedirectsForView($sub, $tableOptions);
960 /** @var array<int, array<string, mixed>> $typedRedirectRows */
961 $typedRedirectRows = array_values(array_filter($rows, 'is_array'));
962 $this->rememberTableDataSignature($sub, $typedRedirectRows);
963 $displayed = 0;
964 $y = 1;
965 foreach ($typedRedirectRows as $row) {
966 $displayed++;
967 $rowType = $row['type'] ?? 0;
968 $rowStatus = $row['status'] ?? 0;
969 $rowFinalDest = is_string($row['final_dest'] ?? '') ? (string)($row['final_dest'] ?? '') : '';
970 $destForView = trim(is_scalar($row['dest_for_view'] ?? '') ? (string)($row['dest_for_view'] ?? '') : '');
971 $statusTitle = '';
972 if ($rowStatus == ABJ404_STATUS_MANUAL) {
973 $statusTitle = __('Manually created', '404-solution');
974 } else if ($rowStatus == ABJ404_STATUS_AUTO) {
975 $statusTitle = __('Automatically created', '404-solution');
976 } else if ($rowStatus == ABJ404_STATUS_REGEX) {
977 $statusTitle = __('Regular Expression (Manually Created)', '404-solution');
978 } else {
979 $statusTitle = __('Unknown', '404-solution');
980 }
981
982 $link = "";
983 $title = __('Visit', '404-solution') . " ";
984 if ($rowType == ABJ404_TYPE_EXTERNAL) {
985 if ($rowFinalDest !== '') {
986 $link = $rowFinalDest;
987 $title .= $rowFinalDest;
988 }
989 } else if ($rowType == ABJ404_TYPE_CAT) {
990 if ($rowFinalDest !== '') {
991 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rowFinalDest . "|" . ABJ404_TYPE_CAT, 0);
992 $link = is_string($permalink['link']) ? $permalink['link'] : '';
993 $title .= __('Category:', '404-solution') . " " . (is_string($permalink['title']) ? $permalink['title'] : '');
994 }
995 } else if ($rowType == ABJ404_TYPE_TAG) {
996 if ($rowFinalDest !== '') {
997 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rowFinalDest . "|" . ABJ404_TYPE_TAG, 0);
998 $link = is_string($permalink['link']) ? $permalink['link'] : '';
999 $title .= __('Tag:', '404-solution') . " " . (is_string($permalink['title']) ? $permalink['title'] : '');
1000 }
1001 } else if ($rowType == ABJ404_TYPE_HOME) {
1002 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rowFinalDest . "|" . ABJ404_TYPE_HOME, 0);
1003 $link = is_string($permalink['link']) ? $permalink['link'] : '';
1004 $title .= __('Home Page:', '404-solution') . " " . (is_string($permalink['title']) ? $permalink['title'] : '');
1005 } else if ($rowType == ABJ404_TYPE_POST) {
1006 if ($rowFinalDest !== '') {
1007 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rowFinalDest . "|" . ABJ404_TYPE_POST, 0);
1008 $link = is_string($permalink['link']) ? $permalink['link'] : '';
1009 $title .= is_string($permalink['title']) ? $permalink['title'] : '';
1010 }
1011
1012 } else if ($rowType == ABJ404_TYPE_404_DISPLAYED) {
1013 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rowFinalDest . "|" . ABJ404_TYPE_404_DISPLAYED, 0);
1014 // for custom 404 page use the link
1015 $link = is_string($permalink['link']) ? $permalink['link'] : '';
1016 $title .= is_string($permalink['title']) ? $permalink['title'] : '';
1017
1018 // for the normal 404 page just use #
1019 if ($rowFinalDest == '0') {
1020 $link = '';
1021 }
1022
1023 } else {
1024 $this->logger->errorMessage("Unexpected row type while displaying table: " . $rowType);
1025 }
1026
1027 if ($link != '') {
1028 $link = "href='" . esc_url($link) . "'";
1029 }
1030
1031 $hits = is_scalar($row['logshits'] ?? 0) ? (int)($row['logshits'] ?? 0) : 0;
1032
1033 $last_used = is_scalar($row['last_used'] ?? 0) ? (int)($row['last_used'] ?? 0) : 0;
1034 if ($last_used != 0) {
1035 $last = (string)wp_date("Y/m/d h:i:s A", abs($last_used));
1036 } else {
1037 $last = __('Never Used', '404-solution');
1038 }
1039
1040 // Build action links using helper method
1041 /** @var array<string, mixed> $row */
1042 $links = $this->buildTableActionLinks($row, $sub, $tableOptions, false);
1043 $editlink = '';
1044 $logslink = '';
1045 $trashlink = '';
1046 $trashtitle = '';
1047 $deletelink = '';
1048 $ajaxTrashLink = '';
1049 extract($links);
1050
1051 $class = "";
1052 if ($y == 0) {
1053 $class = "alternate";
1054 $y++;
1055 } else {
1056 $y = 0;
1057 $class = "normal-non-alternate";
1058 }
1059 // make the entire row red if the destination is missing, doesn't exist, or is unpublished.
1060 $destinationDoesNotExistClass = '';
1061 $destinationIsMissing = false;
1062 if ($rowType != ABJ404_TYPE_404_DISPLAYED && trim((string)$rowFinalDest) === '') {
1063 $destinationIsMissing = true;
1064 $destinationDoesNotExistClass = ' destination-does-not-exist';
1065 }
1066 if (array_key_exists('published_status', $row)) {
1067 if ($row['published_status'] == '0') {
1068 $destinationDoesNotExistClass = ' destination-does-not-exist';
1069 }
1070 }
1071
1072 // Check if URL looks like a regex pattern but is not marked as a regex redirect
1073 $urlLooksLikeRegexClass = '';
1074 $rowUrl = is_string($row['url'] ?? '') ? (string)($row['url'] ?? '') : '';
1075 $urlLooksLikeRegex = ABJ_404_Solution_Functions::urlLooksLikeRegex($rowUrl);
1076 $isRegexStatus = ($rowStatus == ABJ404_STATUS_REGEX);
1077 if ($urlLooksLikeRegex && !$isRegexStatus) {
1078 $urlLooksLikeRegexClass = ' url-looks-like-regex';
1079 }
1080
1081 $class = $class . $destinationDoesNotExistClass . $urlLooksLikeRegexClass;
1082
1083 // -------------------------------------------
1084 // Build modern row action buttons with icons
1085 $editBtnHTML = '';
1086 $logsBtnHTML = '';
1087 $trashBtnHTML = '';
1088 $deleteBtnHTML = '';
1089
1090 $currentFilter = $tableOptions['filter'] ?? 0;
1091 if ($currentFilter != ABJ404_TRASH_FILTER) {
1092 $editBtnHTML = '<a href="' . esc_url($editlink) . '" class="abj404-action-link" title="{Edit Redirect Details}">'
1093 . '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg> '
1094 . '{Edit}</a>';
1095 $trashBtnHTML = '<a href="#" class="abj404-action-link danger ajax-trash-link" data-url="{ajaxTrashLink}" title="{Trash Redirected URL}">'
1096 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> '
1097 . '{Trash}</a>';
1098 }
1099 if (($row['logsid'] ?? 0) > 0) {
1100 $logsBtnHTML = '<a href="{logsLink}" class="abj404-action-link" title="{View Redirect Logs}">'
1101 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg> '
1102 . '{Logs}</a>';
1103 }
1104 if ($currentFilter == ABJ404_TRASH_FILTER) {
1105 $trashBtnHTML = '<a href="{trashLink}" class="abj404-action-link" title="{Restore}">'
1106 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg> '
1107 . '{Restore}</a>';
1108 $deleteBtnHTML = ' | <a href="{deletelink}" class="abj404-action-link danger" title="{Delete Redirect Permanently}">'
1109 . '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> '
1110 . '{Delete}</a>';
1111 }
1112
1113 // Determine badge classes
1114 $statusBadgeClass = 'abj404-badge-manual';
1115 if ($rowStatus == ABJ404_STATUS_AUTO) {
1116 $statusBadgeClass = 'abj404-badge-auto';
1117 } else if ($rowStatus == ABJ404_STATUS_REGEX) {
1118 $statusBadgeClass = 'abj404-badge-regex';
1119 }
1120
1121 $rowCode = is_scalar($row['code'] ?? '') ? (string)($row['code'] ?? '') : '';
1122 $codeBadgeMap = array(
1123 '301' => 'abj404-badge-301',
1124 '302' => 'abj404-badge-302',
1125 '307' => 'abj404-badge-307',
1126 '308' => 'abj404-badge-308',
1127 '410' => 'abj404-badge-410',
1128 '451' => 'abj404-badge-451',
1129 '0' => 'abj404-badge-meta',
1130 );
1131 $codeBadgeClass = isset($codeBadgeMap[$rowCode]) ? $codeBadgeMap[$rowCode] : 'abj404-badge-302';
1132
1133 // In Simple mode, show plain language labels instead of numeric codes
1134 $codeDisplay = $rowCode;
1135 if ($this->logic->getSettingsMode() === 'simple') {
1136 $codeDisplay = self::getPlainLanguageCodeLabel($rowCode);
1137 }
1138
1139 $lastUsedClass = '';
1140 if ($last_used == 0) {
1141 $lastUsedClass = 'abj404-never-used';
1142 }
1143
1144 // Legacy variables for backwards compatibility
1145 $editlinkHTML = '';
1146 $logslinkHTML = '';
1147 $deletePermanentlyHTML = '';
1148
1149 $destinationExists = '';
1150 $destinationDoesNotExist = 'display: none;';
1151 $destinationWarningText = __("This page doesn't exist or is not published so the redirect won't work.", '404-solution');
1152 if ($destinationIsMissing) {
1153 $destinationExists = 'display: none;';
1154 $destinationDoesNotExist = '';
1155 $destinationWarningText = __('Destination missing. Edit this redirect and choose a destination.', '404-solution');
1156 if (trim((string)$destForView) === '') {
1157 $destForView = __('(Destination missing)', '404-solution');
1158 }
1159 }
1160 if (array_key_exists('published_status', $row)) {
1161 if ($row['published_status'] == '0') {
1162 $destinationExists = 'display: none;';
1163 $destinationDoesNotExist = '';
1164 if (trim((string)$destForView) === '') {
1165 $destForView = __('(Destination unavailable)', '404-solution');
1166 }
1167 }
1168 }
1169
1170 // Dead destination: destination exists in DB but is generating 404s
1171 $rowIdStr = is_scalar($row['id'] ?? '') ? (string) ($row['id'] ?? '') : '';
1172 if (in_array($rowIdStr, $deadDestIds, true)) {
1173 $destinationExists = 'display: none;';
1174 $destinationDoesNotExist = '';
1175 $destinationWarningText = __('Destination returned 404 recently — redirect suspended until destination is restored.', '404-solution');
1176 }
1177
1178 // URL regex warning visibility
1179 $urlIsNormal = '';
1180 $urlLooksLikeRegexWarning = 'display: none;';
1181 if ($urlLooksLikeRegex && !$isRegexStatus) {
1182 $urlIsNormal = 'display: none;';
1183 $urlLooksLikeRegexWarning = '';
1184 }
1185
1186 // Build full URL with WordPress base path for subdirectory installations
1187 $rowId = is_scalar($row['id'] ?? '') ? (string)($row['id'] ?? '') : '';
1188 $fullVisitorURL = esc_url(home_url($rowUrl));
1189
1190 $htmlTemp = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/tableRowPageRedirects.html");
1191 $htmlTemp = $this->f->str_replace('{rowid}', $rowId, $htmlTemp);
1192 $htmlTemp = $this->f->str_replace('{rowClass}', $class, $htmlTemp);
1193 $htmlTemp = $this->f->str_replace('{visitorURL}', $fullVisitorURL, $htmlTemp);
1194 $htmlTemp = $this->f->str_replace('{rowURL}', esc_html($rowUrl), $htmlTemp);
1195
1196 // URL regex warning
1197 $htmlTemp = $this->f->str_replace('{url-is-normal}', $urlIsNormal, $htmlTemp);
1198 $htmlTemp = $this->f->str_replace('{url-looks-like-regex}', $urlLooksLikeRegexWarning, $htmlTemp);
1199
1200 // Modern row action buttons
1201 $htmlTemp = $this->f->str_replace('{editBtnHTML}', $editBtnHTML, $htmlTemp);
1202 $htmlTemp = $this->f->str_replace('{logsBtnHTML}', $logsBtnHTML, $htmlTemp);
1203 $htmlTemp = $this->f->str_replace('{trashBtnHTML}', $trashBtnHTML, $htmlTemp);
1204 $htmlTemp = $this->f->str_replace('{deleteBtnHTML}', $deleteBtnHTML, $htmlTemp);
1205
1206 // Badge classes
1207 $htmlTemp = $this->f->str_replace('{statusBadgeClass}', $statusBadgeClass, $htmlTemp);
1208 $htmlTemp = $this->f->str_replace('{codeBadgeClass}', $codeBadgeClass, $htmlTemp);
1209 $htmlTemp = $this->f->str_replace('{lastUsedClass}', $lastUsedClass, $htmlTemp);
1210
1211 $htmlTemp = $this->f->str_replace('{link}', $link, $htmlTemp);
1212 $htmlTemp = $this->f->str_replace('{title}', esc_attr($title), $htmlTemp);
1213 $htmlTemp = $this->f->str_replace('{dest}', esc_attr($destForView), $htmlTemp);
1214 $htmlTemp = $this->f->str_replace('{destination-exists}', $destinationExists, $htmlTemp);
1215 $htmlTemp = $this->f->str_replace('{destination-does-not-exist}', $destinationDoesNotExist, $htmlTemp);
1216 $htmlTemp = $this->f->str_replace('{destination-warning-text}', $destinationWarningText, $htmlTemp);
1217 $statusForView = is_string($row['status_for_view'] ?? '') ? (string)($row['status_for_view'] ?? '') : '';
1218 $typeForView = is_string($row['type_for_view'] ?? '') ? (string)($row['type_for_view'] ?? '') : '';
1219 $htmlTemp = $this->f->str_replace('{status}', $statusForView, $htmlTemp);
1220 $htmlTemp = $this->f->str_replace('{statusTitle}', $statusTitle, $htmlTemp);
1221 $rowEngine = is_string($row['engine'] ?? '') ? trim((string)($row['engine'] ?? '')) : '';
1222 $engineHTML = ($rowEngine !== '') ? '<br><span class="abj404-engine-label">' . esc_html($rowEngine) . '</span>' : '';
1223 $htmlTemp = $this->f->str_replace('{engineHTML}', $engineHTML, $htmlTemp);
1224 $rawScore = $row['score'] ?? null;
1225 // Keep {rowScore} empty — score now lives in its own Confidence column.
1226 $htmlTemp = $this->f->str_replace('{rowScore}', '', $htmlTemp);
1227 if ($rawScore !== null && $rawScore !== '') {
1228 $scoreNum = (float)(is_numeric($rawScore) ? $rawScore : 0);
1229 $scorePct = number_format($scoreNum, 0);
1230 if ($scoreNum >= 80) {
1231 $scoreBadgeClass = 'abj404-score-high';
1232 } elseif ($scoreNum >= 50) {
1233 $scoreBadgeClass = 'abj404-score-medium';
1234 } else {
1235 $scoreBadgeClass = 'abj404-score-low';
1236 }
1237 $scoreCell = '<span class="abj404-score-badge ' . $scoreBadgeClass . '">' . esc_html($scorePct) . '%</span>';
1238 } else {
1239 $noScoreTitle = ($rowEngine !== '')
1240 ? __('No confidence score for this engine', '404-solution')
1241 : __('Manual redirect — no confidence score', '404-solution');
1242 $scoreCell = '<span class="abj404-score-manual" title="' . esc_attr($noScoreTitle) . '">—</span>';
1243 }
1244 $htmlTemp = $this->f->str_replace('{scoreCell}', $scoreCell, $htmlTemp);
1245 $htmlTemp = $this->f->str_replace('{type}', $typeForView, $htmlTemp);
1246 $htmlTemp = $this->f->str_replace('{rowCode}', esc_html($codeDisplay), $htmlTemp);
1247 $htmlTemp = $this->f->str_replace('{hits}', esc_html((string)$hits), $htmlTemp);
1248 $htmlTemp = $this->f->str_replace('{logsLink}', $logslink, $htmlTemp);
1249 $htmlTemp = $this->f->str_replace('{trashLink}', $trashlink, $htmlTemp);
1250 $htmlTemp = $this->f->str_replace('{ajaxTrashLink}', $ajaxTrashLink, $htmlTemp);
1251 $htmlTemp = $this->f->str_replace('{trashtitle}', $trashtitle, $htmlTemp);
1252 $htmlTemp = $this->f->str_replace('{deletelink}', $deletelink, $htmlTemp);
1253 $htmlTemp = $this->f->str_replace('{created_date}',
1254 esc_html((string)wp_date("Y/m/d h:i:s A", abs(is_scalar($row['timestamp'] ?? 0) ? intval($row['timestamp'] ?? 0) : 0))), $htmlTemp);
1255 $htmlTemp = $this->f->str_replace('{last_used_date}', esc_html($last), $htmlTemp);
1256
1257 $htmlTemp = $this->f->doNormalReplacements($htmlTemp);
1258 $html .= $htmlTemp;
1259 }
1260 if ($displayed == 0) {
1261 $html .= "<tr>\n" .
1262 "<td colspan=\"10\" class=\"abj404-empty-state\">" .
1263 "<div class=\"abj404-empty-state-icon\">📋</div>" .
1264 "<h3>" . __('No Redirect Records To Display', '404-solution') . "</h3>" .
1265 "<p>" . __('Redirects will appear here once created.', '404-solution') . "</p>" .
1266 "</td></tr>";
1267 }
1268 $html .= "</tbody></table>";
1269
1270 return $html;
1271 }
1272
1273 /**
1274 * @param array<string, mixed> $tableOptions
1275 * @return void
1276 */
1277 function echoAddManualRedirect($tableOptions) {
1278
1279 $options = $this->getOptionsWithDefaults();
1280
1281 $url = "?page=" . ABJ404_PP . "&subpage=abj404_redirects";
1282 $orderby = array_key_exists('orderby', $tableOptions) && is_string($tableOptions['orderby']) ? $tableOptions['orderby'] : 'url';
1283 $order = array_key_exists('order', $tableOptions) && is_string($tableOptions['order']) ? $tableOptions['order'] : 'ASC';
1284 if (!($orderby == "url" && $order == "ASC")) {
1285 $url .= "&orderby=" . sanitize_text_field($orderby) . "&order=" . sanitize_text_field($order);
1286 }
1287 $filter = array_key_exists('filter', $tableOptions) && is_scalar($tableOptions['filter']) ? $tableOptions['filter'] : 0;
1288 if ($filter != 0) {
1289 $url .= "&filter=" . $filter;
1290 }
1291 $link = wp_nonce_url($url, "abj404addRedirect");
1292
1293 $urlPlaceholder = parse_url(get_home_url(), PHP_URL_PATH) . "/example";
1294 if (isset($_POST['url']) && $_POST['url'] != '') {
1295 $postedURL = esc_url($_POST['url']);
1296 } else {
1297 $postedURL = $urlPlaceholder;
1298 }
1299
1300 $selected301 = ($options['default_redirect'] == '301') ? ' selected ' : '';
1301 $selected302 = ($options['default_redirect'] == '302') ? ' selected ' : '';
1302 $selected307 = ($options['default_redirect'] == '307') ? ' selected ' : '';
1303 $selected308 = ($options['default_redirect'] == '308') ? ' selected ' : '';
1304 $selected410 = '';
1305 $selected451 = '';
1306 $selected0 = '';
1307
1308 // read the html content.
1309 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/addManualRedirectTop.html");
1310 $html .= ABJ_404_Solution_Functions::readFileContents(__DIR__ .
1311 "/html/addManualRedirectPageSearchDropdown.html");
1312
1313 $html = $this->f->str_replace('{redirect_to_label}', __('Redirect to', '404-solution'), $html);
1314 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_EMPTY}',
1315 __('(Type a page name or an external URL)', '404-solution'), $html);
1316 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_PAGE}',
1317 __('(A page has been selected.)', '404-solution'), $html);
1318 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_CUSTOM_STRING}',
1319 __('(A custom string has been entered.)', '404-solution'), $html);
1320 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_URL}',
1321 __('(An external URL will be used.)', '404-solution'), $html);
1322 $html = $this->f->str_replace('{REDIRECT_TO_USER_FIELD_WARNING}', '', $html);
1323 $html = $this->f->str_replace('{redirectPageTitle}', '', $html);
1324 $html = $this->f->str_replace('{pageIDAndType}', '', $html);
1325 $html = $this->f->str_replace('{redirectPageTitle}', '', $html);
1326 $html = $this->f->str_replace('{data-url}',
1327 "admin-ajax.php?action=echoRedirectToPages&includeDefault404Page=true&includeSpecial=true&nonce=" . wp_create_nonce('abj404_ajax'), $html);
1328
1329 $html .= ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/addManualRedirectBottom.html");
1330 $html = $this->f->str_replace('{addManualRedirectAction}', $link, $html);
1331 $html = $this->f->str_replace('{urlPlaceholder}', esc_attr($urlPlaceholder), $html);
1332 $html = $this->f->str_replace('{postedURL}', esc_attr($postedURL), $html);
1333 $html = $this->f->str_replace('{301selected}', $selected301, $html);
1334 $html = $this->f->str_replace('{302selected}', $selected302, $html);
1335 $html = $this->f->str_replace('{307selected}', $selected307, $html);
1336 $html = $this->f->str_replace('{308selected}', $selected308, $html);
1337 $html = $this->f->str_replace('{410selected}', $selected410, $html);
1338 $html = $this->f->str_replace('{451selected}', $selected451, $html);
1339 $html = $this->f->str_replace('{0selected}', $selected0, $html);
1340
1341 // constants and translations.
1342 $html = $this->f->doNormalReplacements($html);
1343
1344 echo $html;
1345 }
1346
1347 /** This is used both to add and to edit a redirect.
1348 * @param string $destination
1349 * @param string $codeselected
1350 * @param string $label
1351 */
1352 /**
1353 * @param string $destination
1354 * @param string $codeselected
1355 * @param string $label
1356 * @param string|null $source_page
1357 * @param string|null $filter
1358 * @param string|null $orderby
1359 * @param string|null $order
1360 * @param string $startDate
1361 * @param string $endDate
1362 * @return void
1363 */
1364 function echoEditRedirect($destination, $codeselected, $label, $source_page = null, $filter = null, $orderby = null, $order = null, $startDate = '', $endDate = '') {
1365 // Redirect type — button grid with hidden input
1366 $this->echoRedirectTypeButtonGrid((string)$codeselected);
1367
1368 // Advanced Options: Active From/Until + Conditions (collapsed by default)
1369 {
1370 $redirectId = 0;
1371 if (isset($_GET['id']) && $this->f->regexMatch('[0-9]+', (string)$_GET['id'])) {
1372 $redirectId = absint($_GET['id']);
1373 } elseif (isset($_POST['id']) && $this->f->regexMatch('[0-9]+', (string)$_POST['id'])) {
1374 $redirectId = absint($_POST['id']);
1375 }
1376 $hasExistingConditions = ($redirectId > 0) && !empty($this->dao->getRedirectConditions($redirectId));
1377 $hasAdvancedValues = ($startDate !== '' || $endDate !== '' || $hasExistingConditions);
1378 $openAttr = $hasAdvancedValues ? ' open' : '';
1379 echo '<details class="abj404-advanced-options"' . $openAttr . '>';
1380 echo '<summary class="abj404-advanced-options__summary">' . esc_html__('Advanced Options', '404-solution') . '</summary>';
1381 echo '<div class="abj404-advanced-options__body">';
1382
1383 // Active From
1384 echo '<div class="abj404-form-group">';
1385 echo '<label class="abj404-form-label" for="redirect_start_date">' . esc_html__('Active From (optional)', '404-solution') . '</label>';
1386 echo '<input type="date" name="redirect_start_date" id="redirect_start_date" class="abj404-form-input" value="' . esc_attr($startDate) . '">';
1387 echo '<p class="abj404-form-help">' . esc_html__('Leave blank to activate immediately', '404-solution') . '</p>';
1388 echo '</div>';
1389
1390 // Active Until
1391 echo '<div class="abj404-form-group">';
1392 echo '<label class="abj404-form-label" for="redirect_end_date">' . esc_html__('Active Until (optional)', '404-solution') . '</label>';
1393 echo '<input type="date" name="redirect_end_date" id="redirect_end_date" class="abj404-form-input" value="' . esc_attr($endDate) . '">';
1394 echo '<p class="abj404-form-help">' . esc_html__('Leave blank to never expire', '404-solution') . '</p>';
1395 echo '</div>';
1396
1397 // Conditions
1398 $this->echoRedirectConditionsSection();
1399
1400 echo '</div>'; // end abj404-advanced-options__body
1401 echo '</details>';
1402 }
1403
1404 // Button group
1405 echo '<div class="abj404-button-group">';
1406
1407 // Cancel button
1408 $cancelUrl = '?page=' . ABJ404_PP;
1409 if ($source_page) {
1410 $cancelUrl .= '&subpage=' . esc_attr($source_page);
1411 }
1412 if ($filter !== null) {
1413 $cancelUrl .= '&filter=' . esc_attr($filter);
1414 }
1415 if ($orderby !== null) {
1416 $cancelUrl .= '&orderby=' . esc_attr($orderby);
1417 }
1418 if ($order !== null) {
1419 $cancelUrl .= '&order=' . esc_attr($order);
1420 }
1421 echo '<a href="' . esc_url($cancelUrl) . '" class="abj404-btn abj404-btn-secondary">' . esc_html__('Cancel', '404-solution') . '</a>';
1422
1423 // Submit button
1424 echo '<button type="submit" class="abj404-btn abj404-btn-primary">' . esc_html($label) . '</button>';
1425 echo '</div>';
1426 }
1427
1428 /**
1429 * @param string $currentlySelected
1430 * @return string
1431 */
1432 function echoRedirectDestinationOptionsDefaults($currentlySelected) {
1433 $content = "";
1434 $content .= "\n" . '<optgroup label="' . __('Special', '404-solution') . '">' . "\n";
1435
1436 $selected = "";
1437 if ($currentlySelected == ABJ404_TYPE_EXTERNAL) {
1438 $selected = " selected";
1439 }
1440 $content .= "\n<option value=\"" . ABJ404_TYPE_EXTERNAL . "|" . ABJ404_TYPE_EXTERNAL . "\"" . $selected . ">" .
1441 __('External Page', '404-solution') . "</option>";
1442
1443 if ($currentlySelected == ABJ404_TYPE_HOME) {
1444 $selected = " selected";
1445 } else {
1446 $selected = "";
1447 }
1448 $content .= "\n<option value=\"" . ABJ404_TYPE_HOME . "|" . ABJ404_TYPE_HOME . "\"" . $selected . ">" .
1449 __('Home Page', '404-solution') . "</option>";
1450
1451 $content .= "\n" . '</optgroup>' . "\n";
1452
1453 return $content;
1454 }
1455
1456 /**
1457 * Render the Conditions section on the Edit Redirect page.
1458 *
1459 * Reads the current redirect ID from GET/POST so existing conditions can
1460 * be pre-populated. New redirects (id = 0) render an empty container.
1461 *
1462 * @return void
1463 */
1464 private function echoRedirectConditionsSection(): void {
1465 $redirectId = 0;
1466 if (isset($_GET['id']) && $this->f->regexMatch('[0-9]+', (string)$_GET['id'])) {
1467 $redirectId = absint($_GET['id']);
1468 } elseif (isset($_POST['id']) && $this->f->regexMatch('[0-9]+', (string)$_POST['id'])) {
1469 $redirectId = absint($_POST['id']);
1470 }
1471
1472 $existingConditions = ($redirectId > 0) ? $this->dao->getRedirectConditions($redirectId) : [];
1473
1474 echo '<div class="abj404-form-group abj404-conditions-section">';
1475 echo '<h4>' . esc_html__('Conditions (optional)', '404-solution') . '</h4>';
1476 echo '<p class="abj404-form-help">' . esc_html__('This redirect only fires when all conditions are met. Leave empty to always redirect.', '404-solution') . '</p>';
1477
1478 echo '<div id="abj404-conditions-container">';
1479 foreach ($existingConditions as $i => $cond) {
1480 $this->echoConditionRow($i, $cond);
1481 }
1482 echo '</div>';
1483
1484 echo '<button type="button" onclick="abj404AddConditionRow()" class="button abj404-btn-add-condition">'
1485 . esc_html__('+ Add Condition', '404-solution')
1486 . '</button>';
1487
1488 echo '</div>';
1489
1490 // Hidden template row (display:none) cloned by JS.
1491 echo '<script type="text/template" id="abj404-condition-row-template">';
1492 $this->echoConditionRow('__IDX__', []);
1493 echo '</script>';
1494
1495 // Inline JS for dynamic condition rows.
1496 $this->echoConditionsJavaScript();
1497 }
1498
1499 /**
1500 * Render a single condition row.
1501 *
1502 * @param int|string $index Row index (used in field names).
1503 * @param array<string, mixed> $cond Existing condition data (empty = defaults).
1504 * @return void
1505 */
1506 private function echoConditionRow($index, array $cond): void {
1507 $logic = isset($cond['logic']) && is_string($cond['logic']) ? $cond['logic'] : 'AND';
1508 $type = isset($cond['condition_type']) && is_string($cond['condition_type']) ? $cond['condition_type'] : '';
1509 $operator = isset($cond['operator']) && is_string($cond['operator']) ? $cond['operator'] : 'equals';
1510 $value = isset($cond['value']) && is_string($cond['value']) ? $cond['value'] : '';
1511 $sortOrder = isset($cond['sort_order']) && is_scalar($cond['sort_order']) ? (int)$cond['sort_order'] : (int)$index;
1512
1513 $namePrefix = 'conditions[' . $index . ']';
1514
1515 echo '<div class="abj404-condition-row" data-index="' . esc_attr((string)$index) . '">';
1516
1517 // Logic (AND / OR) — shown only on rows after the first.
1518 echo '<select name="' . esc_attr($namePrefix . '[logic]') . '" class="abj404-condition-logic" aria-label="' . esc_attr__('Logic', '404-solution') . '">';
1519 foreach (['AND' => __('AND', '404-solution'), 'OR' => __('OR', '404-solution')] as $logicVal => $logicLabel) {
1520 $sel = ($logic === $logicVal) ? ' selected' : '';
1521 echo '<option value="' . esc_attr($logicVal) . '"' . $sel . '>' . esc_html($logicLabel) . '</option>';
1522 }
1523 echo '</select>';
1524
1525 // Condition type.
1526 $typeOptions = [
1527 'login_status' => __('Login Status', '404-solution'),
1528 'user_role' => __('User Role', '404-solution'),
1529 'referrer' => __('Referrer URL', '404-solution'),
1530 'user_agent' => __('User Agent', '404-solution'),
1531 'ip_range' => __('IP Range (CIDR)', '404-solution'),
1532 'http_header' => __('HTTP Header', '404-solution'),
1533 ];
1534 echo '<select name="' . esc_attr($namePrefix . '[condition_type]') . '" class="abj404-condition-type" aria-label="' . esc_attr__('Condition type', '404-solution') . '">';
1535 echo '<option value="">' . esc_html__('— Select type —', '404-solution') . '</option>';
1536 foreach ($typeOptions as $typeVal => $typeLabel) {
1537 $sel = ($type === $typeVal) ? ' selected' : '';
1538 echo '<option value="' . esc_attr($typeVal) . '"' . $sel . '>' . esc_html($typeLabel) . '</option>';
1539 }
1540 echo '</select>';
1541
1542 // Operator.
1543 $operatorOptions = [
1544 'equals' => __('equals', '404-solution'),
1545 'not_equals' => __('not equals', '404-solution'),
1546 'contains' => __('contains', '404-solution'),
1547 'not_contains' => __('does not contain', '404-solution'),
1548 'regex' => __('matches regex', '404-solution'),
1549 ];
1550 echo '<select name="' . esc_attr($namePrefix . '[operator]') . '" class="abj404-condition-operator" aria-label="' . esc_attr__('Operator', '404-solution') . '">';
1551 foreach ($operatorOptions as $opVal => $opLabel) {
1552 $sel = ($operator === $opVal) ? ' selected' : '';
1553 echo '<option value="' . esc_attr($opVal) . '"' . $sel . '>' . esc_html($opLabel) . '</option>';
1554 }
1555 echo '</select>';
1556
1557 // Value input.
1558 echo '<input type="text" name="' . esc_attr($namePrefix . '[value]') . '" class="abj404-condition-value abj404-form-input" value="' . esc_attr($value) . '" placeholder="' . esc_attr__('Value', '404-solution') . '" aria-label="' . esc_attr__('Condition value', '404-solution') . '">';
1559
1560 // Sort order (hidden).
1561 echo '<input type="hidden" name="' . esc_attr($namePrefix . '[sort_order]') . '" class="abj404-condition-sort-order" value="' . esc_attr((string)$sortOrder) . '">';
1562
1563 // Remove button.
1564 echo '<button type="button" class="button abj404-remove-condition" onclick="abj404RemoveConditionRow(this)" aria-label="' . esc_attr__('Remove condition', '404-solution') . '">'
1565 . esc_html__('Remove', '404-solution')
1566 . '</button>';
1567
1568 echo '</div>';
1569 }
1570
1571 /**
1572 * Output inline JavaScript that manages dynamic condition rows.
1573 *
1574 * @return void
1575 */
1576 private function echoConditionsJavaScript(): void {
1577 $redirectId = 0;
1578 if (isset($_GET['id']) && is_scalar($_GET['id']) && ctype_digit((string)$_GET['id'])) {
1579 $redirectId = (int)$_GET['id'];
1580 } elseif (isset($_POST['id']) && is_scalar($_POST['id']) && ctype_digit((string)$_POST['id'])) {
1581 $redirectId = (int)$_POST['id'];
1582 }
1583 $initialIndex = ($redirectId > 0) ? max(1, count($this->dao->getRedirectConditions($redirectId))) : 1;
1584
1585 echo '<script type="text/javascript">' . "\n";
1586 echo '(function() {' . "\n";
1587 echo ' var abj404ConditionIndex = ' . (int)$initialIndex . ';' . "\n";
1588 echo "\n";
1589 echo ' window.abj404AddConditionRow = function() {' . "\n";
1590 echo ' var template = document.getElementById(\'abj404-condition-row-template\');' . "\n";
1591 echo ' if (!template) { return; }' . "\n";
1592 echo ' var html = template.innerHTML.replace(/__IDX__/g, String(abj404ConditionIndex));' . "\n";
1593 echo ' var container = document.getElementById(\'abj404-conditions-container\');' . "\n";
1594 echo ' if (!container) { return; }' . "\n";
1595 echo ' var div = document.createElement(\'div\');' . "\n";
1596 echo ' div.innerHTML = html;' . "\n";
1597 echo ' while (div.firstChild) {' . "\n";
1598 echo ' container.appendChild(div.firstChild);' . "\n";
1599 echo ' }' . "\n";
1600 echo ' abj404UpdateConditionSortOrders();' . "\n";
1601 echo ' abj404ConditionIndex++;' . "\n";
1602 echo ' };' . "\n";
1603 echo "\n";
1604 echo ' window.abj404RemoveConditionRow = function(btn) {' . "\n";
1605 echo ' var row = btn.closest(\'.abj404-condition-row\');' . "\n";
1606 echo ' if (row) {' . "\n";
1607 echo ' row.parentNode.removeChild(row);' . "\n";
1608 echo ' abj404UpdateConditionSortOrders();' . "\n";
1609 echo ' }' . "\n";
1610 echo ' };' . "\n";
1611 echo "\n";
1612 echo ' function abj404UpdateConditionSortOrders() {' . "\n";
1613 echo ' var rows = document.querySelectorAll(\'#abj404-conditions-container .abj404-condition-row\');' . "\n";
1614 echo ' for (var i = 0; i < rows.length; i++) {' . "\n";
1615 echo ' var so = rows[i].querySelector(\'.abj404-condition-sort-order\');' . "\n";
1616 echo ' if (so) { so.value = String(i); }' . "\n";
1617 echo ' }' . "\n";
1618 echo ' }' . "\n";
1619 echo '}());' . "\n";
1620 echo '</script>' . "\n";
1621 }
1622
1623 }
1624