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

595 lines 19.8 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 * Build a heading's new inner HTML, preserving the author's inline markup.
378 *
379 * Delegates to Metasync_otto_html so the render paths and this persistence
380 * path share one rule. Loads that file on demand — persistence runs from
381 * REST/cron contexts where otto_pixel.php (which normally requires it) may
382 * not have loaded. The method is static, so no DOM library is touched.
383 *
384 * @param string $inner_html Current inner HTML of the heading.
385 * @param string $replacement_text New heading text.
386 * @return string|null New inner HTML, or null to leave the heading alone.
387 */
388 private static function build_heading_inner_html($inner_html, $replacement_text) {
389 if (!class_exists('Metasync_otto_html')) {
390 require_once plugin_dir_path(__FILE__) . 'Otto_html_class.php';
391 }
392
393 return Metasync_otto_html::build_heading_inner_html($inner_html, $replacement_text);
394 }
395
396 /**
397 * Persist heading changes to post content
398 * Updates heading text in the actual post_content
399 *
400 * @param int $post_id WordPress post ID
401 * @param array $heading_data Array of heading changes
402 * @return array Results with counts and details
403 */
404 public static function persist_heading_changes($post_id, $heading_data) {
405 if (empty($heading_data) || !is_array($heading_data)) {
406 return [
407 'success' => false,
408 'updated_count' => 0,
409 'message' => 'No heading data provided',
410 ];
411 }
412
413 # Check if persistence is enabled
414 if (!self::should_persist('heading_changes')) {
415 return [
416 'success' => false,
417 'updated_count' => 0,
418 'message' => 'Heading changes persistence is disabled',
419 ];
420 }
421
422 $post = get_post($post_id);
423 if (!$post) {
424 return [
425 'success' => false,
426 'updated_count' => 0,
427 'message' => 'Post not found',
428 ];
429 }
430
431 if (!current_user_can('edit_post', $post_id)) {
432 return [
433 'success' => false,
434 'updated_count' => 0,
435 'message' => 'Insufficient permissions to edit this post',
436 ];
437 }
438
439 $content = $post->post_content;
440 $original_content = $content;
441 $updated_count = 0;
442 $updated_headings = [];
443
444 foreach ($heading_data as $heading) {
445 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
446 continue;
447 }
448
449 $tag = strtolower(sanitize_text_field($heading['type'])); # h1, h2, h3, etc.
450 $current_value = $heading['current_value'];
451 $recommended_value = sanitize_text_field($heading['recommended_value']);
452
453 if ($current_value === $recommended_value) {
454 continue;
455 }
456
457 # Build regex to match the heading tag — use callback to strip inline HTML (e.g. <span>) for comparison
458 $pattern = '/(<' . preg_quote($tag, '/') . '[^>]*>)(.*?)(<\/' . preg_quote($tag, '/') . '>)/is';
459
460 $normalized_current = trim(preg_replace('/\s+/', ' ', html_entity_decode($current_value, ENT_QUOTES, 'UTF-8')));
461 $count = 0;
462 $new_content = preg_replace_callback(
463 $pattern,
464 function ($m) use ($normalized_current, $recommended_value, &$count) {
465 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
466 if (strcasecmp($inner_text, $normalized_current) !== 0) {
467 return $m[0];
468 }
469
470 # Keep the author's inline formatting. This writes to
471 # post_content, so flattening a heading here destroys the
472 # markup permanently — skip rather than damage the content.
473 $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
474 if ($new_inner === null) {
475 return $m[0];
476 }
477
478 $count++;
479 return $m[1] . $new_inner . $m[3];
480 },
481 $content
482 );
483
484 if ($count > 0 && $new_content !== null) {
485 $content = $new_content;
486 $updated_count += $count;
487 $updated_headings[] = [
488 'type' => $tag,
489 'old_value' => $current_value,
490 'new_value' => $recommended_value,
491 'occurrences' => $count,
492 ];
493 }
494 }
495
496 # Only update if content changed
497 if ($content !== $original_content) {
498 $result = wp_update_post([
499 'ID' => $post_id,
500 'post_content' => $content,
501 ], true);
502
503 if (is_wp_error($result)) {
504 return [
505 'success' => false,
506 'updated_count' => 0,
507 'message' => 'Failed to update post: ' . $result->get_error_message(),
508 ];
509 }
510
511 # Clear post cache
512 clean_post_cache($post_id);
513
514 $result = [
515 'success' => true,
516 'updated_count' => $updated_count,
517 'updated_headings' => $updated_headings,
518 'message' => sprintf('Updated %d heading(s) in post content', $updated_count),
519 ];
520
521 # Log to sync history
522 self::log_to_sync_history('heading_changes', $post_id, [
523 'updated_count' => $updated_count,
524 'updated_headings' => $updated_headings,
525 ]);
526
527 return $result;
528 }
529
530 return [
531 'success' => false,
532 'updated_count' => 0,
533 'message' => 'No matching headings found in post content',
534 ];
535 }
536
537 /**
538 * Process all persistence for a post based on OTTO SEO data
539 * This is the main entry point called from metasync_update_comprehensive_seo_fields
540 *
541 * @param int $post_id WordPress post ID
542 * @param array $seo_data Complete SEO data from OTTO
543 * @return array Results for all persistence operations
544 */
545 public static function process_persistence($post_id, $seo_data) {
546 $results = [
547 'image_alt_text' => null,
548 'link_corrections' => null,
549 'heading_changes' => null,
550 ];
551
552 # Process image alt text persistence
553 if (!empty($seo_data['body_substitutions']['images'])) {
554 $results['image_alt_text'] = self::persist_image_alt_text($seo_data['body_substitutions']['images']);
555 }
556
557 # Process link corrections persistence
558 if (!empty($seo_data['body_substitutions']['links'])) {
559 $results['link_corrections'] = self::persist_link_corrections($post_id, $seo_data['body_substitutions']['links']);
560 }
561
562 # Process heading changes persistence
563 if (!empty($seo_data['body_substitutions']['headings'])) {
564 $results['heading_changes'] = self::persist_heading_changes($post_id, $seo_data['body_substitutions']['headings']);
565 }
566
567 return $results;
568 }
569
570 /**
571 * Extract link corrections data from OTTO SEO data
572 *
573 * @param array $seo_data The SEO data from OTTO API
574 * @return array Array of old_url => new_url
575 */
576 public static function extract_link_corrections($seo_data) {
577 if (empty($seo_data['body_substitutions']['links']) || !is_array($seo_data['body_substitutions']['links'])) {
578 return [];
579 }
580
581 $link_data = [];
582 foreach ($seo_data['body_substitutions']['links'] as $old_url => $new_url) {
583 if (!empty($old_url) && !empty($new_url)) {
584 $link_data[esc_url_raw($old_url)] = esc_url_raw($new_url);
585 }
586 }
587
588 return $link_data;
589 }
590 }
591
592
593
594
595