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

558 lines 18.2 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 with the current value
438 # Matches: <h1>current value</h1>, <h1 class="...">current value</h1>, etc.
439 $pattern = '/(<' . preg_quote($tag, '/') . '[^>]*>)\s*' . preg_quote($current_value, '/') . '\s*(<\/' . preg_quote($tag, '/') . '>)/i';
440
441 # Escape backslashes and dollar signs to prevent back-reference injection
442 $safe_replacement = str_replace(['\\', '$'], ['\\\\', '\\$'], $recommended_value);
443 $replacement = '$1' . $safe_replacement . '$2';
444
445 $new_content = preg_replace($pattern, $replacement, $content, -1, $count);
446
447 if ($count > 0 && $new_content !== null) {
448 $content = $new_content;
449 $updated_count += $count;
450 $updated_headings[] = [
451 'type' => $tag,
452 'old_value' => $current_value,
453 'new_value' => $recommended_value,
454 'occurrences' => $count,
455 ];
456 }
457 }
458
459 # Only update if content changed
460 if ($content !== $original_content) {
461 $result = wp_update_post([
462 'ID' => $post_id,
463 'post_content' => $content,
464 ], true);
465
466 if (is_wp_error($result)) {
467 return [
468 'success' => false,
469 'updated_count' => 0,
470 'message' => 'Failed to update post: ' . $result->get_error_message(),
471 ];
472 }
473
474 # Clear post cache
475 clean_post_cache($post_id);
476
477 $result = [
478 'success' => true,
479 'updated_count' => $updated_count,
480 'updated_headings' => $updated_headings,
481 'message' => sprintf('Updated %d heading(s) in post content', $updated_count),
482 ];
483
484 # Log to sync history
485 self::log_to_sync_history('heading_changes', $post_id, [
486 'updated_count' => $updated_count,
487 'updated_headings' => $updated_headings,
488 ]);
489
490 return $result;
491 }
492
493 return [
494 'success' => false,
495 'updated_count' => 0,
496 'message' => 'No matching headings found in post content',
497 ];
498 }
499
500 /**
501 * Process all persistence for a post based on OTTO SEO data
502 * This is the main entry point called from metasync_update_comprehensive_seo_fields
503 *
504 * @param int $post_id WordPress post ID
505 * @param array $seo_data Complete SEO data from OTTO
506 * @return array Results for all persistence operations
507 */
508 public static function process_persistence($post_id, $seo_data) {
509 $results = [
510 'image_alt_text' => null,
511 'link_corrections' => null,
512 'heading_changes' => null,
513 ];
514
515 # Process image alt text persistence
516 if (!empty($seo_data['body_substitutions']['images'])) {
517 $results['image_alt_text'] = self::persist_image_alt_text($seo_data['body_substitutions']['images']);
518 }
519
520 # Process link corrections persistence
521 if (!empty($seo_data['body_substitutions']['links'])) {
522 $results['link_corrections'] = self::persist_link_corrections($post_id, $seo_data['body_substitutions']['links']);
523 }
524
525 # Process heading changes persistence
526 if (!empty($seo_data['body_substitutions']['headings'])) {
527 $results['heading_changes'] = self::persist_heading_changes($post_id, $seo_data['body_substitutions']['headings']);
528 }
529
530 return $results;
531 }
532
533 /**
534 * Extract link corrections data from OTTO SEO data
535 *
536 * @param array $seo_data The SEO data from OTTO API
537 * @return array Array of old_url => new_url
538 */
539 public static function extract_link_corrections($seo_data) {
540 if (empty($seo_data['body_substitutions']['links']) || !is_array($seo_data['body_substitutions']['links'])) {
541 return [];
542 }
543
544 $link_data = [];
545 foreach ($seo_data['body_substitutions']['links'] as $old_url => $new_url) {
546 if (!empty($old_url) && !empty($new_url)) {
547 $link_data[esc_url_raw($old_url)] = esc_url_raw($new_url);
548 }
549 }
550
551 return $link_data;
552 }
553 }
554
555
556
557
558