PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.10.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / class-metabox-manager.php

class-metabox-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.10.0, at includes/admin/class-metabox-manager.php

753 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Meta Box Manager
5 *
6 * Handles ThinkRank meta boxes in post/page edit screens
7 *
8 * @package ThinkRank\Admin
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin;
15
16 use ThinkRank\AI\Metadata_Generator;
17 use ThinkRank\AI\SEOScoreCalculator;
18 use ThinkRank\Core\Settings;
19 use ThinkRank\Core\Database;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 /**
27 * Meta Box Manager Class
28 *
29 * Single Responsibility: Manage post/page meta boxes
30 *
31 * @since 1.0.0
32 */
33 class Metabox_Manager {
34
35 /**
36 * Settings instance
37 *
38 * @var Settings
39 */
40 private Settings $settings;
41
42 /**
43 * Metadata generator instance
44 *
45 * @var Metadata_Generator
46 */
47 private Metadata_Generator $metadata_generator;
48
49 /**
50 * SEO Score Calculator instance
51 *
52 * @var SEOScoreCalculator
53 */
54 private SEOScoreCalculator $seo_calculator;
55
56 /**
57 * Constructor
58 *
59 * @param Settings|null $settings Settings instance
60 * @param Metadata_Generator|null $metadata_generator Metadata generator instance
61 * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
62 */
63 public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
64 $this->settings = $settings ?? new Settings();
65 $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
66 $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
67 }
68
69 /**
70 * Initialize meta box manager
71 *
72 * @return void
73 */
74 public function init(): void {
75 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
76 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
77 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
78 add_action('init', [$this, 'register_meta_fields']);
79
80 // AJAX handlers for meta box functionality
81 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
82 add_action('wp_ajax_thinkrank_save_post_metadata', [$this, 'ajax_save_post_metadata']);
83
84 // Removed debug hooks
85 }
86
87 /**
88 * Register meta fields for REST API access
89 *
90 * @return void
91 */
92 public function register_meta_fields(): void {
93 // Register schema form data meta fields
94 register_post_meta('', '_thinkrank_schema_form_data', [
95 'show_in_rest' => true,
96 'single' => true,
97 'type' => 'string',
98 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
99 'auth_callback' => function () {
100 return current_user_can('edit_posts') || current_user_can('edit_pages');
101 }
102 ]);
103
104 register_post_meta('', '_thinkrank_selected_schema_type', [
105 'show_in_rest' => true,
106 'single' => true,
107 'type' => 'string',
108 'auth_callback' => function () {
109 return current_user_can('edit_posts') || current_user_can('edit_pages');
110 }
111 ]);
112
113 register_post_meta('', '_thinkrank_additional_schemas', [
114 'show_in_rest' => true,
115 'single' => true,
116 'type' => 'string',
117 'sanitize_callback' => [$this, 'sanitize_json_ld_field'],
118 'auth_callback' => function() {
119 return current_user_can('edit_posts') || current_user_can('edit_pages');
120 }
121 ]);
122 }
123
124 /**
125 * Sanitize JSON meta field data
126 *
127 * Validates JSON structure and recursively sanitizes all string values
128 * to prevent XSS and injection attacks.
129 *
130 * @param string $value Raw JSON string value
131 * @return string Sanitized JSON string or empty string if invalid
132 */
133 public function sanitize_json_meta_field(string $value): string {
134 // Return empty string for non-string values
135 if (!is_string($value) || empty($value)) {
136 return '';
137 }
138
139 // Validate JSON structure
140 $decoded = json_decode($value, true);
141 if (json_last_error() !== JSON_ERROR_NONE) {
142 // Invalid JSON - return empty string
143 return '';
144 }
145
146 // Check for reasonable data size (prevent JSON bombs)
147 if (strlen($value) > 50000) { // 50KB limit
148 return '';
149 }
150
151 // Recursively sanitize all values
152 $sanitized = $this->sanitize_json_recursively($decoded);
153
154 // Re-encode as JSON
155 $result = wp_json_encode($sanitized);
156 return $result !== false ? $result : '';
157 }
158
159 /**
160 * Recursively sanitize JSON data
161 *
162 * @param mixed $data Data to sanitize
163 * @param int $depth Current recursion depth
164 * @return mixed Sanitized data
165 */
166 private function sanitize_json_recursively($data, int $depth = 0): mixed {
167 // Prevent deep recursion attacks
168 if ($depth > 10) {
169 return null;
170 }
171
172 if (is_array($data)) {
173 $sanitized = [];
174 foreach ($data as $key => $value) {
175 $clean_key = sanitize_key($key);
176 $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1);
177 }
178 return $sanitized;
179 }
180
181 if (is_string($data)) {
182 // Sanitize string data to prevent XSS
183 return sanitize_textarea_field($data);
184 }
185
186 if (is_numeric($data)) {
187 return $data;
188 }
189
190 if (is_bool($data)) {
191 return $data;
192 }
193
194 // For any other data type, return null
195 return null;
196 }
197
198 /**
199 * Sanitize JSON-LD meta field data
200 *
201 * Validates JSON structure, recursively sanitizes all string values,
202 * and preserves @ characters in keys (crucial for JSON-LD).
203 *
204 * @param string $value Raw JSON string value
205 *
206 * @return string Sanitized JSON string or empty string if invalid
207 */
208 public function sanitize_json_ld_field( string $value ): string {
209 // Return empty string for non-string values
210 if ( ! is_string( $value ) || empty( $value ) ) {
211 return '';
212 }
213
214 // Validate JSON structure
215 $decoded = json_decode( $value, true );
216 if ( json_last_error() !== JSON_ERROR_NONE ) {
217 // Invalid JSON - return empty string
218 return '';
219 }
220
221 // Check for reasonable data size (prevent JSON bombs)
222 if ( strlen( $value ) > 200000 ) { // Limit to 200KB for larger schemas
223 return '';
224 }
225
226 // Recursively sanitize all values
227 $sanitized = $this->sanitize_json_ld_recursively( $decoded );
228
229 // Re-encode as JSON
230 $result = wp_json_encode( $sanitized );
231
232 return $result !== false ? $result : '';
233 }
234
235 /**
236 * Recursively sanitize JSON-LD data
237 *
238 * Similar to sanitize_json_recursively but preserves @ symbol and case in keys.
239 *
240 * @param mixed $data Data to sanitize
241 * @param int $depth Current recursion depth
242 *
243 * @return mixed Sanitized data
244 */
245 private function sanitize_json_ld_recursively( $data, int $depth = 0 ): mixed {
246 // Prevent deep recursion attacks
247 if ( $depth > 10 ) {
248 return null;
249 }
250
251 if ( is_array( $data ) ) {
252 $sanitized = [];
253 foreach ( $data as $key => $value ) {
254 // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD)
255 // Also preserve case as JSON-LD keys are case-sensitive
256 $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key );
257 $sanitized[ $clean_key ] = $this->sanitize_json_ld_recursively( $value, $depth + 1 );
258 }
259
260 return $sanitized;
261 }
262
263 if ( is_string( $data ) ) {
264 // Sanitize string data to prevent XSS
265 return sanitize_textarea_field( $data );
266 }
267
268 if ( is_numeric( $data ) ) {
269 return $data;
270 }
271
272 if ( is_bool( $data ) ) {
273 return $data;
274 }
275
276 // For any other data type, return null
277 return null;
278 }
279
280 // Removed debug methods
281
282 /**
283 * Add ThinkRank meta boxes
284 *
285 * @return void
286 */
287 public function add_meta_boxes(): void {
288 $post_types = $this->get_supported_post_types();
289
290 foreach ($post_types as $post_type) {
291 add_meta_box(
292 'thinkrank-seo-metabox',
293 __('ThinkRank SEO', 'thinkrank'),
294 [$this, 'render_seo_metabox'],
295 $post_type,
296 'normal',
297 'high'
298 );
299 }
300 }
301
302 /**
303 * Render SEO meta box
304 *
305 * @param \WP_Post $post Post object
306 * @return void
307 */
308 public function render_seo_metabox(\WP_Post $post): void {
309 // Add nonce for security
310 wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce');
311
312 // Get existing metadata
313 $existing_metadata = $this->get_post_metadata($post->ID);
314
315 // Get post content for AI analysis
316 $content_preview = $this->get_content_preview($post);
317
318 // Render React metabox container with hidden form fields for data
319 ?>
320 <div id="thinkrank-metabox-container" class="thinkrank-metabox">
321 <div class="thinkrank-loading">
322 <span class="spinner is-active"></span>
323 <p><?php esc_html_e('Loading ThinkRank metabox...', 'thinkrank'); ?></p>
324 </div>
325
326 <!-- Hidden form fields for React to read initial data -->
327 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
328 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
329 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
330 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
331 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
332 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
333 <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
334 </div>
335 <?php
336
337 }
338
339 /**
340 * Save meta box data
341 *
342 * @param int $post_id Post ID
343 * @param \WP_Post $post Post object
344 * @return void
345 */
346 public function save_meta_boxes(int $post_id, \WP_Post $post): void {
347 // Verify nonce
348 if (!isset($_POST['thinkrank_metabox_nonce'])) {
349 return;
350 }
351
352 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
353 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
354 return;
355 }
356
357 // Check permissions
358 if (!current_user_can('edit_post', $post_id)) {
359 return;
360 }
361
362 // Skip autosave
363 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
364 return;
365 }
366
367 // Save metadata
368 $fields = [
369 'thinkrank_seo_title' => 'sanitize_text_field',
370 'thinkrank_meta_description' => 'sanitize_textarea_field',
371 'thinkrank_focus_keyword' => 'sanitize_text_field',
372 'thinkrank_seo_score' => 'absint',
373 'thinkrank_generated_at' => 'sanitize_text_field',
374 'thinkrank_pillar_content' => 'sanitize_text_field',
375 ];
376
377 foreach ($fields as $field => $sanitize_callback) {
378 if (isset($_POST[$field])) {
379 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized via callback below
380 $raw_value = wp_unslash($_POST[$field]);
381 $value = call_user_func($sanitize_callback, $raw_value);
382 update_post_meta($post_id, "_{$field}", $value);
383 }
384 }
385
386 // Update last modified timestamp
387 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
388 }
389
390
391 /**
392 * Enqueue meta box scripts
393 *
394 * @param string $hook Current admin page hook
395 * @return void
396 */
397 public function enqueue_metabox_scripts(string $hook): void {
398 // Only load on post edit screens (including block editor)
399 if (!in_array($hook, ['post.php', 'post-new.php'])) {
400 return;
401 }
402
403 // Get current post type - handle both classic and block editor contexts
404 $current_post_type = $this->get_current_post_type();
405 if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types())) {
406 return;
407 }
408
409 // Get post object for additional data
410 global $post;
411
412 // No chunk dependencies needed - all bundled into main metabox.js
413 // Enqueue React metabox script with direct dependencies
414 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
415 $asset = file_exists($asset_file) ? include $asset_file : [
416 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
417 'version' => THINKRANK_VERSION
418 ];
419
420 // Use dependencies directly from asset file
421 $dependencies = $asset['dependencies'];
422
423 wp_enqueue_script(
424 'thinkrank-metabox',
425 THINKRANK_PLUGIN_URL . 'assets/metabox.js',
426 $dependencies,
427 $asset['version'],
428 true
429 );
430
431 // Localize script data
432 // Resolve the correct REST API base for the current post type.
433 // Falls back to 'posts' for any type that doesn't register a rest_base.
434 $post_type_obj = get_post_type_object( $post->post_type );
435 $post_rest_base = ( $post_type_obj && ! empty( $post_type_obj->rest_base ) )
436 ? $post_type_obj->rest_base
437 : $post->post_type;
438
439 wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', [
440 'ajaxUrl' => admin_url('admin-ajax.php'),
441 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
442 'postId' => $post->ID,
443 'postType' => $post->post_type,
444 'postRestBase' => $post_rest_base,
445 'postPermalink' => get_permalink($post->ID),
446 'restUrl' => rest_url('thinkrank/v1/'),
447 'restNonce' => wp_create_nonce('wp_rest'),
448 'homeUrl' => home_url(),
449 'siteName' => get_bloginfo('name'),
450 'faviconUrl' => $this->get_site_favicon_url(),
451 'featuredImageUrl' => $this->get_post_featured_image_url($post->ID),
452 'strings' => [
453 'generating' => __('Generating...', 'thinkrank'),
454 'analyzing' => __('Analyzing...', 'thinkrank'),
455 'error' => __('Error occurred', 'thinkrank'),
456 'success' => __('Success!', 'thinkrank'),
457 'generated' => __('Metadata generated successfully', 'thinkrank'),
458 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
459 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
460 ],
461 'postModified' => get_the_modified_date('c', $post),
462 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post->post_type),
463 'postStatus' => get_post_status($post->ID),
464 'isPro' => (bool) apply_filters('thinkrank_is_pro_active', false),
465 ]);
466
467 // Enqueue metabox styles
468 wp_enqueue_style(
469 'thinkrank-metabox',
470 THINKRANK_PLUGIN_URL . 'assets/metabox.css',
471 [],
472 THINKRANK_VERSION
473 );
474 }
475
476 /**
477 * Check if link suggestions are enabled for a post type
478 *
479 * @param string $post_type Post type to check
480 * @return bool True if enabled, false otherwise
481 */
482 private function is_link_suggestions_enabled(string $post_type): bool {
483 $settings = get_option('thinkrank_global_seo_settings', []);
484
485 if (isset($settings[$post_type]['link_suggestions'])) {
486 return (bool) $settings[$post_type]['link_suggestions'];
487 }
488
489 return true;
490 }
491
492 /**
493 * Get current post type in admin context
494 *
495 * Handles both classic editor and block editor contexts
496 *
497 * @return string|null Current post type or null if not found
498 */
499 private function get_current_post_type(): ?string {
500 global $post, $typenow, $current_screen;
501
502 // Try to get post type from various sources
503 if ($post && !empty($post->post_type)) {
504 return $post->post_type;
505 }
506
507 if (!empty($typenow)) {
508 return $typenow;
509 }
510
511 if ($current_screen && !empty($current_screen->post_type)) {
512 return $current_screen->post_type;
513 }
514
515 // Fallback: check URL parameters for block editor
516 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
517 if (isset($_GET['post_type'])) {
518 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
519 return sanitize_text_field(wp_unslash($_GET['post_type']));
520 }
521
522 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
523 if (isset($_GET['post'])) {
524 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
525 $post_id = absint($_GET['post']);
526 $post_type = get_post_type($post_id);
527 if ($post_type) {
528 return $post_type;
529 }
530 }
531
532 return null;
533 }
534
535 /**
536 * Get supported post types
537 *
538 * @return array Supported post types
539 */
540 private function get_supported_post_types(): array {
541 $default_types = ['post', 'page'];
542
543 // Add WooCommerce product if available
544 if (class_exists('WooCommerce')) {
545 $default_types[] = 'product';
546 }
547
548 // Add other common e-commerce post types
549 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
550 foreach ($ecommerce_types as $type) {
551 if (post_type_exists($type) && !in_array($type, $default_types)) {
552 $default_types[] = $type;
553 }
554 }
555
556 // Add custom post types that are public and have UI
557 $custom_post_types = get_post_types([
558 'public' => true,
559 'show_ui' => true,
560 '_builtin' => false,
561 ]);
562
563 foreach ($custom_post_types as $post_type) {
564 // Skip certain post types that shouldn't have SEO metabox
565 $excluded_types = [
566 'attachment',
567 'revision',
568 'nav_menu_item',
569 'custom_css',
570 'customize_changeset',
571 'oembed_cache',
572 'user_request',
573 'wp_block',
574 'wp_template',
575 'wp_template_part',
576 'wp_global_styles',
577 'wp_navigation',
578 'acf-field',
579 'acf-field-group',
580 ];
581
582 if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) {
583 $default_types[] = $post_type;
584 }
585 }
586
587 return apply_filters('thinkrank_supported_post_types', $default_types);
588 }
589
590 /**
591 * Get existing post metadata
592 *
593 * @param int $post_id Post ID
594 * @return array Existing metadata
595 */
596 private function get_post_metadata(int $post_id): array {
597 return [
598 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
599 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
600 'focus_keyword' => get_post_meta($post_id, '_thinkrank_focus_keyword', true),
601 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
602 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
603 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
604 ];
605 }
606
607 /**
608 * Get site favicon URL
609 *
610 * @return string Favicon URL
611 */
612 private function get_site_favicon_url(): string {
613 // Try to get site icon first (WordPress 4.3+)
614 $site_icon_id = get_option('site_icon');
615 if ($site_icon_id) {
616 $site_icon_url = wp_get_attachment_image_url($site_icon_id, 'full');
617 if ($site_icon_url) {
618 return $site_icon_url;
619 }
620 }
621
622 // Fallback to common favicon locations
623 $favicon_paths = [
624 '/favicon.ico',
625 '/favicon.png',
626 '/apple-touch-icon.png',
627 ];
628
629 foreach ($favicon_paths as $path) {
630 $favicon_url = home_url($path);
631 $response = wp_remote_head($favicon_url);
632 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
633 return $favicon_url;
634 }
635 }
636
637 // Final fallback - return a default favicon URL
638 return home_url('/favicon.ico');
639 }
640
641 /**
642 * Get post featured image URL
643 *
644 * @param int $post_id Post ID
645 * @return string|null Featured image URL or null if not available
646 */
647 private function get_post_featured_image_url(int $post_id): ?string {
648 $thumbnail_id = get_post_thumbnail_id($post_id);
649 if ($thumbnail_id) {
650 $image_url = wp_get_attachment_image_url($thumbnail_id, 'medium');
651 return $image_url ?: null;
652 }
653 return null;
654 }
655
656 /**
657 * Get content preview for AI analysis
658 *
659 * @param \WP_Post $post Post object
660 * @return string Content preview
661 */
662 private function get_content_preview(\WP_Post $post): string {
663 $content = $post->post_title . "\n\n";
664
665 if (!empty($post->post_excerpt)) {
666 $content .= $post->post_excerpt . "\n\n";
667 }
668
669 $content .= $post->post_content;
670
671 // Clean and limit content
672 $content = wp_strip_all_tags($content);
673 $content = preg_replace('/\s+/', ' ', $content);
674
675 return trim(substr($content, 0, 4000));
676 }
677
678 /**
679 * AJAX handler for generating post metadata
680 *
681 * @return void
682 */
683 public function ajax_generate_post_metadata(): void {
684 // Verify nonce
685 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
686 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
687 wp_die('Security check failed');
688 }
689
690 // Check permissions
691 $post_id = absint($_POST['post_id'] ?? 0);
692 if (!current_user_can('edit_post', $post_id)) {
693 wp_die('Insufficient permissions');
694 }
695
696 try {
697 $options = [
698 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
699 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
700 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
701 ];
702
703 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
704
705 wp_send_json_success([
706 'metadata' => $metadata,
707 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
708 ]);
709 } catch (\Exception $e) {
710 wp_send_json_error([
711 'message' => $e->getMessage(),
712 ]);
713 }
714 }
715
716 /**
717 * AJAX handler for saving post metadata
718 *
719 * @return void
720 */
721 public function ajax_save_post_metadata(): void {
722 // Verify nonce
723 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
724 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
725 wp_die('Security check failed');
726 }
727
728 // Check permissions
729 $post_id = absint($_POST['post_id'] ?? 0);
730 if (!current_user_can('edit_post', $post_id)) {
731 wp_die('Insufficient permissions');
732 }
733
734 // Save metadata
735 $metadata = [
736 'title' => sanitize_text_field(wp_unslash($_POST['title'] ?? '')),
737 'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')),
738 'focus_keyword' => sanitize_text_field(wp_unslash($_POST['focus_keyword'] ?? '')),
739 'seo_score' => absint($_POST['seo_score'] ?? 0),
740 ];
741
742 foreach ($metadata as $key => $value) {
743 update_post_meta($post_id, "_thinkrank_{$key}", $value);
744 }
745
746 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
747
748 wp_send_json_success([
749 'message' => __('Metadata saved successfully!', 'thinkrank'),
750 ]);
751 }
752 }
753