PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.1.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.1.0, at includes/admin/class-metabox-manager.php

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