PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/view/View_Stats.php

352 lines 18.1 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 // The band counts rows where score IS NULL, which is not the same as
185 // "manual": a captured 404 that no engine scored has no score either.
186 $labelManual = esc_html__('No score', '404-solution');
187
188 $avgLabel = ($avgScore !== null)
189 ? sprintf(
190 '<strong>' . esc_html__('Avg confidence: %s%%', '404-solution') . '</strong>',
191 esc_html(number_format($avgScore, 1))
192 )
193 : '';
194
195 // Configuration carrier for statsConfidenceChart.js. The external JS
196 // reads labels + band counts from this canvas's data attribute, so
197 // PHP doesn't need to inline any JavaScript.
198 // Encoded through ABJ_404_Solution_JsonResponseEncoder, not wp_json_encode:
199 // these labels are __() results, so a co-plugin hooked on `gettext` can hand
200 // back bytes json_encode() cannot represent. wp_json_encode() answers that with
201 // false, esc_attr((string)false) is '', and the chart's JSON.parse('') throws.
202 $confidenceConfig = ABJ_404_Solution_JsonResponseEncoder::encode(array(
203 'labelHigh' => $labelHigh,
204 'labelMedium' => $labelMedium,
205 'labelLow' => $labelLow,
206 'labelManual' => $labelManual,
207 'high' => $highCount,
208 'medium' => $mediumCount,
209 'low' => $lowCount,
210 'manual' => $manualCount,
211 ))->json();
212
213 // Confidence chart rendering moved to includes/js/statsConfidenceChart.js.
214 // The canvas in the template carries its config via data-abj404-confidence.
215 $content = $this->tpl('viewStatsConfidenceDistribution.html');
216 $content = $this->f->str_replace('{avg_label}', $avgLabel, $content);
217 $content = $this->f->str_replace('{confidence_config}', esc_attr($confidenceConfig), $content);
218 $content = $this->f->str_replace('{label_high}', esc_html($labelHigh), $content);
219 $content = $this->f->str_replace('{label_medium}', esc_html($labelMedium), $content);
220 $content = $this->f->str_replace('{label_low}', esc_html($labelLow), $content);
221 $content = $this->f->str_replace('{label_manual}', esc_html($labelManual), $content);
222 $content = $this->f->str_replace('{high_count}', esc_html((string)$highCount), $content);
223 $content = $this->f->str_replace('{medium_count}', esc_html((string)$mediumCount), $content);
224 $content = $this->f->str_replace('{low_count}', esc_html((string)$lowCount), $content);
225 $content = $this->f->str_replace('{manual_count}', esc_html((string)$manualCount), $content);
226
227 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
228 'stats-confidence',
229 'abj404-confidenceSection',
230 __('Match Confidence', '404-solution'),
231 $content,
232 false,
233 $abj404view->getCardIcon('check')
234 ));
235 }
236
237 /**
238 * Output the Trends (time-series charts) section on the Stats page.
239 * @return void
240 */
241 public function echoTrendsSection() {
242 global $abj404view;
243
244 $trendNonce = wp_create_nonce('abj404_trendData');
245 $ajaxUrl = admin_url('admin-ajax.php');
246
247 $label7d = esc_html__('7 days', '404-solution');
248 $label30d = esc_html__('30 days', '404-solution');
249 $label90d = esc_html__('90 days', '404-solution');
250
251 // Trend chart rendering moved to includes/js/statsTrends.js. We emit a
252 // small JSON config carrier; the external JS reads ajaxUrl/nonce/labels
253 // from #abj404-trends-config's data attribute.
254 $trendsConfig = wp_json_encode(array(
255 'ajaxUrl' => $ajaxUrl,
256 'nonce' => $trendNonce,
257 'label404' => __('404 Hits per Day', '404-solution'),
258 'labelRedirect' => __('Redirects per Day', '404-solution'),
259 'labelCapture' => __('New Captures per Day', '404-solution'),
260 ));
261
262 $trendsContent = $this->tpl('viewStatsTrendsSection.html');
263 $trendsContent = $this->f->str_replace('{period_aria_label}', esc_attr__('Period', '404-solution'), $trendsContent);
264 $trendsContent = $this->f->str_replace('{label_7d}', $label7d, $trendsContent);
265 $trendsContent = $this->f->str_replace('{label_30d}', $label30d, $trendsContent);
266 $trendsContent = $this->f->str_replace('{label_90d}', $label90d, $trendsContent);
267 $trendsContent = $this->f->str_replace('{loading_text}', esc_html__('Loading chart data…', '404-solution'), $trendsContent);
268 $trendsContent = $this->f->str_replace('{error_text}', esc_html__('Could not load chart data.', '404-solution'), $trendsContent);
269 $trendsContent = $this->f->str_replace('{trends_config}', esc_attr((string)$trendsConfig), $trendsContent);
270
271 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
272 'stats-trends',
273 'abj404-trendsSection',
274 __('Trend Analytics', '404-solution'),
275 $trendsContent,
276 false,
277 $abj404view->getCardIcon('chart')
278 ));
279 }
280
281 /**
282 * Output the Broken Internal Links section on the Stats page (if results are cached).
283 * @return void
284 */
285 public function echoBrokenInternalLinksSection() {
286 global $abj404view;
287
288 if (!class_exists('ABJ_404_Solution_InternalLinkScanner')) {
289 return;
290 }
291
292 $scanner = new ABJ_404_Solution_InternalLinkScanner();
293 $results = $scanner->getCachedResults();
294
295 if ($results === false || !is_array($results)) {
296 // No cached results yet — nothing to show.
297 return;
298 }
299
300 if (empty($results)) {
301 $content = $this->tpl('viewStatsBrokenLinksEmpty.html');
302 $content = $this->f->str_replace('{empty_text}', esc_html__('No broken internal links found.', '404-solution'), $content);
303 } else {
304 $postCount = count(array_unique(array_column($results, 'post_id')));
305 $rowLinkedTpl = $this->tpl('viewStatsBrokenLinksRowLinked.html');
306 $rowPlainTpl = $this->tpl('viewStatsBrokenLinksRowPlain.html');
307 $rowsHtml = '';
308 foreach ($results as $item) {
309 if (!is_array($item)) {
310 continue;
311 }
312 $postTitle = (string)$item['post_title'];
313 $brokenUrl = (string)$item['broken_url'];
314 $hitCount = intval($item['hit_count']);
315 $postId = intval($item['post_id']);
316 $editLink = ($postId > 0) ? get_edit_post_link($postId) : '';
317 if ($editLink) {
318 $row = $this->f->str_replace('{edit_link}', esc_url($editLink), $rowLinkedTpl);
319 $row = $this->f->str_replace('{post_title}', esc_html($postTitle), $row);
320 } else {
321 $row = $this->f->str_replace('{post_title}', esc_html($postTitle), $rowPlainTpl);
322 }
323 $row = $this->f->str_replace('{broken_url}', esc_html($brokenUrl), $row);
324 $row = $this->f->str_replace('{hit_count}', esc_html((string)$hitCount), $row);
325 $rowsHtml .= $row;
326 }
327 $summary = esc_html(sprintf(
328 /* translators: 1: number of broken links, 2: number of posts/pages */
329 __('Found %1$d broken internal link(s) across %2$d post(s)/page(s).', '404-solution'),
330 count($results),
331 $postCount
332 ));
333 $content = $this->tpl('viewStatsBrokenLinksTable.html');
334 $content = $this->f->str_replace('{summary_text}', $summary, $content);
335 $content = $this->f->str_replace('{th_post}', esc_html__('Post/Page', '404-solution'), $content);
336 $content = $this->f->str_replace('{th_broken_url}', esc_html__('Broken URL', '404-solution'), $content);
337 $content = $this->f->str_replace('{th_hits}', esc_html__('404 Hits', '404-solution'), $content);
338 $content = $this->f->str_replace('{rows}', $rowsHtml, $content);
339 }
340
341 $abj404view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView(
342 'stats-broken-links',
343 'abj404-brokenLinksSection',
344 __('Broken Internal Links', '404-solution'),
345 $content,
346 false,
347 $abj404view->getCardIcon('warning')
348 ));
349 }
350
351 }
352