PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / class-metasync-otto-persistence-handler.php

class-metasync-otto-persistence-handler.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.25, at otto/class-metasync-otto-persistence-handler.php

460 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OTTO Persistence Handler
4 *
5 * Handles persisting OTTO data to native WordPress fields
6 * so changes survive plugin uninstallation.
7 *
8 * @package MetaSync
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Otto_Persistence_Handler {
17
18 /**
19 * Singleton instance
20 */
21 private static $instance = null;
22
23 /**
24 * Get singleton instance
25 */
26 public static function get_instance() {
27 if (self::$instance === null) {
28 self::$instance = new self();
29 }
30 return self::$instance;
31 }
32
33 /**
34 * Constructor
35 */
36 private function __construct() {
37 // Initialize
38 }
39
40 /**
41 * Log persistence operation to sync history
42 *
43 * @param string $content_type Type of persistence (image_alt_text, link_corrections, heading_changes)
44 * @param int $post_id Optional post ID
45 * @param array $details Details about what was updated
46 * @return bool Whether logging succeeded
47 */
48 private static function log_to_sync_history($content_type, $post_id = null, $details = []) {
49 // Ensure sync history class is loaded
50 if (!class_exists('Metasync_Sync_History_Database')) {
51 return false;
52 }
53
54 try {
55 $sync_history = new Metasync_Sync_History_Database();
56
57 // Build title based on content type and details
58 $title = self::build_log_title($content_type, $details);
59
60 // Get post URL if post_id provided
61 $url = null;
62 if ($post_id) {
63 $url = get_permalink($post_id);
64 if (!$url) {
65 $url = get_edit_post_link($post_id, 'raw');
66 }
67 }
68
69 // Add to sync history
70 $sync_history->add([
71 'title' => $title,
72 'source' => 'OTTO Persistence',
73 'status' => 'published',
74 'content_type' => $content_type,
75 'url' => $url,
76 'meta_data' => json_encode($details),
77 'created_at' => current_time('mysql'),
78 ]);
79
80 return true;
81 } catch (Exception $e) {
82 // Silently fail - don't break persistence if logging fails
83 return false;
84 }
85 }
86
87 /**
88 * Build a descriptive title for the sync log entry
89 *
90 * @param string $content_type
91 * @param array $details
92 * @return string
93 */
94 private static function build_log_title($content_type, $details) {
95 switch ($content_type) {
96 case 'image_alt_text':
97 $count = isset($details['updated_count']) ? $details['updated_count'] : 0;
98 return sprintf('Updated %d image alt text%s', $count, $count === 1 ? '' : 's');
99
100 case 'link_corrections':
101 $count = isset($details['updated_count']) ? $details['updated_count'] : 0;
102 return sprintf('Corrected %d link%s in post content', $count, $count === 1 ? '' : 's');
103
104 case 'heading_changes':
105 $count = isset($details['updated_count']) ? $details['updated_count'] : 0;
106 return sprintf('Updated %d heading%s in post content', $count, $count === 1 ? '' : 's');
107
108 default:
109 return 'OTTO persistence operation';
110 }
111 }
112
113 /**
114 * Check if a specific data type should be persisted
115 *
116 * @param string $key
117 * @return bool
118 */
119 public static function should_persist($key) {
120 return Metasync_Otto_Persistence_Settings::should_persist($key);
121 }
122
123 /**
124 * Persist image alt text to WordPress Media Library
125 * Updates the _wp_attachment_image_alt meta field on attachment posts
126 *
127 * @param array $image_data Array of image_url => alt_text
128 * @return array Results with counts and details
129 */
130 public static function persist_image_alt_text($image_data) {
131 if (empty($image_data) || !is_array($image_data)) {
132 return [
133 'success' => false,
134 'updated_count' => 0,
135 'skipped_count' => 0,
136 'message' => 'No image data provided',
137 ];
138 }
139
140 # Check if persistence is enabled
141 if (!self::should_persist('image_alt_text')) {
142 return [
143 'success' => false,
144 'updated_count' => 0,
145 'skipped_count' => count($image_data),
146 'message' => 'Image alt text persistence is disabled',
147 ];
148 }
149
150 $updated_count = 0;
151 $skipped_count = 0;
152 $updated_images = [];
153 $skipped_images = [];
154
155 foreach ($image_data as $image_url => $alt_text) {
156 # Sanitize inputs
157 $image_url = esc_url_raw($image_url);
158 $alt_text = sanitize_text_field($alt_text);
159
160 if (empty($image_url) || empty($alt_text)) {
161 $skipped_count++;
162 $skipped_images[] = [
163 'url' => $image_url,
164 'reason' => 'Empty URL or alt text',
165 ];
166 continue;
167 }
168
169 # Find attachment ID by URL
170 $attachment_id = self::get_attachment_id_by_url($image_url);
171
172 if (!$attachment_id) {
173 $skipped_count++;
174 $skipped_images[] = [
175 'url' => $image_url,
176 'reason' => 'Attachment not found in Media Library',
177 ];
178 continue;
179 }
180
181 # Get current alt text
182 $current_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
183
184 # Only update if different
185 if ($current_alt !== $alt_text) {
186 update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
187 $updated_count++;
188 $updated_images[] = [
189 'attachment_id' => $attachment_id,
190 'url' => $image_url,
191 'old_alt' => $current_alt,
192 'new_alt' => $alt_text,
193 ];
194 } else {
195 $skipped_count++;
196 $skipped_images[] = [
197 'url' => $image_url,
198 'reason' => 'Alt text already matches',
199 ];
200 }
201 }
202
203 $result = [
204 'success' => $updated_count > 0,
205 'updated_count' => $updated_count,
206 'skipped_count' => $skipped_count,
207 'updated_images' => $updated_images,
208 'message' => sprintf('Updated %d image alt text(s) in Media Library', $updated_count),
209 ];
210
211 # Log to sync history if any images were updated
212 if ($updated_count > 0) {
213 self::log_to_sync_history('image_alt_text', null, [
214 'updated_count' => $updated_count,
215 'skipped_count' => $skipped_count,
216 'updated_images' => $updated_images,
217 ]);
218 }
219
220 return $result;
221 }
222
223 /**
224 * Get attachment ID by URL
225 * Uses multiple methods to find the attachment
226 *
227 * @param string $url Image URL
228 * @return int|false Attachment ID or false if not found
229 */
230 private static function get_attachment_id_by_url($url) {
231 # Method 1: Use WordPress core function
232 $attachment_id = attachment_url_to_postid($url);
233 if ($attachment_id) {
234 return $attachment_id;
235 }
236
237 # Method 2: Try without size suffix (e.g., -258x172)
238 $url_without_size = preg_replace('/-\d+x\d+(?=\.[a-z]{3,4}$)/i', '', $url);
239 if ($url_without_size !== $url) {
240 $attachment_id = attachment_url_to_postid($url_without_size);
241 if ($attachment_id) {
242 return $attachment_id;
243 }
244 }
245
246 # Method 3: Search by filename in database
247 global $wpdb;
248 $filename = basename(parse_url($url, PHP_URL_PATH));
249 # Remove size suffix from filename
250 $filename_base = preg_replace('/-\d+x\d+(?=\.[a-z]{3,4}$)/i', '', $filename);
251
252 $attachment_id = $wpdb->get_var($wpdb->prepare(
253 "SELECT post_id FROM {$wpdb->postmeta}
254 WHERE meta_key = '_wp_attached_file'
255 AND meta_value LIKE %s
256 LIMIT 1",
257 '%' . $wpdb->esc_like($filename_base) . '%'
258 ));
259
260 return $attachment_id ? (int) $attachment_id : false;
261 }
262
263 /**
264 * Persist link corrections to post content
265 * Updates href attributes in the actual post_content
266 *
267 * @param int $post_id WordPress post ID
268 * @param array $link_data Array of old_url => new_url
269 * @return array Results with counts and details
270 */
271 public static function persist_link_corrections($post_id, $link_data) {
272 if (empty($link_data) || !is_array($link_data)) {
273 return [
274 'success' => false,
275 'updated_count' => 0,
276 'message' => 'No link data provided',
277 ];
278 }
279
280 # Check if persistence is enabled
281 if (!self::should_persist('link_corrections')) {
282 return [
283 'success' => false,
284 'updated_count' => 0,
285 'message' => 'Link corrections persistence is disabled',
286 ];
287 }
288
289 # Fail-closed guard: Only modify post_content if post explicitly uses Gutenberg blocks
290 if (!function_exists('has_blocks') || !has_blocks($post_id)) {
291 return [
292 'success' => false,
293 'updated_count' => 0,
294 'message' => 'Post does not use Gutenberg blocks - content modifications skipped',
295 ];
296 }
297
298 $post = get_post($post_id);
299 if (!$post) {
300 return [
301 'success' => false,
302 'updated_count' => 0,
303 'message' => 'Post not found',
304 ];
305 }
306
307 if (!current_user_can('edit_post', $post_id)) {
308 return [
309 'success' => false,
310 'updated_count' => 0,
311 'message' => 'Insufficient permissions to edit this post',
312 ];
313 }
314
315 $content = $post->post_content;
316 $original_content = $content;
317 $updated_count = 0;
318 $updated_links = [];
319
320 foreach ($link_data as $old_url => $new_url) {
321 # Sanitize URLs
322 $old_url = esc_url_raw($old_url);
323 $new_url = esc_url_raw($new_url);
324
325 if (empty($old_url) || empty($new_url) || $old_url === $new_url) {
326 continue;
327 }
328
329 # Count occurrences before replacement
330 $count_before = substr_count($content, $old_url);
331
332 if ($count_before > 0) {
333 # Replace in content (handles both href="..." and href='...')
334 $content = str_replace($old_url, $new_url, $content);
335 $updated_count += $count_before;
336 $updated_links[] = [
337 'old_url' => $old_url,
338 'new_url' => $new_url,
339 'occurrences' => $count_before,
340 ];
341 }
342 }
343
344 # Only update if content changed
345 if ($content !== $original_content) {
346 $result = wp_update_post([
347 'ID' => $post_id,
348 'post_content' => wp_slash($content),
349 ], true);
350
351 if (is_wp_error($result)) {
352 return [
353 'success' => false,
354 'updated_count' => 0,
355 'message' => 'Failed to update post: ' . $result->get_error_message(),
356 ];
357 }
358
359 # Clear post cache
360 clean_post_cache($post_id);
361
362 $result = [
363 'success' => true,
364 'updated_count' => $updated_count,
365 'updated_links' => $updated_links,
366 'message' => sprintf('Updated %d link(s) in post content', $updated_count),
367 ];
368
369 # Log to sync history
370 self::log_to_sync_history('link_corrections', $post_id, [
371 'updated_count' => $updated_count,
372 'updated_links' => $updated_links,
373 ]);
374
375 return $result;
376 }
377
378 return [
379 'success' => false,
380 'updated_count' => 0,
381 'message' => 'No matching links found in post content',
382 ];
383 }
384
385 /**
386 * Persist heading changes (Deprecated/Disabled)
387 * Headings persistence into post_content is disabled to prevent data corruption.
388 * OTTO runtime SSR handles heading substitutions safely in-memory.
389 *
390 * @param int $post_id WordPress post ID
391 * @param array $heading_data Array of heading changes
392 * @return array Results with counts and details
393 */
394 public static function persist_heading_changes($post_id, $heading_data) {
395 return [
396 'success' => false,
397 'updated_count' => 0,
398 'message' => 'Heading changes persistence into post_content is disabled',
399 ];
400 }
401
402 /**
403 * Process all persistence for a post based on OTTO SEO data
404 * This is the main entry point called from metasync_update_comprehensive_seo_fields
405 *
406 * @param int $post_id WordPress post ID
407 * @param array $seo_data Complete SEO data from OTTO
408 * @return array Results for all persistence operations
409 */
410 public static function process_persistence($post_id, $seo_data) {
411 $results = [
412 'image_alt_text' => null,
413 'link_corrections' => null,
414 'heading_changes' => null,
415 ];
416
417 # Process image alt text persistence
418 if (!empty($seo_data['body_substitutions']['images'])) {
419 $results['image_alt_text'] = self::persist_image_alt_text($seo_data['body_substitutions']['images']);
420 }
421
422 # Process link corrections persistence
423 if (!empty($seo_data['body_substitutions']['links'])) {
424 $results['link_corrections'] = self::persist_link_corrections($post_id, $seo_data['body_substitutions']['links']);
425 }
426
427 # Process heading changes persistence
428 if (!empty($seo_data['body_substitutions']['headings'])) {
429 $results['heading_changes'] = self::persist_heading_changes($post_id, $seo_data['body_substitutions']['headings']);
430 }
431
432 return $results;
433 }
434
435 /**
436 * Extract link corrections data from OTTO SEO data
437 *
438 * @param array $seo_data The SEO data from OTTO API
439 * @return array Array of old_url => new_url
440 */
441 public static function extract_link_corrections($seo_data) {
442 if (empty($seo_data['body_substitutions']['links']) || !is_array($seo_data['body_substitutions']['links'])) {
443 return [];
444 }
445
446 $link_data = [];
447 foreach ($seo_data['body_substitutions']['links'] as $old_url => $new_url) {
448 if (!empty($old_url) && !empty($new_url)) {
449 $link_data[esc_url_raw($old_url)] = esc_url_raw($new_url);
450 }
451 }
452
453 return $link_data;
454 }
455 }
456
457
458
459
460