PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / SpellChecker.php

SpellChecker.php in 404 Solution 4.2.0, at includes/SpellChecker.php

429 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Finds similar pages.
9 * Finds search suggestions. */
10
11 class ABJ_404_Solution_SpellChecker {
12
13 /** @var array<int, string> */
14 private array $separatingCharacters = array("-","_",".","~",'%20');
15
16 /** Same as above except without the period (.) because of the extension in the file name.
17 * @var array<int, string> */
18 private array $separatingCharactersForImages = array("-","_","~",'%20');
19
20 const MAX_DIST = 2083;
21
22 const MAX_LIKELY_DISTANCE = 300;
23
24 const NGRAM_PREFILTER_THRESHOLD = 0.3;
25
26 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
27
28 const NGRAM_MIN_CACHE_ENTRIES = 50;
29
30 const NGRAM_SECONDARY_THRESHOLD = 0.4;
31
32 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
33
34 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
35
36 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
37
38 private static ?self $instance = null;
39
40 /** @var ABJ_404_Solution_Functions */
41 private $f;
42
43 /** @var ABJ_404_Solution_PluginLogic */
44 private $logic;
45
46 /** @var ABJ_404_Solution_ContentRepository */
47 private $contentRepository;
48
49 /** @var ABJ_404_Solution_Logging */
50 private $logger;
51
52 /** @var ABJ_404_Solution_SpellURLMatcher */
53 private $urlMatcher;
54
55 /** @var ABJ_404_Solution_SpellLevenshteinEngine */
56 private $levenshteinEngine;
57
58 /** @var ABJ_404_Solution_SpellCandidateFilter */
59 private $candidateFilter;
60
61 /** @var ABJ_404_Solution_SpellPostListeners */
62 private $postListeners;
63
64 /**
65 * @param ABJ_404_Solution_Functions|null $functions
66 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic
67 * @param ABJ_404_Solution_ContentRepository|null $contentRepository
68 * @param ABJ_404_Solution_Logging|null $logging
69 * @param ABJ_404_Solution_PermalinkCache|null $permalinkCache
70 * @param ABJ_404_Solution_NGramFilter|null $ngramFilter
71 * @param ABJ_404_Solution_ViewReadService|null $viewReadService
72 */
73 public function __construct($functions = null, $pluginLogic = null, $contentRepository = null, $logging = null, $permalinkCache = null, $ngramFilter = null, $viewReadService = null) {
74 $this->f = $functions !== null ? $functions : abj_service('functions');
75 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
76 $this->contentRepository = $contentRepository !== null ? $contentRepository : abj_service('content_repository');
77 $this->logger = $logging !== null ? $logging : abj_service('logging');
78 $permalinkCacheResolved = $permalinkCache !== null ? $permalinkCache : abj_service('permalink_cache');
79 $ngramFilterResolved = $ngramFilter !== null ? $ngramFilter : abj_service('ngram_filter');
80 $viewReadServiceResolved = $viewReadService !== null ? $viewReadService :
81 (is_object($contentRepository) && method_exists($contentRepository, 'getRedirectsWithRegEx') ? $contentRepository : abj_service('view_read_service'));
82
83 $options = $this->logic->getOptions();
84 $custom404PageIDRaw =
85 (is_array($options) && isset($options['dest404page']) ?
86 $options['dest404page'] : null);
87 $custom404PageID = is_string($custom404PageIDRaw) ? $custom404PageIDRaw : (is_int($custom404PageIDRaw) ? (string)$custom404PageIDRaw : null);
88 $custom404PageIDResolved = null;
89 if ($this->logic->thereIsAUserSpecified404Page($custom404PageID)) {
90 $custom404PageIDResolved = $custom404PageID;
91 }
92
93 $this->urlMatcher = new ABJ_404_Solution_SpellURLMatcher(
94 $this->f, $this->logic, $this->logger, $this->contentRepository,
95 $viewReadServiceResolved, $custom404PageIDResolved
96 );
97
98 $this->postListeners = new ABJ_404_Solution_SpellPostListeners(
99 $this->f, $this->logic, $this->logger, $this->contentRepository,
100 $permalinkCacheResolved, $ngramFilterResolved
101 );
102
103 $this->levenshteinEngine = new ABJ_404_Solution_SpellLevenshteinEngine(
104 $this->f, $this->logic, $this->logger, $this->contentRepository,
105 $ngramFilterResolved, $this->urlMatcher, $this->separatingCharacters
106 );
107
108 $this->candidateFilter = new ABJ_404_Solution_SpellCandidateFilter(
109 $this->f, $this->logic, $this->logger, $this->contentRepository,
110 $this->urlMatcher, $this->levenshteinEngine, $this->postListeners,
111 $custom404PageIDResolved, $this->separatingCharacters, $this->separatingCharactersForImages
112 );
113 }
114
115 public static function resetForTests(): void {
116 self::$instance = null;
117 }
118
119 public static function getInstance(): self {
120 if (self::$instance !== null) {
121 return self::$instance;
122 }
123
124 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
125 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('spell_checker');
126 if ($resolved instanceof self) {
127 self::$instance = $resolved;
128 return self::$instance;
129 }
130 }
131
132 self::$instance = new ABJ_404_Solution_SpellChecker();
133
134 return self::$instance;
135 }
136
137 public function enablePerformanceCounters(bool $enable = true): void {
138 $this->levenshteinEngine->enablePerformanceCounters($enable);
139 }
140
141 public function setSkipNgramGate4(bool $skip = true): void {
142 $this->levenshteinEngine->setSkipNgramGate4($skip);
143 }
144
145 public function resetPerformanceCounters(): void {
146 $this->levenshteinEngine->resetPerformanceCounters();
147 }
148
149 /**
150 * @return array{levenshtein_calls: int, pages_considered: int, efficiency_percent: float}
151 */
152 public function getPerformanceCounters(): array {
153 return $this->levenshteinEngine->getPerformanceCounters();
154 }
155
156 /** @return array<string, mixed>|null */
157 function getPermalinkUsingRegEx(string $requestedURL, $options = null) {
158 return $this->urlMatcher->getPermalinkUsingRegEx($requestedURL, $options);
159 }
160
161 /** @return array<string, mixed>|null */
162 function getPermalinkUsingSlug(string $requestedURL) {
163 return $this->urlMatcher->getPermalinkUsingSlug($requestedURL);
164 }
165
166 function requestIsForAnImage(string $requestedURL): bool {
167 return $this->urlMatcher->requestIsForAnImage($requestedURL);
168 }
169
170 /** @return array<int, array<string, mixed>> */
171 function getOnlyIDandTermID(array $rowsAsObject): array {
172 return $this->urlMatcher->getOnlyIDandTermID($rowsAsObject);
173 }
174
175 /** @return array<int|string, mixed> */
176 function getFromPermalinkCache(string $requestedURL): array {
177 return $this->urlMatcher->getFromPermalinkCache($requestedURL);
178 }
179
180 /**
181 * @return string|null
182 * @throws Exception
183 */
184 function getPermalink($id, $rowType) {
185 return $this->urlMatcher->getPermalink($id, $rowType);
186 }
187
188 function getLastURLPart($url) {
189 return $this->urlMatcher->getLastURLPart($url);
190 }
191
192 /** @return array<int, mixed> */
193 function findMatchingPosts(string $requestedURLRaw, string $includeCats = '1', string $includeTags = '1') {
194 return $this->candidateFilter->findMatchingPosts($requestedURLRaw, $includeCats, $includeTags);
195 }
196
197 /** @return array<string, string> */
198 function removeExcludedPages(array $options, array $permalinks): array {
199 return $this->candidateFilter->removeExcludedPages($options, $permalinks);
200 }
201
202 /** @return array<string, string> */
203 function removeExcludedPagesWithRegex(array $options, array $permalinks, int $maxCacheCount): array {
204 return $this->candidateFilter->removeExcludedPagesWithRegex($options, $permalinks, $maxCacheCount);
205 }
206
207 /** @return array<string, string> */
208 function matchOnCats(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
209 return $this->candidateFilter->matchOnCats($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
210 }
211
212 /** @return array<string, string> */
213 function matchOnTags(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
214 return $this->candidateFilter->matchOnTags($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
215 }
216
217 /** @return array<string, string> */
218 function matchOnPosts(array $permalinks, string $requestedURLRaw, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
219 return $this->candidateFilter->matchOnPosts($permalinks, $requestedURLRaw, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
220 }
221
222 /** @return array<int|string, mixed> */
223 function getLikelyMatchIDs(string $requestedURLCleaned, string $fullURLspaces, string $rowType, ?array $rows = null) {
224 return $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspaces, $rowType, $rows);
225 }
226
227 function getMaxAcceptableDistance(array $maxDistances, int $onlyNeedThisManyPages): int {
228 return $this->levenshteinEngine->getMaxAcceptableDistance($maxDistances, $onlyNeedThisManyPages);
229 }
230
231 function customLevenshtein($str1, $str2) {
232 return $this->levenshteinEngine->customLevenshtein($str1, $str2);
233 }
234
235 function save_postListener($post_id, $post = null, $update = null): void {
236 // @hook-lifecycle: opt-out - delegated SpellPostListeners::save_postListener owns request-level dedup.
237 $this->postListeners->save_postListener($post_id, $post, $update);
238 }
239
240 function delete_postListener($post_id, $post = null): void {
241 $this->postListeners->delete_postListener($post_id, $post);
242 }
243
244 function savePostHandler($post_id, $post, $update, $saveOrDelete): void {
245 $this->postListeners->savePostHandler($post_id, $post, $update, $saveOrDelete);
246 }
247
248 function permalinkStructureChanged($var1, $newStructure): void {
249 $this->postListeners->permalinkStructureChanged($var1, $newStructure);
250 }
251
252 function initializePublishedPostsProvider(): void {
253 $this->postListeners->initializePublishedPostsProvider();
254 }
255
256 /**
257 * @return array<int, mixed>
258 */
259 public function findSuggestionsForURLUsingSmartCache($requestedURL, $includeCats = '1', $includeTags = true) {
260 $includeTagsStr = $includeTags ? '1' : '0';
261 return $this->findMatchingPosts($requestedURL, $includeCats, $includeTagsStr);
262 }
263
264 static function init(): void {
265 $me = abj_service('spell_checker');
266
267 add_action('updated_option', array($me,'permalinkStructureChanged'), 10, 2);
268 add_action('save_post', array($me,'save_postListener'), 10, 3);
269 add_action('delete_post', array($me,'delete_postListener'), 10, 2);
270 }
271
272 /**
273 * @return array<string, mixed>|null
274 */
275 function getPermalinkUsingSpelling(string $requestedURL, ?string $fullRequestedURL = null, $optionsOverride = null) {
276 $abj404spellChecker = abj_service('spell_checker');
277
278 $options = is_array($optionsOverride) ? $optionsOverride : $this->logic->getOptions();
279
280 if (@$options['auto_redirects'] == '1') {
281 $autoCats = isset($options['auto_cats']) && is_string($options['auto_cats']) ? $options['auto_cats'] : '1';
282 $autoTags = isset($options['auto_tags']) && is_string($options['auto_tags']) ? $options['auto_tags'] : '1';
283 $permalinksPacket = $abj404spellChecker->findMatchingPosts($requestedURL,
284 $autoCats, $autoTags);
285
286 $permalinks = $permalinksPacket[0];
287 $rowType = $permalinksPacket[1];
288
289 $minScore = $options['auto_score'];
290
291 if (!is_array($permalinks) || empty($permalinks)) {
292 return null;
293 }
294 $linkScore = reset($permalinks);
295 $idAndType = key($permalinks);
296 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
297 $linkScoreInt = is_scalar($linkScore) ? (int)$linkScore : 0;
298 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndTypeStr, $linkScoreInt,
299 is_string($rowType) ? $rowType : null, $options);
300
301 if ($permalink['score'] >= $minScore) {
302 $redirectType = $permalink['type'];
303 if (('' . $redirectType != ABJ404_TYPE_404_DISPLAYED) && ('' . $redirectType != ABJ404_TYPE_HOME)) {
304 return $permalink;
305
306 } else {
307 $permalinkJson = json_encode($permalink);
308 $this->logger->errorMessage("Unhandled permalink type: " .
309 wp_kses_post(is_string($permalinkJson) ? $permalinkJson : '{}'));
310 return null;
311 }
312 }
313
314 if ($fullRequestedURL !== null) {
315 $this->cacheComputedSuggestionsForShortcode($fullRequestedURL, $permalinksPacket);
316 }
317 }
318
319 return null;
320 }
321
322 private function cacheComputedSuggestionsForShortcode(string $fullRequestedURL, array $permalinksPacket): void {
323 $normalizedURL = $this->f->normalizeURLForCacheKey($fullRequestedURL);
324
325 $urlKey = md5($normalizedURL);
326 $transientKey = 'abj404_suggest_' . $urlKey;
327
328 $existing = get_transient($transientKey);
329 if ($existing !== false) {
330 return;
331 }
332
333 // allow-cache-empty: factory-built typed array; SuggestionTransient::completeArray
334 // always returns a non-empty associative array with at minimum a 'status' key.
335 set_transient(
336 $transientKey,
337 ABJ_404_Solution_SuggestionTransient::completeArray(
338 $normalizedURL,
339 $permalinksPacket,
340 time(),
341 ''
342 ),
343 300
344 ); // 5 minute TTL
345
346 $this->logger->debugMessage("Cached spell-check suggestions for shortcode: " .
347 esc_html($normalizedURL));
348 }
349
350 public function triggerAsyncSuggestionComputation($requestedURL) {
351 $f = abj_service('functions');
352
353 $normalizedURL = $f->normalizeURLForCacheKey($requestedURL);
354
355 $urlKey = md5($normalizedURL);
356 $transientKey = 'abj404_suggest_' . $urlKey;
357
358 $existing = ABJ_404_Solution_SuggestionTransient::fromRaw(get_transient($transientKey));
359 if ($existing !== null) {
360 $this->logger->debugMessage("Async suggestions: skipping, transient already exists for " .
361 esc_html($normalizedURL) . " (status: " . esc_html($existing->getStatus()) . ")");
362 return false;
363 }
364
365 $token = wp_generate_password(32, false);
366
367 // allow-cache-empty: factory-built typed array; keep the TTL at 120
368 // seconds so slow hosts can start before the polling UI gives up.
369 set_transient(
370 $transientKey,
371 ABJ_404_Solution_SuggestionTransient::pendingArray(
372 $normalizedURL,
373 $token,
374 0,
375 time()
376 ),
377 120
378 ); // 2 minute TTL
379
380 $this->logger->debugMessage("Async suggestions: triggering background computation for " .
381 esc_html($normalizedURL));
382
383 $response = wp_remote_post(admin_url('admin-ajax.php'), array(
384 'blocking' => false,
385 'timeout' => 5,
386 'sslverify' => apply_filters('https_local_ssl_verify', false),
387 'body' => array(
388 'action' => 'abj404_compute_suggestions',
389 'url' => $normalizedURL,
390 'token' => $token
391 )
392 ));
393
394 if (is_wp_error($response)) {
395 $this->logger->debugMessage("Async suggestions: dispatch failed for " .
396 esc_html($normalizedURL) . " - " . $response->get_error_message());
397 delete_transient($transientKey);
398 return false;
399 }
400
401 return true;
402 }
403
404 public function does404PageHaveSuggestionsShortcode() {
405 $options = $this->logic->getOptions();
406 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
407 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : null;
408
409 if (!$this->logic->thereIsAUserSpecified404Page($dest404page)) {
410 return false;
411 }
412
413 $parts = explode('|', $dest404page ?? '');
414 $page404Id = isset($parts[0]) ? intval($parts[0]) : 0;
415
416 if ($page404Id <= 0) {
417 return false;
418 }
419
420 $page = get_post($page404Id);
421 if (!$page) {
422 return false;
423 }
424
425 return has_shortcode($page->post_content, ABJ404_SHORTCODE_NAME);
426 }
427
428 }
429