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_Stats.php

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

346 lines 17.6 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_Stats methods.
9 */
10 class ABJ_404_Solution_View_Stats extends ABJ_404_Solution_ViewComponent {
11
12
13 /**
14 * Load an HTML template from includes/html/ as a string.
15 *
16 * Centralized so every section of this class loads templates the same
17 * way. Returns the raw template contents; callers perform their own
18 * placeholder substitution via Functions::str_replace().
19 *
20 * @param string $name Filename relative to includes/html/.
21 * @return string
22 */
23 private function tpl($name) {
24 return (string)ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name);
25 }
26
27 /**
28 * Output the stats page.
29 * @return void
30 */
31 function outputAdminStatsPage() {
32 global $abj404view;
33
34 $statsSnapshot = $this->statsRepository->getStatsDashboardSnapshot(true);
35 $statsData = $statsSnapshot['data'];
36 $statsHash = $statsSnapshot['hash'];
37
38 // Header (container open + h2 + Expand All button).
39 $header = $this->tpl('viewStatsPageHeader.html');
40 $header = $this->f->str_replace('{title}', esc_html__('Statistics', '404-solution'), $header);
41 $header = $this->f->str_replace('{expand_all}', esc_html__('Expand All', '404-solution'), $header);
42 echo $header;
43
44 // Config for stale-while-refresh stats snapshot updates (no visible table overwrite).
45 $refresh = $this->tpl('viewStatsRefreshConfig.html');
46 $refresh = $this->f->str_replace('{refresh_nonce}', esc_attr(wp_create_nonce('abj404_refreshStatsDashboard')), $refresh);
47 $refresh = $this->f->str_replace('{current_hash}', esc_attr($statsHash), $refresh);
48 $refresh = $this->f->str_replace('{available_text}', esc_attr(__('Refresh available', '404-solution')), $refresh);
49 echo $refresh;
50
51 // Flow layout for stats cards.
52 echo $this->tpl('viewStatsFlowLayoutOpen.html');
53
54 // Redirects Statistics Card
55 $redirectStats = (is_array($statsData) && isset($statsData['redirects']) && is_array($statsData['redirects']))
56 ? $statsData['redirects']
57 : array();
58 $auto301 = intval($redirectStats['auto301'] ?? 0);
59 $auto302 = intval($redirectStats['auto302'] ?? 0);
60 $manual301 = intval($redirectStats['manual301'] ?? 0);
61 $manual302 = intval($redirectStats['manual302'] ?? 0);
62 $trashed = intval($redirectStats['trashed'] ?? 0);
63
64 $total = $auto301 + $auto302 + $manual301 + $manual302 + $trashed;
65
66 $content = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/statsRedirectsBox.html");
67
68 // In Simple mode, replace technical "301"/"302" labels with plain language
69 if (abj_service('settings_mode_preference')->getMode() === 'simple') {
70 $content = $this->f->str_replace('{Automatic 301 Redirects}', esc_html__('Automatic Permanent Redirects', '404-solution'), $content);
71 $content = $this->f->str_replace('{Automatic 302 Redirects}', esc_html__('Automatic Temporary Redirects', '404-solution'), $content);
72 $content = $this->f->str_replace('{Manual 301 Redirects}', esc_html__('Manual Permanent Redirects', '404-solution'), $content);
73 $content = $this->f->str_replace('{Manual 302 Redirects}', esc_html__('Manual Temporary Redirects', '404-solution'), $content);
74 }
75
76 $content = $this->f->str_replace('{auto301}', esc_html((string)$auto301), $content);
77 $content = $this->f->str_replace('{auto302}', esc_html((string)$auto302), $content);
78 $content = $this->f->str_replace('{manual301}', esc_html((string)$manual301), $content);
79 $content = $this->f->str_replace('{manual302}', esc_html((string)$manual302), $content);
80 $content = $this->f->str_replace('{trashed}', esc_html((string)$trashed), $content);
81 $content = $this->f->str_replace('{total}', esc_html((string)$total), $content);
82 $content = $this->f->doNormalReplacements($content);
83 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('stats-redirects', 'abj404-redirectStats', __('Redirects', '404-solution'), $content, true, $abj404view->getCardIcon('chart')));
84
85 // Captured URLs Statistics Card
86 $capturedStats = (is_array($statsData) && isset($statsData['captured']) && is_array($statsData['captured']))
87 ? $statsData['captured']
88 : array();
89 $captured = intval($capturedStats['captured'] ?? 0);
90 $ignored = intval($capturedStats['ignored'] ?? 0);
91 $trashed = intval($capturedStats['trashed'] ?? 0);
92
93 $total = $captured + $ignored + $trashed;
94
95 $content = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/statsCapturedURLsBox.html");
96 $content = $this->f->str_replace('{captured}', esc_html((string)$captured), $content);
97 $content = $this->f->str_replace('{ignored}', esc_html((string)$ignored), $content);
98 $content = $this->f->str_replace('{trashed}', esc_html((string)$trashed), $content);
99 $content = $this->f->str_replace('{total}', esc_html((string)$total), $content);
100 $content = $this->f->doNormalReplacements($content);
101 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('stats-captured', 'abj404-capturedStats', __('Captured URLs', '404-solution'), $content, true, $abj404view->getCardIcon('warning')));
102
103 // Periodic Stats Cards
104 $periodicStats = (is_array($statsData) && isset($statsData['periods']) && is_array($statsData['periods']))
105 ? $statsData['periods']
106 : array();
107 $periodMeta = array(
108 array('title' => __("Today's Stats", '404-solution'), 'key' => 'today'),
109 array('title' => __("This Month", '404-solution'), 'key' => 'month'),
110 array('title' => __("This Year", '404-solution'), 'key' => 'year'),
111 array('title' => __("All Stats", '404-solution'), 'key' => 'all'),
112 );
113
114 for ($x = 0; $x <= 3; $x++) {
115 $title = $periodMeta[$x]['title'];
116 $periodKey = $periodMeta[$x]['key'];
117 $periodStats = (is_array($periodicStats) && isset($periodicStats[$periodKey]) && is_array($periodicStats[$periodKey]))
118 ? $periodicStats[$periodKey]
119 : array();
120 $disp404 = intval($periodStats['disp404'] ?? 0);
121 $distinct404 = intval($periodStats['distinct404'] ?? 0);
122 $visitors404 = intval($periodStats['visitors404'] ?? 0);
123 $refer404 = intval($periodStats['refer404'] ?? 0);
124 $redirected = intval($periodStats['redirected'] ?? 0);
125 $distinctredirected = intval($periodStats['distinctredirected'] ?? 0);
126 $distinctvisitors = intval($periodStats['distinctvisitors'] ?? 0);
127 $distinctrefer = intval($periodStats['distinctrefer'] ?? 0);
128
129 $content = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/statsPeriodicBox.html");
130 $content = $this->f->str_replace('{disp404}', esc_html((string)$disp404), $content);
131 $content = $this->f->str_replace('{distinct404}', esc_html((string)$distinct404), $content);
132 $content = $this->f->str_replace('{visitors404}', esc_html((string)$visitors404), $content);
133 $content = $this->f->str_replace('{refer404}', esc_html((string)$refer404), $content);
134 $content = $this->f->str_replace('{redirected}', esc_html((string)$redirected), $content);
135 $content = $this->f->str_replace('{distinctredirected}', esc_html((string)$distinctredirected), $content);
136 $content = $this->f->str_replace('{distinctvisitors}', esc_html((string)$distinctvisitors), $content);
137 $content = $this->f->str_replace('{distinctrefer}', esc_html((string)$distinctrefer), $content);
138 $content = $this->f->doNormalReplacements($content);
139 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('stats-periodic-' . $x, 'abj404-stats' . $x, $title, $content, ($x == 0), $abj404view->getCardIcon('clock')));
140 }
141
142 // Match Confidence distribution card (full-width)
143 $this->echoConfidenceDistributionSection();
144
145 // Trend Analytics section (full-width, below the flow layout cards)
146 $this->echoTrendsSection();
147
148 // Broken Internal Links section
149 $this->echoBrokenInternalLinksSection();
150
151 // Closes flow layout, settings content, and container in that order.
152 echo $this->tpl('viewStatsPageFooter.html');
153 }
154
155 /**
156 * Output the Match Confidence distribution card on the Stats page.
157 * Asks the stats repository for the score-band counts and renders them
158 * into the Chart.js doughnut card. Presentation only: the SQL, table name,
159 * and HIGH/MEDIUM thresholds live in
160 * ABJ_404_Solution_StatsReadRepository::getConfidenceBandCounts().
161 * @return void
162 */
163 public function echoConfidenceDistributionSection() {
164 global $abj404view;
165
166 // Ask the stats repository for the band counts. The repository owns the
167 // table name, the SQL, and the HIGH/MEDIUM thresholds; this view method
168 // only formats the result into the card template.
169 $bands = $this->statsRepository->getConfidenceBandCounts();
170 if (!is_array($bands) || (int)($bands['total'] ?? 0) === 0) {
171 return;
172 }
173
174 $highCount = (int)($bands['high'] ?? 0);
175 $mediumCount = (int)($bands['medium'] ?? 0);
176 $lowCount = (int)($bands['low'] ?? 0);
177 $manualCount = (int)($bands['manual'] ?? 0);
178 $avgRaw = $bands['avg'] ?? null;
179 $avgScore = is_numeric($avgRaw) ? (float)$avgRaw : null;
180
181 $labelHigh = esc_html__('High (≥80%)', '404-solution');
182 $labelMedium = esc_html__('Medium (50–79%)', '404-solution');
183 $labelLow = esc_html__('Low (<50%)', '404-solution');
184 $labelManual = esc_html__('Manual (no score)', '404-solution');
185
186 $avgLabel = ($avgScore !== null)
187 ? sprintf(
188 '<strong>' . esc_html__('Avg confidence: %s%%', '404-solution') . '</strong>',
189 esc_html(number_format($avgScore, 1))
190 )
191 : '';
192
193 // Configuration carrier for statsConfidenceChart.js. The external JS
194 // reads labels + band counts from this canvas's data attribute, so
195 // PHP doesn't need to inline any JavaScript.
196 $confidenceConfig = wp_json_encode(array(
197 'labelHigh' => $labelHigh,
198 'labelMedium' => $labelMedium,
199 'labelLow' => $labelLow,
200 'labelManual' => $labelManual,
201 'high' => $highCount,
202 'medium' => $mediumCount,
203 'low' => $lowCount,
204 'manual' => $manualCount,
205 ));
206
207 // Confidence chart rendering moved to includes/js/statsConfidenceChart.js.
208 // The canvas in the template carries its config via data-abj404-confidence.
209 $content = $this->tpl('viewStatsConfidenceDistribution.html');
210 $content = $this->f->str_replace('{avg_label}', $avgLabel, $content);
211 $content = $this->f->str_replace('{confidence_config}', esc_attr((string)$confidenceConfig), $content);
212 $content = $this->f->str_replace('{label_high}', esc_html($labelHigh), $content);
213 $content = $this->f->str_replace('{label_medium}', esc_html($labelMedium), $content);
214 $content = $this->f->str_replace('{label_low}', esc_html($labelLow), $content);
215 $content = $this->f->str_replace('{label_manual}', esc_html($labelManual), $content);
216 $content = $this->f->str_replace('{high_count}', esc_html((string)$highCount), $content);
217 $content = $this->f->str_replace('{medium_count}', esc_html((string)$mediumCount), $content);
218 $content = $this->f->str_replace('{low_count}', esc_html((string)$lowCount), $content);
219 $content = $this->f->str_replace('{manual_count}', esc_html((string)$manualCount), $content);
220
221 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
222 'stats-confidence',
223 'abj404-confidenceSection',
224 __('Match Confidence', '404-solution'),
225 $content,
226 false,
227 $abj404view->getCardIcon('check')
228 ));
229 }
230
231 /**
232 * Output the Trends (time-series charts) section on the Stats page.
233 * @return void
234 */
235 public function echoTrendsSection() {
236 global $abj404view;
237
238 $trendNonce = wp_create_nonce('abj404_trendData');
239 $ajaxUrl = admin_url('admin-ajax.php');
240
241 $label7d = esc_html__('7 days', '404-solution');
242 $label30d = esc_html__('30 days', '404-solution');
243 $label90d = esc_html__('90 days', '404-solution');
244
245 // Trend chart rendering moved to includes/js/statsTrends.js. We emit a
246 // small JSON config carrier; the external JS reads ajaxUrl/nonce/labels
247 // from #abj404-trends-config's data attribute.
248 $trendsConfig = wp_json_encode(array(
249 'ajaxUrl' => $ajaxUrl,
250 'nonce' => $trendNonce,
251 'label404' => __('404 Hits per Day', '404-solution'),
252 'labelRedirect' => __('Redirects per Day', '404-solution'),
253 'labelCapture' => __('New Captures per Day', '404-solution'),
254 ));
255
256 $trendsContent = $this->tpl('viewStatsTrendsSection.html');
257 $trendsContent = $this->f->str_replace('{period_aria_label}', esc_attr__('Period', '404-solution'), $trendsContent);
258 $trendsContent = $this->f->str_replace('{label_7d}', $label7d, $trendsContent);
259 $trendsContent = $this->f->str_replace('{label_30d}', $label30d, $trendsContent);
260 $trendsContent = $this->f->str_replace('{label_90d}', $label90d, $trendsContent);
261 $trendsContent = $this->f->str_replace('{loading_text}', esc_html__('Loading chart data…', '404-solution'), $trendsContent);
262 $trendsContent = $this->f->str_replace('{error_text}', esc_html__('Could not load chart data.', '404-solution'), $trendsContent);
263 $trendsContent = $this->f->str_replace('{trends_config}', esc_attr((string)$trendsConfig), $trendsContent);
264
265 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
266 'stats-trends',
267 'abj404-trendsSection',
268 __('Trend Analytics', '404-solution'),
269 $trendsContent,
270 false,
271 $abj404view->getCardIcon('chart')
272 ));
273 }
274
275 /**
276 * Output the Broken Internal Links section on the Stats page (if results are cached).
277 * @return void
278 */
279 public function echoBrokenInternalLinksSection() {
280 global $abj404view;
281
282 if (!class_exists('ABJ_404_Solution_InternalLinkScanner')) {
283 return;
284 }
285
286 $scanner = new ABJ_404_Solution_InternalLinkScanner();
287 $results = $scanner->getCachedResults();
288
289 if ($results === false || !is_array($results)) {
290 // No cached results yet — nothing to show.
291 return;
292 }
293
294 if (empty($results)) {
295 $content = $this->tpl('viewStatsBrokenLinksEmpty.html');
296 $content = $this->f->str_replace('{empty_text}', esc_html__('No broken internal links found.', '404-solution'), $content);
297 } else {
298 $postCount = count(array_unique(array_column($results, 'post_id')));
299 $rowLinkedTpl = $this->tpl('viewStatsBrokenLinksRowLinked.html');
300 $rowPlainTpl = $this->tpl('viewStatsBrokenLinksRowPlain.html');
301 $rowsHtml = '';
302 foreach ($results as $item) {
303 if (!is_array($item)) {
304 continue;
305 }
306 $postTitle = (string)$item['post_title'];
307 $brokenUrl = (string)$item['broken_url'];
308 $hitCount = intval($item['hit_count']);
309 $postId = intval($item['post_id']);
310 $editLink = ($postId > 0) ? get_edit_post_link($postId) : '';
311 if ($editLink) {
312 $row = $this->f->str_replace('{edit_link}', esc_url($editLink), $rowLinkedTpl);
313 $row = $this->f->str_replace('{post_title}', esc_html($postTitle), $row);
314 } else {
315 $row = $this->f->str_replace('{post_title}', esc_html($postTitle), $rowPlainTpl);
316 }
317 $row = $this->f->str_replace('{broken_url}', esc_html($brokenUrl), $row);
318 $row = $this->f->str_replace('{hit_count}', esc_html((string)$hitCount), $row);
319 $rowsHtml .= $row;
320 }
321 $summary = esc_html(sprintf(
322 /* translators: 1: number of broken links, 2: number of posts/pages */
323 __('Found %1$d broken internal link(s) across %2$d post(s)/page(s).', '404-solution'),
324 count($results),
325 $postCount
326 ));
327 $content = $this->tpl('viewStatsBrokenLinksTable.html');
328 $content = $this->f->str_replace('{summary_text}', $summary, $content);
329 $content = $this->f->str_replace('{th_post}', esc_html__('Post/Page', '404-solution'), $content);
330 $content = $this->f->str_replace('{th_broken_url}', esc_html__('Broken URL', '404-solution'), $content);
331 $content = $this->f->str_replace('{th_hits}', esc_html__('404 Hits', '404-solution'), $content);
332 $content = $this->f->str_replace('{rows}', $rowsHtml, $content);
333 }
334
335 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
336 'stats-broken-links',
337 'abj404-brokenLinksSection',
338 __('Broken Internal Links', '404-solution'),
339 $content,
340 false,
341 $abj404view->getCardIcon('warning')
342 ));
343 }
344
345 }
346