PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.11
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.11
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.11, at otto/class-metasync-otto-persistence-handler.php

566 lines 18.5 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 $post = get_post($post_id);
290 if (!$post) {
291 return [
292 'success' => false,
293 'updated_count' => 0,
294 'message' => 'Post not found',
295 ];
296 }
297
298 if (!current_user_can('edit_post', $post_id)) {
299 return [
300 'success' => false,
301 'updated_count' => 0,
302 'message' => 'Insufficient permissions to edit this post',
303 ];
304 }
305
306 $content = $post->post_content;
307 $original_content = $content;
308 $updated_count = 0;
309 $updated_links = [];
310
311 foreach ($link_data as $old_url => $new_url) {
312 # Sanitize URLs
313 $old_url = esc_url_raw($old_url);
314 $new_url = esc_url_raw($new_url);
315
316 if (empty($old_url) || empty($new_url) || $old_url === $new_url) {
317 continue;
318 }
319
320 # Count occurrences before replacement
321 $count_before = substr_count($content, $old_url);
322
323 if ($count_before > 0) {
324 # Replace in content (handles both href="..." and href='...')
325 $content = str_replace($old_url, $new_url, $content);
326 $updated_count += $count_before;
327 $updated_links[] = [
328 'old_url' => $old_url,
329 'new_url' => $new_url,
330 'occurrences' => $count_before,
331 ];
332 }
333 }
334
335 # Only update if content changed
336 if ($content !== $original_content) {
337 $result = wp_update_post([
338 'ID' => $post_id,
339 'post_content' => $content,
340 ], true);
341
342 if (is_wp_error($result)) {
343 return [
344 'success' => false,
345 'updated_count' => 0,
346 'message' => 'Failed to update post: ' . $result->get_error_message(),
347 ];
348 }
349
350 # Clear post cache
351 clean_post_cache($post_id);
352
353 $result = [
354 'success' => true,
355 'updated_count' => $updated_count,
356 'updated_links' => $updated_links,
357 'message' => sprintf('Updated %d link(s) in post content', $updated_count),
358 ];
359
360 # Log to sync history
361 self::log_to_sync_history('link_corrections', $post_id, [
362 'updated_count' => $updated_count,
363 'updated_links' => $updated_links,
364 ]);
365
366 return $result;
367 }
368
369 return [
370 'success' => false,
371 'updated_count' => 0,
372 'message' => 'No matching links found in post content',
373 ];
374 }
375
376 /**
377 * Persist heading changes to post content
378 * Updates heading text in the actual post_content
379 *
380 * @param int $post_id WordPress post ID
381 * @param array $heading_data Array of heading changes
382 * @return array Results with counts and details
383 */
384 public static function persist_heading_changes($post_id, $heading_data) {
385 if (empty($heading_data) || !is_array($heading_data)) {
386 return [
387 'success' => false,
388 'updated_count' => 0,
389 'message' => 'No heading data provided',
390 ];
391 }
392
393 # Check if persistence is enabled
394 if (!self::should_persist('heading_changes')) {
395 return [
396 'success' => false,
397 'updated_count' => 0,
398 'message' => 'Heading changes persistence is disabled',
399 ];
400 }
401
402 $post = get_post($post_id);
403 if (!$post) {
404 return [
405 'success' => false,
406 'updated_count' => 0,
407 'message' => 'Post not found',
408 ];
409 }
410
411 if (!current_user_can('edit_post', $post_id)) {
412 return [
413 'success' => false,
414 'updated_count' => 0,
415 'message' => 'Insufficient permissions to edit this post',
416 ];
417 }
418
419 $content = $post->post_content;
420 $original_content = $content;
421 $updated_count = 0;
422 $updated_headings = [];
423
424 foreach ($heading_data as $heading) {
425 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
426 continue;
427 }
428
429 $tag = strtolower(sanitize_text_field($heading['type'])); # h1, h2, h3, etc.
430 $current_value = $heading['current_value'];
431 $recommended_value = sanitize_text_field($heading['recommended_value']);
432
433 if ($current_value === $recommended_value) {
434 continue;
435 }
436
437 # Build regex to match the heading tag — use callback to strip inline HTML (e.g. <span>) for comparison
438 $pattern = '/(<' . preg_quote($tag, '/') . '[^>]*>)(.*?)(<\/' . preg_quote($tag, '/') . '>)/is';
439
440 $normalized_current = trim(preg_replace('/\s+/', ' ', html_entity_decode($current_value, ENT_QUOTES, 'UTF-8')));
441 $count = 0;
442 $new_content = preg_replace_callback(
443 $pattern,
444 function ($m) use ($normalized_current, $recommended_value, &$count) {
445 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
446 if (strcasecmp($inner_text, $normalized_current) === 0) {
447 $count++;
448 return $m[1] . $recommended_value . $m[3];
449 }
450 return $m[0];
451 },
452 $content
453 );
454
455 if ($count > 0 && $new_content !== null) {
456 $content = $new_content;
457 $updated_count += $count;
458 $updated_headings[] = [
459 'type' => $tag,
460 'old_value' => $current_value,
461 'new_value' => $recommended_value,
462 'occurrences' => $count,
463 ];
464 }
465 }
466
467 # Only update if content changed
468 if ($content !== $original_content) {
469 $result = wp_update_post([
470 'ID' => $post_id,
471 'post_content' => $content,
472 ], true);
473
474 if (is_wp_error($result)) {
475 return [
476 'success' => false,
477 'updated_count' => 0,
478 'message' => 'Failed to update post: ' . $result->get_error_message(),
479 ];
480 }
481
482 # Clear post cache
483 clean_post_cache($post_id);
484
485 $result = [
486 'success' => true,
487 'updated_count' => $updated_count,
488 'updated_headings' => $updated_headings,
489 'message' => sprintf('Updated %d heading(s) in post content', $updated_count),
490 ];
491
492 # Log to sync history
493 self::log_to_sync_history('heading_changes', $post_id, [
494 'updated_count' => $updated_count,
495 'updated_headings' => $updated_headings,
496 ]);
497
498 return $result;
499 }
500
501 return [
502 'success' => false,
503 'updated_count' => 0,
504 'message' => 'No matching headings found in post content',
505 ];
506 }
507
508 /**
509 * Process all persistence for a post based on OTTO SEO data
510 * This is the main entry point called from metasync_update_comprehensive_seo_fields
511 *
512 * @param int $post_id WordPress post ID
513 * @param array $seo_data Complete SEO data from OTTO
514 * @return array Results for all persistence operations
515 */
516 public static function process_persistence($post_id, $seo_data) {
517 $results = [
518 'image_alt_text' => null,
519 'link_corrections' => null,
520 'heading_changes' => null,
521 ];
522
523 # Process image alt text persistence
524 if (!empty($seo_data['body_substitutions']['images'])) {
525 $results['image_alt_text'] = self::persist_image_alt_text($seo_data['body_substitutions']['images']);
526 }
527
528 # Process link corrections persistence
529 if (!empty($seo_data['body_substitutions']['links'])) {
530 $results['link_corrections'] = self::persist_link_corrections($post_id, $seo_data['body_substitutions']['links']);
531 }
532
533 # Process heading changes persistence
534 if (!empty($seo_data['body_substitutions']['headings'])) {
535 $results['heading_changes'] = self::persist_heading_changes($post_id, $seo_data['body_substitutions']['headings']);
536 }
537
538 return $results;
539 }
540
541 /**
542 * Extract link corrections data from OTTO SEO data
543 *
544 * @param array $seo_data The SEO data from OTTO API
545 * @return array Array of old_url => new_url
546 */
547 public static function extract_link_corrections($seo_data) {
548 if (empty($seo_data['body_substitutions']['links']) || !is_array($seo_data['body_substitutions']['links'])) {
549 return [];
550 }
551
552 $link_data = [];
553 foreach ($seo_data['body_substitutions']['links'] as $old_url => $new_url) {
554 if (!empty($old_url) && !empty($new_url)) {
555 $link_data[esc_url_raw($old_url)] = esc_url_raw($new_url);
556 }
557 }
558
559 return $link_data;
560 }
561 }
562
563
564
565
566