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

ShortcodeSuggestionsAdminNotePresenter.php in 404 Solution trunk, at includes/view/ShortcodeSuggestionsAdminNotePresenter.php

215 lines 8.8 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 * Presents the admin-only explanation block that sits below the front-end
9 * suggestion list.
10 *
11 * What an admin testing the plugin used to see on a 404 that did not clear
12 * auto_score was a list of pages with a bare number in brackets beside each
13 * one, and nothing else: no statement of what the number is measured against,
14 * what the bar is, or where the bar lives. That is the whole of uninstall
15 * report 57 ("it just sent the visitor to a page with a suggested link") --
16 * the plugin was working exactly as designed and the admin had no way to
17 * learn the knob existed.
18 *
19 * This block names the score, names the bar in force, and offers both
20 * remedies: redirect this one URL by hand, or change the score an automatic
21 * redirect has to clear. It renders only for plugin admins (the same gate
22 * that already controls the inline score), so no visitor ever sees it.
23 *
24 * // allow-no-test-found: exercised by SuggestionsPageAdminNoteTest
25 */
26 class ABJ_404_Solution_ShortcodeSuggestionsAdminNotePresenter {
27
28 /** Stylesheet handle shared with the async-suggestions loading skeleton. */
29 const STYLE_HANDLE = 'abj404-suggestions-loading';
30
31 /** The id of the auto_score field on the options page. */
32 const SCORE_FIELD_ANCHOR = 'auto_score';
33
34 /**
35 * Render the block.
36 *
37 * @param bool $hasMatches Whether any suggestion was actually displayed.
38 * @param float $bestScore Highest score among the displayed suggestions.
39 * Ignored when $hasMatches is false: there is no score to report, so the
40 * no-match wording never quotes one.
41 * @param string $requestedURL The URL that 404'd, used to pre-fill the
42 * manual redirect form. May be empty, in which case the link still
43 * works and simply opens an empty form.
44 * @param array<string, mixed> $options The plugin options.
45 * @return string
46 */
47 public function render(bool $hasMatches, float $bestScore, string $requestedURL, array $options): string {
48 $this->enqueueStyles();
49
50 $redirectUrl = $this->manualRedirectUrl($requestedURL, $options);
51 $redirectLabel = esc_html__('Redirect this URL', '404-solution');
52
53 if (!$hasMatches) {
54 return $this->fillTemplate('shortcodeSuggestionsAdminNoteNoMatch.html', array(
55 'state_line' => esc_html__(
56 '404 Solution, admin only: nothing on the site scored close enough to suggest.',
57 '404-solution'),
58 'redirect_url' => esc_url($redirectUrl),
59 'redirect_label' => $redirectLabel,
60 ));
61 }
62
63 return $this->fillTemplate('shortcodeSuggestionsAdminNote.html', array(
64 'state_line' => esc_html($this->stateLine($bestScore, $options)),
65 'redirect_url' => esc_url($redirectUrl),
66 'redirect_label' => $redirectLabel,
67 'score_url' => esc_url(
68 ABJ_404_Solution_SettingsModeDeepLink::urlForAdvancedSetting(self::SCORE_FIELD_ANCHOR, $options)),
69 'score_label' => esc_html__('Change the minimum score', '404-solution'),
70 ));
71 }
72
73 /**
74 * The sentence that states where this 404 landed relative to the bar.
75 *
76 * Two wordings rather than one, because a match can sit at or above the
77 * bar and still show suggestions (automatic redirects turned off, the
78 * best match excluded from redirects, the match being the current page).
79 * Telling that admin the score was "under" the bar would be false on
80 * screen, so the clause flips and the sentence stays true either way.
81 *
82 * @param float $bestScore
83 * @param array<string, mixed> $options
84 * @return string
85 */
86 private function stateLine(float $bestScore, array $options): string {
87 $threshold = ABJ_404_Solution_MinimumAutoRedirectScore::forDisplay($options);
88 $thresholdValue = ABJ_404_Solution_MinimumAutoRedirectScore::asFloat($options);
89 $scoreText = $this->scoreText(array(
90 'score' => $bestScore,
91 'threshold' => $thresholdValue,
92 ));
93
94 if ($bestScore < $thresholdValue) {
95 return sprintf(
96 /* translators: 1: best match score, 2: the configured minimum score. */
97 __('404 Solution, admin only: best match %1$s, under the %2$s needed to redirect automatically.',
98 '404-solution'),
99 $scoreText, $threshold);
100 }
101
102 return sprintf(
103 /* translators: 1: best match score, 2: the configured minimum score. */
104 __('404 Solution, admin only: best match %1$s, at or above the %2$s needed to redirect automatically.',
105 '404-solution'),
106 $scoreText, $threshold);
107 }
108
109 /**
110 * The best score, written at a precision that cannot contradict the clause
111 * printed beside it.
112 *
113 * The suggestion rows show two decimals, so that is where this starts. But
114 * rounding a score to two decimals can move it ACROSS the bar: a match of
115 * 76.9999 against a bar of 77 did not redirect, and is displayed by a plain
116 * number_format() as "77.00", producing "best match 77.00, under the 77
117 * needed to redirect automatically" -- a sentence its own number disproves,
118 * on the one screen whose entire job is to explain that number. Flipping the
119 * clause instead would be worse: it would tell the admin the match cleared a
120 * bar it did not clear, and leave them hunting for why no redirect happened.
121 *
122 * So the precision grows until the number as WRITTEN sits on the same side of
123 * the bar as the number as MEASURED. Any score not within a rounding error of
124 * the bar -- which is very nearly all of them -- comes back at two decimals,
125 * exactly as the row above it reads.
126 *
127 * @param array{score: float, threshold: float} $comparison The best score
128 * and the score an automatic redirect has to clear.
129 * @return string
130 */
131 private function scoreText(array $comparison): string {
132 $score = $comparison['score'];
133 $threshold = $comparison['threshold'];
134 $isUnder = $score < $threshold;
135
136 for ($decimals = 2; $decimals < 10; $decimals++) {
137 $text = number_format($score, $decimals);
138 if (((float)str_replace(',', '', $text) < $threshold) === $isUnder) {
139 return $text;
140 }
141 }
142
143 return number_format($score, 10);
144 }
145
146 /**
147 * Link to the Page Redirects screen with the Add Manual Redirect form
148 * open and this URL already filled in.
149 *
150 * @param string $requestedURL
151 * @param array<string, mixed> $options
152 * @return string
153 */
154 private function manualRedirectUrl(string $requestedURL, array $options): string {
155 $args = array();
156 $path = $this->requestedPath($requestedURL);
157 if ($path !== '') {
158 $args['abj404_add_url'] = $path;
159 }
160 return ABJ_404_Solution_AdminPageUrlBuilder::subpageUrl('abj404_redirects', $args, $options);
161 }
162
163 /**
164 * The site-relative path of the 404'd request, which is the form the
165 * manual redirect form stores. Query string and fragment are dropped:
166 * a redirect rule matches on the path.
167 *
168 * @param string $requestedURL
169 * @return string Empty when no usable path can be read.
170 */
171 private function requestedPath(string $requestedURL): string {
172 if ($requestedURL === '') {
173 return '';
174 }
175 $path = parse_url($requestedURL, PHP_URL_PATH);
176 if (!is_string($path) || $path === '') {
177 return '';
178 }
179 return $path[0] === '/' ? $path : '/' . $path;
180 }
181
182 /**
183 * Make sure the front-end suggestions stylesheet is on the page.
184 *
185 * Idempotent, and a no-op in the async path, where the placeholder render
186 * has already enqueued it (an AJAX response cannot enqueue anything: its
187 * HTML is injected into a page whose head has long since been sent).
188 *
189 * @return void
190 */
191 private function enqueueStyles(): void {
192 if (!function_exists('wp_enqueue_style') || !defined('ABJ404_URL')) {
193 return;
194 }
195 wp_enqueue_style(self::STYLE_HANDLE,
196 ABJ404_URL . 'includes/css/suggestions-loading.css', array(), ABJ404_VERSION);
197 }
198
199 /**
200 * @param string $name
201 * @param array<string, string> $vars
202 * @return string
203 */
204 private function fillTemplate(string $name, array $vars): string {
205 $template = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name, false);
206 $search = [];
207 $replace = [];
208 foreach ($vars as $key => $value) {
209 $search[] = '{' . $key . '}';
210 $replace[] = $value;
211 }
212 return str_replace($search, $replace, (string)$template);
213 }
214 }
215