PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.1.13, at includes/PermalinkCache.php

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