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

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

306 lines 10.7 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 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_PermalinkCache {
11
12 /** The name of the hook to use in WordPress. */
13 const UPDATE_PERMALINK_CACHE_HOOK = 'abj404_updatePermalinkCacheAction';
14
15 /** The maximum number of times in a row to run the hook. */
16 const MAX_EXECUTIONS = 15;
17
18 /** @var self|null */
19 private static $instance = null;
20
21 /** @var ABJ_404_Solution_ContentRepository */
22 private $contentRepository;
23
24 /** @var mixed */
25 private $statsRepository;
26
27 /** @var ABJ_404_Solution_Logging */
28 private $logger;
29
30 /** @var ABJ_404_Solution_PluginLogic */
31 private $logic;
32
33 /**
34 * Constructor with dependency injection.
35 *
36 * @param ABJ_404_Solution_ContentRepository|null $contentRepository Content repository
37 * @param ABJ_404_Solution_Logging|null $logging Logging service
38 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
39 * @param ABJ_404_Solution_StatsRepository|null $statsRepository Stats repository
40 */
41 public function __construct($contentRepository = null, $logging = null, $pluginLogic = null, $statsRepository = null) {
42 // Use injected dependencies or fall back to getInstance() for backward compatibility
43 $this->contentRepository = $contentRepository !== null ? $contentRepository : abj_service('content_repository');
44 $this->logger = $logging !== null ? $logging : abj_service('logging');
45 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
46 $this->statsRepository = $statsRepository !== null ? $statsRepository :
47 (is_object($contentRepository) && method_exists($contentRepository, 'getPostsNeedingContentKeywords') ? $contentRepository : abj_service('stats_repository'));
48 }
49
50 /** @return self */
51 public static function getInstance(): self {
52 if (self::$instance == null) {
53 self::$instance = new ABJ_404_Solution_PermalinkCache();
54 }
55
56 return self::$instance;
57 }
58
59 /** @return void */
60 static function init(): void {
61 $me = abj_service('permalink_cache');
62
63 add_action('updated_option', array($me, 'permalinkStructureChanged'), 10, 2);
64 }
65
66 /** If the permalink structure changes then truncate the cache table and update some values.
67 * @global type $abj404logging
68 * @param string $var1
69 * @param string $newStructure
70 */
71 /**
72 * @param string $var1
73 * @param string $newStructure
74 * @return void
75 */
76 function permalinkStructureChanged($var1, $newStructure): void {
77 if ($var1 != 'permalink_structure') {
78 return;
79 }
80
81 // we need to truncate the permlink cache since the structure changed
82
83 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
84 ": Truncating and updating permalink cache because the permalink structure changed to " .
85 $newStructure);
86
87 $this->contentRepository->truncatePermalinkCacheTable();
88
89 // let's take this opportunity to update some of the values in the cache table.
90 $this->updatePermalinkCache(1);
91 }
92
93 /**
94 * @param int $maxExecutionTime
95 * @param int $executionCount
96 * @return int
97 * @throws Exception
98 */
99 function updatePermalinkCache($maxExecutionTime, $executionCount = 1) {
100 // check to see if we need to upgrade the database.
101 // we must pass "true" here to avoid an infinite loop when updating the database.
102 $this->logic->getOptions(true);
103
104 // insert the new rows.
105 $results = $this->contentRepository->updatePermalinkCache();
106 $rowsInserted = (is_array($results) && isset($results['rows_affected']) && is_int($results['rows_affected'])) ? $results['rows_affected'] : 0;
107
108 // Invalidate coverage ratio if rows were inserted (new permalinks may lack N-grams)
109 if ($rowsInserted > 0) {
110 abj_service('ngram_filter')->invalidateCoverageCaches();
111 }
112
113 // now we have to update the the pages that have parents to include the parent
114 // part of the URL.
115 // wherever the post_parent != 0, prepend the parent ID URL onto the current URL
116 // and update the post_parent to be the parent ID of the parent.
117 $this->contentRepository->updatePermalinkCacheParentPages();
118
119 $this->populateContentKeywords();
120
121 $this->checkPermalinkCacheStaleness();
122
123 return $rowsInserted;
124 }
125
126 /** @return void */
127 private function checkPermalinkCacheStaleness(): void {
128 $cacheCount = $this->contentRepository->getPermalinkCacheCount();
129 if ($cacheCount > 0) {
130 return;
131 }
132 $postCount = function_exists('wp_count_posts') ? (int) (wp_count_posts('post')->publish ?? 0) : 0;
133 $pageCount = function_exists('wp_count_posts') ? (int) (wp_count_posts('page')->publish ?? 0) : 0;
134 if ($postCount + $pageCount === 0) {
135 return;
136 }
137 $message = function_exists('__')
138 ? __('Permalink cache appears empty after rebuild — suggestions may be degraded. Try rebuilding again or check available disk space.', '404-solution')
139 : 'Permalink cache appears empty after rebuild — suggestions may be degraded. Try rebuilding again or check available disk space.';
140 if (function_exists('set_transient')) {
141 // allow-cache-empty: notice payload is constructed locally and intentionally persisted as-is.
142 set_transient('abj404_plugin_db_notice', array(
143 'type' => 'stale_permalink_cache',
144 'message' => $message,
145 'timestamp' => time(),
146 ), 86400);
147 }
148 }
149
150 /**
151 * @param int $executionCount
152 * @return void
153 */
154 function scheduleToRunAgain(int $executionCount): void {
155 $maxExecutionTime = (int)ini_get('max_execution_time') - 5;
156 $maxExecutionTime = max($maxExecutionTime, 25);
157
158 wp_schedule_single_event(1, ABJ_404_Solution_PermalinkCache::UPDATE_PERMALINK_CACHE_HOOK,
159 array($maxExecutionTime, $executionCount));
160 }
161
162 /** Maximum unique keywords to store per post. */
163 const MAX_CONTENT_KEYWORDS = 30;
164
165 /** Minimum word length to keep during keyword extraction. */
166 const MIN_KEYWORD_LENGTH = 3;
167
168 /**
169 * Populate content_keywords for permalink cache rows that have NULL.
170 *
171 * Reads post_content, strips HTML/shortcodes, filters stop words,
172 * keeps top keywords by frequency. Runs in batches to stay within
173 * PHP time limits.
174 *
175 * @param int $batchSize Maximum posts to process per call.
176 * @return int Number of rows updated.
177 */
178 function populateContentKeywords(int $batchSize = 500): int {
179 $rows = $this->getPostsNeedingContentKeywords($batchSize);
180
181 if (empty($rows)) {
182 return 0;
183 }
184
185 $idToKeywords = array();
186 foreach ($rows as $row) {
187 if (!is_object($row)) {
188 continue;
189 }
190 $id = isset($row->id) ? (int)$row->id : 0;
191 if ($id <= 0) {
192 continue;
193 }
194 $content = isset($row->post_content) && is_string($row->post_content) ? $row->post_content : '';
195 $idToKeywords[$id] = self::extractContentKeywords($content);
196 }
197
198 if (empty($idToKeywords)) {
199 return 0;
200 }
201
202 $this->bulkUpdateContentKeywords($idToKeywords);
203
204 return count($idToKeywords);
205 }
206
207 /**
208 * @param int $batchSize
209 * @return array<int, mixed>
210 */
211 private function getPostsNeedingContentKeywords(int $batchSize): array {
212 if (!is_object($this->statsRepository) || !method_exists($this->statsRepository, 'getPostsNeedingContentKeywords')) {
213 return array();
214 }
215 $rows = call_user_func(array($this->statsRepository, 'getPostsNeedingContentKeywords'), $batchSize);
216 return is_array($rows) ? $rows : array();
217 }
218
219 /**
220 * @param array<int, string> $idToKeywords
221 * @return void
222 */
223 private function bulkUpdateContentKeywords(array $idToKeywords): void {
224 if (!is_object($this->statsRepository) || !method_exists($this->statsRepository, 'bulkUpdateContentKeywords')) {
225 return;
226 }
227 call_user_func(array($this->statsRepository, 'bulkUpdateContentKeywords'), $idToKeywords);
228 }
229
230 /**
231 * Extract significant keywords from HTML post content.
232 *
233 * 1. Strip shortcodes ([shortcode attr=val]...[/shortcode] and [self-closing])
234 * 2. Strip HTML tags
235 * 3. Decode HTML entities
236 * 4. Split on whitespace, lowercase, strip non-alpha
237 * 5. Filter: length < MIN_KEYWORD_LENGTH, stop words
238 * 6. Count frequency, take top MAX_CONTENT_KEYWORDS unique words
239 * 7. Return space-joined string
240 *
241 * @param string $htmlContent Raw post_content (may contain HTML and shortcodes).
242 * @return string Space-separated lowercase keywords.
243 */
244 public static function extractContentKeywords(string $htmlContent): string {
245 if (trim($htmlContent) === '') {
246 return '';
247 }
248
249 // Strip shortcodes: [tag attr="val"]content[/tag] and [self-closing /]
250 $text = preg_replace('/\[\/?\w+[^\]]*\]/', '', $htmlContent);
251 if (!is_string($text)) {
252 $text = $htmlContent;
253 }
254
255 // Strip HTML tags
256 $text = strip_tags($text);
257
258 // Decode HTML entities
259 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
260
261 // Lowercase
262 $text = strtolower($text);
263
264 // Replace non-alpha characters with spaces (keeps Unicode letters via \p{L})
265 $text = preg_replace('/[^\p{L}]+/u', ' ', $text);
266 if (!is_string($text)) {
267 return '';
268 }
269
270 // Split on whitespace
271 $words = preg_split('/\s+/', trim($text));
272 if (!is_array($words)) {
273 return '';
274 }
275
276 $stopLookup = array_flip(ABJ_404_Solution_ContentMatchingEngine::$stopWords);
277 $freq = [];
278
279 foreach ($words as $word) {
280 if (!is_string($word) || strlen($word) < self::MIN_KEYWORD_LENGTH) {
281 continue;
282 }
283 if (isset($stopLookup[$word])) {
284 continue;
285 }
286 if (!isset($freq[$word])) {
287 $freq[$word] = 0;
288 }
289 $freq[$word]++;
290 }
291
292 if (empty($freq)) {
293 return '';
294 }
295
296 // Sort by frequency descending
297 arsort($freq);
298
299 // Take top N unique words
300 $top = array_slice(array_keys($freq), 0, self::MAX_CONTENT_KEYWORDS);
301
302 return implode(' ', $top);
303 }
304
305 }
306