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

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

360 lines 15.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 * Presents frontend shortcode suggestion lists and loading placeholders.
9 */
10 class ABJ_404_Solution_ShortcodeSuggestionsPresenter {
11
12 /**
13 * Render suggestions HTML from a pre-computed suggestions packet.
14 *
15 * @param array<int, mixed> $suggestionsPacket
16 * @param string $requestedURL
17 * @param array<string, mixed>|null $options
18 * @param bool $adminDebugModal
19 * @return string
20 */
21 public function renderSuggestionsHTML(array $suggestionsPacket, string $requestedURL = '',
22 ?array $options = null, bool $adminDebugModal = false): string {
23 $options = $options ?? abj_service('options_repository')->getOptions(true);
24
25 $permalinkSuggestions = isset($suggestionsPacket[0]) ? (array)$suggestionsPacket[0] : [];
26 $rowType = isset($suggestionsPacket[1]) && is_string($suggestionsPacket[1])
27 ? $suggestionsPacket[1] : 'pages';
28
29 $showExtraAdminData = $this->shouldShowAdminData();
30 $adminDebugPresenter = new ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter();
31 $extraDataById = ($showExtraAdminData && $adminDebugModal)
32 ? $adminDebugPresenter->collectExtraData($permalinkSuggestions, abj_service('view_read_service'), abj_service('functions'))
33 : [];
34 $adminDebugData = [];
35
36 $rowResult = $this->renderSuggestionRows($permalinkSuggestions, $rowType, $options,
37 $showExtraAdminData, $adminDebugModal, $adminDebugPresenter, $extraDataById, $adminDebugData);
38 $body = $rowResult['body'] . $this->renderResultFooter($rowResult['displayed'], $options)
39 . $this->renderAdminNote($showExtraAdminData, $rowResult, $requestedURL, $options);
40
41 $content = $this->fillTemplate('shortcodeSuggestionsContainer.html', array(
42 'title' => wp_kses_post(
43 $this->replaceSuggestionTemplateToken($this->optionString($options, 'suggest_title'),
44 'suggest_title_text', __('Here are some other great pages', '404-solution'))
45 ),
46 'body' => $body,
47 ));
48
49 if ($showExtraAdminData && $adminDebugModal && !empty($adminDebugData)) {
50 $content .= $adminDebugPresenter->renderScript($adminDebugData);
51 }
52
53 return $content;
54 }
55
56 /**
57 * @param array<int|string, mixed> $permalinkSuggestions
58 * @param string $rowType
59 * @param array<string, mixed> $options
60 * @param bool $showExtraAdminData
61 * @param bool $adminDebugModal
62 * @param ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter $adminDebugPresenter
63 * @param array<string, array<string, mixed>> $extraDataById
64 * @param array<int, array<string, mixed>> $adminDebugData
65 * @return array{body: string, displayed: int, bestScore: float}
66 */
67 private function renderSuggestionRows(array $permalinkSuggestions, string $rowType, array $options,
68 bool $showExtraAdminData, bool $adminDebugModal,
69 ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter $adminDebugPresenter,
70 array $extraDataById, array &$adminDebugData): array {
71 $body = '';
72 $displayed = 0;
73 $bestScore = 0.0;
74 $currentSlug = $this->currentSlug(abj_service('plugin_logic'), abj_service('functions'));
75 $commentPartRaw = abj_service('not_found_response')->getCommentPartAndQueryPartOfRequest();
76 $commentPartAndQueryPart = is_string($commentPartRaw) ? $commentPartRaw : '';
77
78 foreach ($permalinkSuggestions as $idAndType => $linkScore) {
79 $row = $this->renderSuggestionRow((string)$idAndType, $linkScore, $rowType, $options,
80 $currentSlug, $commentPartAndQueryPart, $showExtraAdminData, $adminDebugModal,
81 $adminDebugPresenter, $extraDataById);
82 if ($row === null) {
83 continue;
84 }
85 if ($displayed == 0) {
86 $body .= wp_kses_post($this->optionString($options, 'suggest_before'));
87 }
88 $body .= $row['html'];
89 if ($row['debugData'] !== null) {
90 $adminDebugData[] = $row['debugData'];
91 }
92 if ($displayed == 0 || $row['score'] > $bestScore) {
93 $bestScore = $row['score'];
94 }
95 $displayed++;
96 if ($displayed >= $this->suggestionLimit($options)) {
97 break;
98 }
99 }
100
101 return array('body' => $body, 'displayed' => $displayed, 'bestScore' => $bestScore);
102 }
103
104 /**
105 * @param string $idAndTypeStr
106 * @param mixed $linkScore
107 * @param string $rowType
108 * @param array<string, mixed> $options
109 * @param string $currentSlug
110 * @param string $commentPartAndQueryPart
111 * @param bool $showExtraAdminData
112 * @param bool $adminDebugModal
113 * @param ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter $adminDebugPresenter
114 * @param array<string, array<string, mixed>> $extraDataById
115 * @return array{html: string, debugData: array<string, mixed>|null, score: float}|null
116 */
117 private function renderSuggestionRow(string $idAndTypeStr, $linkScore, string $rowType, array $options,
118 string $currentSlug, string $commentPartAndQueryPart, bool $showExtraAdminData,
119 bool $adminDebugModal, ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter $adminDebugPresenter,
120 array $extraDataById): ?array {
121 if ($this->isExcludedSuggestion($idAndTypeStr)) {
122 return null;
123 }
124
125 $linkScoreFloat = is_scalar($linkScore) ? (float)$linkScore : 0.0;
126 $permalink = ABJ_404_Solution_PermalinkResolver::permalinkInfoToArray(
127 $idAndTypeStr, $linkScoreFloat, $rowType, $options);
128 $permLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
129 $permTitle = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
130 $permScore = isset($permalink['score']) && is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0;
131
132 if ($this->shouldSkipPermalink($permLink, $permScore, $currentSlug, $options)) {
133 return null;
134 }
135
136 $debugData = null;
137 $scoreHtml = $this->scoreHtml($showExtraAdminData, $adminDebugModal, $permScore, $adminDebugPresenter);
138 if ($showExtraAdminData && $adminDebugModal) {
139 $debugData = $adminDebugPresenter->buildItemData($idAndTypeStr, $permTitle, $permScore, $permLink, $extraDataById);
140 }
141
142 return array('html' => $this->fillTemplate('shortcodeSuggestionRow.html', array(
143 'entry_before' => wp_kses_post($this->optionString($options, 'suggest_entrybefore')),
144 'href' => esc_url($permLink . $commentPartAndQueryPart),
145 'title_attr' => esc_attr($permTitle),
146 'title_text' => esc_attr($permTitle),
147 'score_html' => $scoreHtml,
148 'entry_after' => wp_kses_post($this->optionString($options, 'suggest_entryafter')),
149 )) . "\n", 'debugData' => $debugData, 'score' => $permScore);
150 }
151
152 /**
153 * @param string $permLink
154 * @param float $permScore
155 * @param string $currentSlug
156 * @param array<string, mixed> $options
157 * @return bool
158 */
159 private function shouldSkipPermalink(string $permLink, float $permScore, string $currentSlug, array $options): bool {
160 if ($currentSlug !== '' && basename($permLink) == $currentSlug) {
161 return true;
162 }
163 $minScoreEnabled = isset($options['suggest_minscore_enabled']) && $options['suggest_minscore_enabled'] == '1';
164 $suggestMinscoreRaw = isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) ? $options['suggest_minscore'] : 25;
165 return $minScoreEnabled && $permScore < intval($suggestMinscoreRaw);
166 }
167
168 /**
169 * @param bool $showExtraAdminData
170 * @param bool $adminDebugModal
171 * @param float $permScore
172 * @return string
173 */
174 private function scoreHtml(bool $showExtraAdminData, bool $adminDebugModal, float $permScore,
175 ABJ_404_Solution_ShortcodeSuggestionAdminDebugPresenter $adminDebugPresenter): string {
176 if (!$showExtraAdminData) {
177 return '';
178 }
179 if ($adminDebugModal) {
180 return $adminDebugPresenter->renderScoreLink($permScore);
181 }
182 return ' (' . number_format($permScore, 4) . ')';
183 }
184
185 /**
186 * @param int $displayed
187 * @param array<string, mixed> $options
188 * @return string
189 */
190 private function renderResultFooter(int $displayed, array $options): string {
191 if ($displayed >= 1) {
192 return wp_kses_post($this->optionString($options, 'suggest_after')) . "\n";
193 }
194 return wp_kses_post(
195 $this->replaceSuggestionTemplateToken($this->optionString($options, 'suggest_noresults'),
196 'suggest_noresults_text', __('No suggestions. :/ ', '404-solution'))
197 );
198 }
199
200 /**
201 * The admin-only block that explains the scores above it: what the best
202 * match scored, what an automatic redirect has to clear, and the two ways
203 * to act on that. Empty string for everyone else, so a logged-out visitor
204 * sees nothing new.
205 *
206 * Sits below the whole list rather than beside each row on purpose: one
207 * block reads as a note, one sentence per suggestion reads as noise.
208 *
209 * @param bool $showExtraAdminData
210 * @param array{body: string, displayed: int, bestScore: float} $rowResult
211 * @param string $requestedURL
212 * @param array<string, mixed> $options
213 * @return string
214 */
215 private function renderAdminNote(bool $showExtraAdminData, array $rowResult, string $requestedURL,
216 array $options): string {
217 if (!$showExtraAdminData) {
218 return '';
219 }
220 $notePresenter = new ABJ_404_Solution_ShortcodeSuggestionsAdminNotePresenter();
221 return $notePresenter->render($rowResult['displayed'] >= 1, $rowResult['bestScore'],
222 $requestedURL, $options);
223 }
224
225 /**
226 * @param array<string, mixed> $options
227 * @return int
228 */
229 private function suggestionLimit(array $options): int {
230 return isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? (int)$options['suggest_max'] : 5;
231 }
232
233 /**
234 * Render a loading placeholder for async suggestions.
235 *
236 * @param string $requestedURL
237 * @param array<string, mixed> $options
238 * @return string
239 */
240 public function renderAsyncPlaceholder(string $requestedURL, array $options): string {
241 $suggestMaxVal = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
242 $suggestMax = intval($suggestMaxVal);
243
244 $skeletons = '';
245 for ($i = 0; $i < $suggestMax; $i++) {
246 $skeletons .= $this->fillTemplate('shortcodeSuggestionSkeleton.html', array()) . "\n";
247 }
248
249 return $this->fillTemplate('shortcodeSuggestionsPlaceholder.html', array(
250 'requested_url' => esc_attr($requestedURL),
251 'title' => wp_kses_post(
252 $this->replaceSuggestionTemplateToken($this->optionString($options, 'suggest_title'),
253 'suggest_title_text', __('Here are some other great pages', '404-solution'))
254 ),
255 'before' => wp_kses_post($this->optionString($options, 'suggest_before')),
256 'loading_text' => esc_html__('Loading page suggestions...', '404-solution'),
257 'skeletons' => $skeletons,
258 'after' => wp_kses_post($this->optionString($options, 'suggest_after')),
259 ));
260 }
261
262 /**
263 * @return bool
264 */
265 private function shouldShowAdminData(): bool {
266 if (!is_user_logged_in()) {
267 return false;
268 }
269 $policy = abj_service('admin_access_policy');
270 return is_object($policy)
271 && method_exists($policy, 'isPluginAdmin')
272 && (bool)$policy->isPluginAdmin();
273 }
274
275 /**
276 * @param string $idAndTypeStr
277 * @return bool
278 */
279 private function isExcludedSuggestion(string $idAndTypeStr): bool {
280 $idTypeParts = explode('|', $idAndTypeStr, 2);
281 $idInt = isset($idTypeParts[0]) && is_numeric($idTypeParts[0]) ? (int)$idTypeParts[0] : 0;
282 $typeInt = isset($idTypeParts[1]) && is_numeric($idTypeParts[1]) ? (int)$idTypeParts[1] : 0;
283 if ($idInt <= 0) {
284 return false;
285 }
286
287 $typePost = (int)ABJ404_TYPE_POST;
288 $typeCat = (int)ABJ404_TYPE_CAT;
289 $typeTag = (int)ABJ404_TYPE_TAG;
290 if ($typeInt === $typePost) {
291 return function_exists('get_post_meta') && get_post_meta($idInt, '_abj404_exclude', true) === '1';
292 }
293 if ($typeInt === $typeCat || $typeInt === $typeTag) {
294 return function_exists('get_term_meta') && get_term_meta($idInt, '_abj404_exclude', true) === '1';
295 }
296 return false;
297 }
298
299 /**
300 * @param object|null $abj404logic
301 * @param object|null $functions
302 * @return string
303 */
304 private function currentSlug($abj404logic, $functions): string {
305 if (!isset($_SERVER['REQUEST_URI']) || !is_string($_SERVER['REQUEST_URI']) ||
306 !is_object($abj404logic) || !is_object($functions) ||
307 !method_exists($abj404logic, 'urlNormalization') || !method_exists($functions, 'regexReplace')) {
308 return '';
309 }
310 $normalizer = $abj404logic->urlNormalization();
311 if (!is_object($normalizer) || !method_exists($normalizer, 'removeHomeDirectory')) {
312 return '';
313 }
314 $requestUri = abj_service('sanitizer')->normalizeUrlString($_SERVER['REQUEST_URI']);
315 $slugWithHome = $functions->regexReplace('\?.*', '', $requestUri);
316 return is_string($slugWithHome) ? $normalizer->removeHomeDirectory($slugWithHome) : '';
317 }
318
319 /**
320 * @param array<string, mixed> $options
321 * @param string $key
322 * @return string
323 */
324 private function optionString(array $options, string $key): string {
325 return isset($options[$key]) && is_string($options[$key]) ? $options[$key] : '';
326 }
327
328 /**
329 * Replace both placeholder and legacy bare token forms.
330 *
331 * @param string $template
332 * @param string $tokenNameWithoutBraces
333 * @param string $replacement
334 * @return string
335 */
336 private function replaceSuggestionTemplateToken(string $template, string $tokenNameWithoutBraces, string $replacement): string {
337 return str_replace(
338 array('{' . $tokenNameWithoutBraces . '}', $tokenNameWithoutBraces),
339 $replacement,
340 $template
341 );
342 }
343
344 /**
345 * @param string $name
346 * @param array<string, string> $vars
347 * @return string
348 */
349 private function fillTemplate(string $name, array $vars): string {
350 $template = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name, false);
351 $search = [];
352 $replace = [];
353 foreach ($vars as $key => $value) {
354 $search[] = '{' . $key . '}';
355 $replace[] = $value;
356 }
357 return str_replace($search, $replace, $template);
358 }
359 }
360