PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 58 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 2.14.3, at includes/admin/class-metabox-manager.php

1,688 lines 67.3 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 use ThinkRank\Core\Plan_Config;
21 use ThinkRank\SEO\Focus_Keywords;
22 use ThinkRank\SEO\Object_Redirect;
23 use ThinkRank\SEO\Pattern_Resolver;
24
25 // Prevent direct access
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Meta Box Manager Class
32 *
33 * Single Responsibility: Manage post/page meta boxes
34 *
35 * @since 1.0.0
36 */
37 class Metabox_Manager {
38
39 /**
40 * Settings instance
41 *
42 * @var Settings
43 */
44 private Settings $settings;
45
46 /**
47 * Metadata generator instance
48 *
49 * @var Metadata_Generator
50 */
51 private Metadata_Generator $metadata_generator;
52
53 /**
54 * SEO Score Calculator instance
55 *
56 * @var SEOScoreCalculator
57 */
58 private SEOScoreCalculator $seo_calculator;
59
60 /**
61 * Constructor
62 *
63 * @param Settings|null $settings Settings instance
64 * @param Metadata_Generator|null $metadata_generator Metadata generator instance
65 * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
66 */
67 public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
68 $this->settings = $settings ?? Settings::instance();
69 $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
70 $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
71 }
72
73 /**
74 * Initialize meta box manager
75 *
76 * @return void
77 */
78 public function init(): void {
79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 add_action('admin_notices', [$this, 'render_redirect_notice']);
83 add_action('init', [$this, 'register_meta_fields']);
84
85 // AJAX handlers for meta box functionality
86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
87
88 // Full metabox save used by editors that don't submit the #post form
89 // (e.g. the Elementor editor). Persists every metabox field at once.
90 add_action('wp_ajax_thinkrank_save_metabox', [$this, 'ajax_save_metabox']);
91
92 // Removed debug hooks
93 }
94
95 /**
96 * Register meta fields for REST API access
97 *
98 * @return void
99 */
100 public function register_meta_fields(): void {
101 // Register schema form data meta fields
102 register_post_meta('', '_thinkrank_schema_form_data', [
103 'show_in_rest' => true,
104 'single' => true,
105 'type' => 'string',
106 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
107 'auth_callback' => function () {
108 return current_user_can('edit_posts') || current_user_can('edit_pages');
109 }
110 ]);
111
112 register_post_meta('', '_thinkrank_selected_schema_type', [
113 'show_in_rest' => true,
114 'single' => true,
115 'type' => 'string',
116 'auth_callback' => function () {
117 return current_user_can('edit_posts') || current_user_can('edit_pages');
118 }
119 ]);
120
121 register_post_meta('', '_thinkrank_additional_schemas', [
122 'show_in_rest' => true,
123 'single' => true,
124 'type' => 'string',
125 'sanitize_callback' => [$this, 'sanitize_json_ld_field'],
126 'auth_callback' => function() {
127 return current_user_can('edit_posts') || current_user_can('edit_pages');
128 }
129 ]);
130
131 // SEO meta fields for import support
132 $string_meta_fields = [
133 '_thinkrank_canonical_url',
134 '_thinkrank_og_title',
135 '_thinkrank_og_description',
136 '_thinkrank_og_image',
137 '_thinkrank_twitter_title',
138 '_thinkrank_twitter_description',
139 '_thinkrank_twitter_image',
140 '_thinkrank_imported_from',
141 ];
142
143 foreach ($string_meta_fields as $meta_key) {
144 register_post_meta('', $meta_key, [
145 'show_in_rest' => true,
146 'single' => true,
147 'type' => 'string',
148 'auth_callback' => function () {
149 return current_user_can('edit_posts') || current_user_can('edit_pages');
150 }
151 ]);
152 }
153
154 // Multiple focus keywords (array). The legacy single-value
155 // `_thinkrank_focus_keyword` is kept in sync by Focus_Keywords for
156 // backward compatibility and registered for REST as a string elsewhere.
157 register_post_meta('', Focus_Keywords::META_KEY, [
158 'show_in_rest' => [
159 'schema' => [
160 'type' => 'array',
161 'items' => ['type' => 'string'],
162 ],
163 ],
164 'single' => true,
165 'type' => 'array',
166 'sanitize_callback' => function ($value) {
167 return Focus_Keywords::normalize($value);
168 },
169 'auth_callback' => function () {
170 return current_user_can('edit_posts') || current_user_can('edit_pages');
171 }
172 ]);
173
174 register_post_meta('', Focus_Keywords::LEGACY_META_KEY, [
175 'show_in_rest' => true,
176 'single' => true,
177 'type' => 'string',
178 'auth_callback' => function () {
179 return current_user_can('edit_posts') || current_user_can('edit_pages');
180 }
181 ]);
182
183 register_post_meta('', '_thinkrank_robots_meta_enabled', [
184 'show_in_rest' => true,
185 'single' => true,
186 'type' => 'integer',
187 'auth_callback' => function () {
188 return current_user_can('edit_posts') || current_user_can('edit_pages');
189 }
190 ]);
191
192 register_post_meta('', '_thinkrank_robots_meta', [
193 'show_in_rest' => true,
194 'single' => true,
195 'type' => 'string',
196 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
197 'auth_callback' => function () {
198 return current_user_can('edit_posts') || current_user_can('edit_pages');
199 }
200 ]);
201
202 register_post_meta('', '_thinkrank_advanced_robots_meta', [
203 'show_in_rest' => true,
204 'single' => true,
205 'type' => 'string',
206 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
207 'auth_callback' => function () {
208 return current_user_can('edit_posts') || current_user_can('edit_pages');
209 }
210 ]);
211
212 register_post_meta('', \ThinkRank\SEO\Content_Visibility::SEARCH_META, [
213 'show_in_rest' => true,
214 'single' => true,
215 'type' => 'integer',
216 'auth_callback' => function () {
217 return current_user_can('edit_posts') || current_user_can('edit_pages');
218 }
219 ]);
220
221 register_post_meta('', \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, [
222 'show_in_rest' => true,
223 'single' => true,
224 'type' => 'integer',
225 'auth_callback' => function () {
226 return current_user_can('edit_posts') || current_user_can('edit_pages');
227 }
228 ]);
229
230 register_post_meta('', '_thinkrank_primary_category', [
231 'show_in_rest' => true,
232 'single' => true,
233 'type' => 'integer',
234 'auth_callback' => function () {
235 return current_user_can('edit_posts') || current_user_can('edit_pages');
236 }
237 ]);
238 }
239
240 /**
241 * Sanitize JSON meta field data
242 *
243 * Validates JSON structure and recursively sanitizes all string values
244 * to prevent XSS and injection attacks.
245 *
246 * Untyped on purpose: this is a registered `sanitize_callback`, so
247 * WordPress hands it whatever any writer stored. A `string` parameter made
248 * an array or null a fatal TypeError instead of reaching the guard below
249 * (#1014). An array is a decoded payload and is encoded rather than lost.
250 *
251 * @param mixed $value Raw JSON string value, or an already-decoded array
252 * @return string Sanitized JSON string or empty string if invalid
253 */
254 public function sanitize_json_meta_field($value): string {
255 if (is_array($value)) {
256 $value = (string) wp_json_encode($value);
257 }
258
259 // Return empty string for non-string values
260 if (!is_string($value) || empty($value)) {
261 return '';
262 }
263
264 // Validate JSON structure
265 $decoded = json_decode($value, true);
266 if (json_last_error() !== JSON_ERROR_NONE) {
267 // Invalid JSON - return empty string
268 return '';
269 }
270
271 // Check for reasonable data size (prevent JSON bombs)
272 if (strlen($value) > 50000) { // 50KB limit
273 return '';
274 }
275
276 // Recursively sanitize all values
277 $sanitized = $this->sanitize_json_recursively($decoded);
278
279 // Re-encode as JSON
280 $result = wp_json_encode($sanitized);
281 return $result !== false ? $result : '';
282 }
283
284 /**
285 * Recursively sanitize JSON data
286 *
287 * @param mixed $data Data to sanitize
288 * @param int $depth Current recursion depth
289 * @return mixed Sanitized data
290 */
291 private function sanitize_json_recursively($data, int $depth = 0) {
292 // Prevent deep recursion attacks
293 if ($depth > 10) {
294 return null;
295 }
296
297 if (is_array($data)) {
298 $sanitized = [];
299 foreach ($data as $key => $value) {
300 $clean_key = sanitize_key($key);
301 $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1);
302 }
303 return $sanitized;
304 }
305
306 if (is_string($data)) {
307 // Sanitize string data to prevent XSS
308 return sanitize_textarea_field($data);
309 }
310
311 if (is_numeric($data)) {
312 return $data;
313 }
314
315 if (is_bool($data)) {
316 return $data;
317 }
318
319 // For any other data type, return null
320 return null;
321 }
322
323 /**
324 * Sanitize JSON-LD meta field data
325 *
326 * Validates JSON structure, recursively sanitizes all string values,
327 * and preserves @ characters in keys (crucial for JSON-LD).
328 *
329 * Untyped for the same reason as sanitize_json_meta_field() (#1014).
330 *
331 * @param mixed $value Raw JSON string value, or an already-decoded array
332 *
333 * @return string Sanitized JSON string or empty string if invalid
334 */
335 public function sanitize_json_ld_field( $value ): string {
336 if ( is_array( $value ) ) {
337 $value = (string) wp_json_encode( $value );
338 }
339
340 // Return empty string for non-string values
341 if ( ! is_string( $value ) || empty( $value ) ) {
342 return '';
343 }
344
345 // Validate JSON structure
346 $decoded = json_decode( $value, true );
347 if ( json_last_error() !== JSON_ERROR_NONE ) {
348 // Invalid JSON - return empty string
349 return '';
350 }
351
352 // Check for reasonable data size (prevent JSON bombs)
353 if ( strlen( $value ) > 200000 ) { // Limit to 200KB for larger schemas
354 return '';
355 }
356
357 // Recursively sanitize all values
358 $sanitized = $this->sanitize_json_ld_recursively( $decoded );
359
360 // Re-encode as JSON
361 $result = wp_json_encode( $sanitized );
362
363 return $result !== false ? $result : '';
364 }
365
366 /**
367 * Recursively sanitize JSON-LD data
368 *
369 * Similar to sanitize_json_recursively but preserves @ symbol and case in keys.
370 *
371 * @param mixed $data Data to sanitize
372 * @param int $depth Current recursion depth
373 *
374 * @return mixed Sanitized data
375 */
376 private function sanitize_json_ld_recursively( $data, int $depth = 0 ) {
377 // Prevent deep recursion attacks
378 if ( $depth > 10 ) {
379 return null;
380 }
381
382 if ( is_array( $data ) ) {
383 $sanitized = [];
384 foreach ( $data as $key => $value ) {
385 // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD)
386 // Also preserve case as JSON-LD keys are case-sensitive
387 $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key );
388 $sanitized[ $clean_key ] = $this->sanitize_json_ld_recursively( $value, $depth + 1 );
389 }
390
391 return $sanitized;
392 }
393
394 if ( is_string( $data ) ) {
395 // Sanitize string data to prevent XSS
396 return sanitize_textarea_field( $data );
397 }
398
399 if ( is_numeric( $data ) ) {
400 return $data;
401 }
402
403 if ( is_bool( $data ) ) {
404 return $data;
405 }
406
407 // For any other data type, return null
408 return null;
409 }
410
411 // Removed debug methods
412
413 /**
414 * Add ThinkRank meta boxes
415 *
416 * @return void
417 */
418 public function add_meta_boxes(): void {
419 $post_types = $this->get_supported_post_types();
420
421 $is_block_editor = $this->is_block_editor_screen();
422
423 foreach ($post_types as $post_type) {
424 add_meta_box(
425 'thinkrank-seo-metabox',
426 __('ThinkRank SEO', 'thinkrank'),
427 [$this, 'render_seo_metabox'],
428 $post_type,
429 'normal',
430 'high'
431 );
432
433 // Classic Editor sidebar quick-access widget (mirrors SureRank's
434 // "Manage your SEO" sidebar box). Skipped in the Block Editor, which
435 // surfaces the full panel below the content instead.
436 if (!$is_block_editor) {
437 add_meta_box(
438 'thinkrank-seo-sidebar',
439 __('ThinkRank', 'thinkrank'),
440 [$this, 'render_sidebar_meta_box'],
441 $post_type,
442 'side',
443 'high'
444 );
445 }
446 }
447 }
448
449 /**
450 * Whether the current edit screen is using the Block Editor.
451 *
452 * @return bool True when the Block Editor is active, false for Classic Editor.
453 */
454 private function is_block_editor_screen(): bool {
455 if (!function_exists('get_current_screen')) {
456 return false;
457 }
458
459 $screen = get_current_screen();
460
461 return $screen instanceof \WP_Screen
462 && method_exists($screen, 'is_block_editor')
463 && $screen->is_block_editor();
464 }
465
466 /**
467 * Render the Classic Editor sidebar quick-access widget.
468 *
469 * Shows a short label plus a button that scrolls to (and expands) the full
470 * "ThinkRank SEO" panel in the main column.
471 *
472 * @param \WP_Post $post Post object.
473 * @return void
474 */
475 public function render_sidebar_meta_box(\WP_Post $post): void {
476 $box_title = apply_filters('thinkrank_seo_sidebar_box_title', __('Optimize this content for search & AI with ThinkRank.', 'thinkrank'), $post);
477 $cta_label = apply_filters('thinkrank_seo_sidebar_cta_label', __('Open ThinkRank SEO', 'thinkrank'), $post);
478 ?>
479 <div class="thinkrank-classic-sidebar-box">
480 <p class="thinkrank-classic-sidebar-box-title"><?php echo esc_html($box_title); ?></p>
481 <button
482 type="button"
483 class="button button-primary thinkrank-classic-sidebar-box-cta"
484 >
485 <?php echo esc_html($cta_label); ?>
486 </button>
487 </div>
488 <?php
489 }
490
491 /**
492 * Render SEO meta box
493 *
494 * @param \WP_Post $post Post object
495 * @return void
496 */
497 public function render_seo_metabox(\WP_Post $post): void {
498 // Add nonce for security
499 wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce');
500
501 // Get existing metadata
502 $existing_metadata = $this->get_post_metadata($post->ID);
503
504 // Get post content for AI analysis
505 $content_preview = $this->get_content_preview($post);
506
507 // Render React metabox container with hidden form fields for data
508 ?>
509 <div id="thinkrank-metabox-container" class="thinkrank-metabox">
510
511 <!-- Hidden form fields for React to read initial data -->
512 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
513 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
514 <?php // Render-time mirrors of the two fields a background writer (Auto AI, bulk, import) can fill after load. The React app never touches these, so on save they still hold the value shown when the form loaded — letting persist_metadata() tell a stale blank apart from a deliberate clear. ?>
515 <input type="hidden" id="thinkrank_seo_title__orig" name="thinkrank_seo_title__orig" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
516 <input type="hidden" id="thinkrank_meta_description__orig" name="thinkrank_meta_description__orig" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
517 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
518 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
519 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
520 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
521 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
522 <input type="hidden" id="thinkrank_exclude_from_search" name="thinkrank_exclude_from_search" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_search'] ?? '')); ?>" />
523 <input type="hidden" id="thinkrank_exclude_from_archives" name="thinkrank_exclude_from_archives" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_archives'] ?? '')); ?>" />
524 <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
525 <?php
526 // The redirect lives in Pro's rules table, not post meta, so nothing
527 // else hands it to the React app. Without these the field loads
528 // empty, and its own hidden input then posts that empty value on the
529 // next save, which Object_Redirect reads as "remove the redirect".
530 // Rendered only when a provider can store it, matching MetaboxApp.
531 if (Object_Redirect::is_supported()) :
532 ?>
533 <input type="hidden" id="thinkrank_redirect_url" name="thinkrank_redirect_url" value="<?php echo esc_attr((string) ($existing_metadata['redirect_url'] ?? '')); ?>" />
534 <input type="hidden" id="thinkrank_redirect_type" name="thinkrank_redirect_type" value="<?php echo esc_attr((string) ($existing_metadata['redirect_type'] ?? Object_Redirect::DEFAULT_TYPE)); ?>" />
535 <?php endif; ?>
536 <input type="hidden" id="thinkrank_robots_meta_enabled" name="thinkrank_robots_meta_enabled" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta_enabled'] ?? '0')); ?>" />
537 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
538 <input type="hidden" id="thinkrank_advanced_robots_meta" name="thinkrank_advanced_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['advanced_robots_meta'] ?? '')); ?>" />
539 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
540 <input type="hidden" id="thinkrank_og_description" name="thinkrank_og_description" value="<?php echo esc_attr((string) ($existing_metadata['og_description'] ?? '')); ?>" />
541 <input type="hidden" id="thinkrank_og_image" name="thinkrank_og_image" value="<?php echo esc_url((string) ($existing_metadata['og_image'] ?? '')); ?>" />
542 <input type="hidden" id="thinkrank_twitter_title" name="thinkrank_twitter_title" value="<?php echo esc_attr((string) ($existing_metadata['twitter_title'] ?? '')); ?>" />
543 <input type="hidden" id="thinkrank_twitter_description" name="thinkrank_twitter_description" value="<?php echo esc_attr((string) ($existing_metadata['twitter_description'] ?? '')); ?>" />
544 <input type="hidden" id="thinkrank_twitter_image" name="thinkrank_twitter_image" value="<?php echo esc_url((string) ($existing_metadata['twitter_image'] ?? '')); ?>" />
545 <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
546 </div>
547 <?php
548
549 }
550
551 /**
552 * Save meta box data
553 *
554 * @param int $post_id Post ID
555 * @param \WP_Post $post Post object
556 * @return void
557 */
558 public function save_meta_boxes(int $post_id, \WP_Post $post): void {
559 // Verify nonce
560 if (!isset($_POST['thinkrank_metabox_nonce'])) {
561 return;
562 }
563
564 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
565 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
566 return;
567 }
568
569 // Check permissions
570 if (!current_user_can('edit_post', $post_id)) {
571 return;
572 }
573
574 // Skip autosave
575 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
576 return;
577 }
578
579 // The classic/block editor submits the metabox fields as part of the
580 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
581 // the shared persistence routine.
582 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
583 $this->persist_metadata($post_id, wp_unslash($_POST));
584
585 // The redirect is the one field here that can be refused outright. The
586 // response to this request is a redirect back to the editor, so the
587 // reason has to survive one page load to be seen at all.
588 $redirect_error = $this->get_last_redirect_error();
589 if (null !== $redirect_error) {
590 $this->store_redirect_error($redirect_error);
591 }
592 }
593
594 /**
595 * Persist metabox fields for a post from a form-field-name => value map,
596 * reusing the shared metabox persistence (sanitization, JSON encoding,
597 * focus-keyword normalization, empty-value deletion).
598 *
599 * Intended for non-form callers such as the MCP abilities layer. The
600 * caller is responsible for authorization; keys use the same
601 * `thinkrank_*` field names accepted by the metabox form (e.g.
602 * `thinkrank_seo_title`, `thinkrank_meta_description`, `thinkrank_robots_meta`).
603 *
604 * @param int $post_id Post to update.
605 * @param array $fields Field name => value map.
606 * @return void
607 */
608 public function save_seo_fields(int $post_id, array $fields): void {
609 $this->persist_metadata($post_id, $fields);
610 }
611
612 /**
613 * Persist all metabox fields for a post from a $_POST-shaped (already
614 * unslashed) source array.
615 *
616 * Shared by `save_meta_boxes()` (classic/block editor form submit) and
617 * `ajax_save_metabox()` (editors like Elementor that don't submit the #post
618 * form). Nonce/permission checks are the caller's responsibility. Each field
619 * is independently sanitized; missing keys are left untouched.
620 *
621 * @param int $post_id Post to update.
622 * @param array $src Field name => raw value map (unslashed).
623 * @return void
624 */
625 private function persist_metadata(int $post_id, array $src): void {
626 // Title & meta description are handled separately below: they can be
627 // written out-of-band (Auto AI on publish, imports)
628 // after an editor was opened, so a plain save from that now-stale editor
629 // would clobber the generated value with a blank. Focus keywords are
630 // likewise handled separately (array meta) via Focus_Keywords below.
631 //
632 // Both fields may hold variable tags, so they are sanitized as templates:
633 // sanitize_text_field()/sanitize_textarea_field() strip %date% and
634 // %category% as percent-encoding and store "te%" / "tegory%" (#521).
635 $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']);
636 $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']);
637
638 $fields = [
639 'thinkrank_seo_score' => 'absint',
640 'thinkrank_generated_at' => 'sanitize_text_field',
641 'thinkrank_pillar_content' => 'sanitize_text_field',
642 'thinkrank_exclude_from_search' => 'sanitize_text_field',
643 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
644 ];
645
646 // Focus keywords: prefer the JSON array field; fall back to the legacy
647 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
648 // cap at MAX) and keeps the legacy single-value meta in sync.
649 if (isset($src['thinkrank_focus_keywords'])) {
650 $raw_keywords = $src['thinkrank_focus_keywords'];
651 $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords;
652 Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []);
653 } elseif (isset($src['thinkrank_focus_keyword'])) {
654 Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']);
655 }
656
657 // Update the post slug (post_name) when the metabox permalink field
658 // was edited. This touches the WP post itself, not post meta.
659 //
660 // The baseline is what the field was RENDERED with. Without it the
661 // guard here was a bare isset(), and the hidden input is always
662 // posted — so a user who edited WordPress's own permalink field in
663 // the Classic Editor had their new slug written by core and then
664 // overwritten by this page-load snapshot (#441).
665 if (isset($src['thinkrank_post_slug'])) {
666 $this->maybe_update_slug(
667 $post_id,
668 (string) $src['thinkrank_post_slug'],
669 isset($src['thinkrank_post_slug_baseline'])
670 ? (string) $src['thinkrank_post_slug_baseline']
671 : null
672 );
673 }
674
675 // Save canonical URL separately with URL sanitization
676 if (isset($src['thinkrank_canonical_url'])) {
677 $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']);
678 if (empty($canonical_url)) {
679 delete_post_meta($post_id, '_thinkrank_canonical_url');
680 } else {
681 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
682 }
683 }
684
685 $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
686
687 foreach ($fields as $field => $sanitize_callback) {
688 if (isset($src[$field])) {
689 $value = call_user_func($sanitize_callback, $src[$field]);
690 update_post_meta($post_id, "_{$field}", $value);
691 }
692 }
693
694 $this->save_robots_meta($post_id, $src);
695 $this->save_visibility_meta($post_id, $src);
696 $this->save_social_meta($post_id, $src);
697
698 // Update last modified timestamp
699 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
700 }
701
702 /**
703 * Persist one SEO text field with an out-of-band-write guard.
704 *
705 * Auto AI (on publish) and imports write the SEO title / meta description
706 * directly to post meta. When that happens after an editor
707 * was opened, the editor's hidden input is a stale blank; a normal save
708 * would overwrite the freshly generated value with that blank. This guard
709 * skips the write only when the submitted value is empty AND it was also
710 * empty when the form was rendered (the `<field>__orig` mirror), yet the
711 * stored value is now non-empty — i.e. a background writer won the race.
712 *
713 * A deliberate clear still applies: if the field held a value at render and
714 * is submitted empty, `$orig` is non-empty so the guard does not trigger.
715 * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
716 * paths) keep the plain write behavior.
717 *
718 * @param int $post_id Post being saved.
719 * @param array $src Unslashed field map.
720 * @param string $field POST field name (e.g. thinkrank_seo_title).
721 * @param string $meta_key Target post meta key.
722 * @param callable $sanitize Sanitizer applied to the submitted value.
723 * @return void
724 */
725 private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
726 if (!isset($src[$field])) {
727 return; // Field absent from this submit → leave the stored value untouched.
728 }
729
730 $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
731
732 if ('' === $submitted && isset($src[$field . '__orig'])) {
733 $orig = (string) $src[$field . '__orig'];
734 $stored = (string) get_post_meta($post_id, $meta_key, true);
735 // Blank now, blank at render, but populated in storage → a background
736 // write landed after this editor loaded; don't clobber it.
737 if ('' === $orig && '' !== $stored) {
738 return;
739 }
740 }
741
742 update_post_meta($post_id, $meta_key, $submitted);
743 }
744
745 /**
746 * Update the post slug (post_name) from the metabox permalink field.
747 *
748 * Runs inside the save_post cycle, so wp_update_post() would recurse — a
749 * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
750 * so a colliding slug is de-duplicated automatically. Empty input is left
751 * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are
752 * skipped so we don't fight the editor's own slug generation.
753 *
754 * @param int $post_id Post to update.
755 * @param string $raw_slug Desired slug from the metabox.
756 * @return void
757 */
758 private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void {
759 static $updating = false;
760 if ($updating) {
761 return;
762 }
763
764 $post = get_post($post_id);
765 if (!$post || wp_is_post_revision($post_id)) {
766 return;
767 }
768
769 if (in_array($post->post_status, ['auto-draft', 'trash'], true)) {
770 return;
771 }
772
773 $desired = sanitize_title($raw_slug);
774 if ($desired === '') {
775 return;
776 }
777
778 // Unchanged from what the form was rendered with, so the user did not
779 // choose this value — they left it alone. Writing it back would undo
780 // whatever core already saved from WordPress's own permalink field a
781 // moment ago, on the same save_post priority (#441).
782 //
783 // Compared against the BASELINE rather than the current post_name on
784 // purpose: by the time this runs core has already updated post_name,
785 // so that comparison cannot tell a deliberate edit from a stale one.
786 if ($baseline !== null && $desired === sanitize_title($baseline)) {
787 return;
788 }
789
790 if ($desired === $post->post_name) {
791 return;
792 }
793
794 $updating = true;
795 wp_update_post([
796 'ID' => $post_id,
797 'post_name' => $desired,
798 ]);
799 $updating = false;
800 }
801
802 /**
803 * Persist per-post Open Graph and Twitter Card overrides.
804 *
805 * Empty values delete the meta entry so the frontend falls back to the
806 * SEO title / meta description / featured image chain.
807 */
808 private function save_social_meta(int $post_id, array $src): void {
809 $text_fields = [
810 'thinkrank_og_title' => '_thinkrank_og_title',
811 'thinkrank_og_description' => '_thinkrank_og_description',
812 'thinkrank_twitter_title' => '_thinkrank_twitter_title',
813 'thinkrank_twitter_description' => '_thinkrank_twitter_description',
814 ];
815 foreach ($text_fields as $field => $meta_key) {
816 if (!isset($src[$field])) {
817 continue;
818 }
819 // Template fields: the frontend resolves their variable tags, so the
820 // %tokens% have to survive the save (#521).
821 $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]);
822 if ($value === '') {
823 delete_post_meta($post_id, $meta_key);
824 } else {
825 update_post_meta($post_id, $meta_key, $value);
826 }
827 }
828
829 $url_fields = [
830 'thinkrank_og_image' => '_thinkrank_og_image',
831 'thinkrank_twitter_image' => '_thinkrank_twitter_image',
832 ];
833 foreach ($url_fields as $field => $meta_key) {
834 if (!isset($src[$field])) {
835 continue;
836 }
837 $value = esc_url_raw((string) $src[$field]);
838 if ($value === '') {
839 delete_post_meta($post_id, $meta_key);
840 } else {
841 update_post_meta($post_id, $meta_key, $value);
842 }
843 }
844 }
845
846 /**
847 * Save per-post robots meta and advanced robots meta from the metabox.
848 *
849 * Stores the robots payloads as JSON-encoded strings, sanitized via
850 * sanitize_json_meta_field. The toggle plus the two JSON blobs are the
851 * single source of truth for per-post robots overrides.
852 */
853 private function save_robots_meta(int $post_id, array $src): void {
854 if (!isset($src['thinkrank_robots_meta_enabled'])) {
855 return;
856 }
857
858 $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled'];
859 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled);
860
861 if (isset($src['thinkrank_robots_meta'])) {
862 update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta']));
863 }
864
865 if (isset($src['thinkrank_advanced_robots_meta'])) {
866 update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta']));
867 }
868 }
869
870
871 /**
872 * Save the per-post listing-visibility switches.
873 *
874 * Stored as 1 or deleted rather than 1/0: the excluded set is read with a
875 * `meta_value = '1'` query, so a row holding 0 would be dead weight on every
876 * post anyone ever unticked. Deleting keeps the postmeta table proportional
877 * to the number of posts actually hidden.
878 *
879 * @since 2.7.0
880 *
881 * @param int $post_id Post being saved.
882 * @param array $src Submitted fields.
883 * @return void
884 */
885 private function save_visibility_meta(int $post_id, array $src): void {
886 $fields = [
887 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META,
888 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META,
889 ];
890
891 foreach ($fields as $field => $meta_key) {
892 if (!isset($src[$field])) {
893 continue;
894 }
895
896 if ((bool) $src[$field]) {
897 update_post_meta($post_id, $meta_key, 1);
898 } else {
899 delete_post_meta($post_id, $meta_key);
900 }
901 }
902
903 \ThinkRank\SEO\Content_Visibility::flush();
904 }
905
906 /**
907 * Enqueue meta box scripts
908 *
909 * @param string $hook Current admin page hook
910 * @return void
911 */
912 public function enqueue_metabox_scripts(string $hook): void {
913 // Only load on post edit screens (including block editor)
914 if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
915 return;
916 }
917
918 // Get current post type - handle both classic and block editor contexts
919 $current_post_type = $this->get_current_post_type();
920 if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
921 return;
922 }
923
924 // Get post object for additional data
925 global $post;
926
927 // Ensure wp.media is available for the social-image media picker.
928 wp_enqueue_media();
929
930 // No chunk dependencies needed - all bundled into main metabox.js
931 // Enqueue React metabox script with direct dependencies
932 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
933 $asset = file_exists($asset_file) ? include $asset_file : [
934 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
935 'version' => THINKRANK_VERSION
936 ];
937
938 // Use dependencies directly from the asset file. The pinned "Configure
939 // SEO" launcher needs wp-plugins (already listed by the build) and
940 // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do
941 // NOT add wp-interface here: it is not a registered script handle on
942 // WP 7.x, and an unmet dependency would drop the whole metabox script.
943 $dependencies = $asset['dependencies'];
944
945 wp_enqueue_script(
946 'thinkrank-metabox',
947 THINKRANK_PLUGIN_URL . 'assets/metabox.js',
948 $dependencies,
949 $asset['version'],
950 true
951 );
952
953 // Localize script data (shared builder; reused by the Elementor editor
954 // integration, which has no #post form / hidden inputs of its own).
955 wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID));
956
957 // Add defer attribute for non-blocking script loading
958 wp_script_add_data('thinkrank-metabox', 'defer', true);
959
960 // Enqueue metabox styles
961 wp_enqueue_style(
962 'thinkrank-metabox',
963 THINKRANK_PLUGIN_URL . 'assets/metabox.css',
964 ['wp-components'],
965 THINKRANK_VERSION
966 );
967
968 // Classic Editor sidebar widget: lightweight styles + a vanilla-JS
969 // handler so the "Optimize Here" button works without depending on the
970 // React bundle. The box itself is only registered in the Classic Editor.
971 $sidebar_css = <<<'CSS'
972 .thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;}
973 .thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;}
974 CSS;
975 wp_add_inline_style('thinkrank-metabox', $sidebar_css);
976
977 $sidebar_js = <<<'JS'
978 (function(){
979 document.addEventListener('click', function(e){
980 var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta');
981 if(!btn){return;}
982 e.preventDefault();
983 // Open the ThinkRank SEO drawer mounted by the React metabox app.
984 window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer'));
985 });
986 })();
987 JS;
988 wp_add_inline_script('thinkrank-metabox', $sidebar_js);
989 }
990
991 /**
992 * Build the data object localized into the metabox script (`thinkrankMetabox`).
993 *
994 * Extracted so the Elementor editor integration can reuse the exact same
995 * configuration. Callers that run outside the #post form (Elementor) also
996 * read `existingMetadata`/`contentPreview`, which they add on top of this.
997 *
998 * @param int $post_id Post being edited.
999 * @return array Localized config consumed by the React metabox.
1000 */
1001 public function get_localized_data(int $post_id): array {
1002 $post = get_post($post_id);
1003 $post_type = $post ? $post->post_type : 'post';
1004
1005 // Resolve the correct REST API base for the current post type.
1006 // Falls back to the post type slug for any type without a rest_base.
1007 $post_type_obj = get_post_type_object($post_type);
1008 $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base))
1009 ? $post_type_obj->rest_base
1010 : $post_type;
1011
1012 return [
1013 'ajaxUrl' => admin_url('admin-ajax.php'),
1014 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
1015 'postId' => $post_id,
1016 'postType' => $post_type,
1017 'postRestBase' => $post_rest_base,
1018 'postPermalink' => get_permalink($post_id),
1019 'postSlug' => $post ? $post->post_name : '',
1020 'restUrl' => rest_url('thinkrank/v1/'),
1021 'restNonce' => wp_create_nonce('wp_rest'),
1022 'homeUrl' => home_url(),
1023 'siteName' => get_bloginfo('name'),
1024 'faviconUrl' => $this->get_site_favicon_url(),
1025 'featuredImageUrl' => $this->get_post_featured_image_url($post_id),
1026 'strings' => [
1027 'generating' => __('Generating...', 'thinkrank'),
1028 'analyzing' => __('Analyzing...', 'thinkrank'),
1029 'error' => __('Error occurred', 'thinkrank'),
1030 'success' => __('Success!', 'thinkrank'),
1031 'generated' => __('Metadata generated successfully', 'thinkrank'),
1032 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
1033 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
1034 ],
1035 'seoScore' => $this->get_persisted_seo_score($post_id),
1036 'postModified' => $post ? get_the_modified_date('c', $post) : '',
1037 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
1038 'postStatus' => get_post_status($post_id),
1039 'isPro' => Plan_Config::is_pro(),
1040 // Whether a provider (Pro's Redirections feature) can actually store
1041 // a redirect. False renders the field as an upsell rather than an
1042 // input that accepts text nothing will ever act on.
1043 'redirectSupported' => Object_Redirect::is_supported(),
1044 'redirectTypes' => Object_Redirect::TYPES,
1045 /**
1046 * Filter the editor SEO panel's post-load refresh behaviour.
1047 *
1048 * The panel re-checks `/metadata/{id}` after load so values written
1049 * by a background writer (Auto AI on publish, imports) appear
1050 * without a reload. It only polls while the server
1051 * reports a write in flight, but the poll lives in JavaScript, so
1052 * the switch has to be localized into the bundle rather than being
1053 * a PHP-side filter alone (#329).
1054 *
1055 * Set `enabled` to false to switch the refresh off entirely.
1056 *
1057 * @since 1.30.0
1058 *
1059 * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
1060 * @param int $post_id Post being edited.
1061 */
1062 'seoRefresh' => apply_filters(
1063 'thinkrank_metabox_seo_refresh',
1064 [
1065 'enabled' => true,
1066 'intervalMs' => 4000,
1067 'maxTicks' => 10,
1068 ],
1069 $post_id
1070 ),
1071 // Whether the selected AI provider is configured — gates the
1072 // "Generate with AI" button in the metabox. An OpenAI-compatible
1073 // endpoint counts once it has a base URL and a model id, with or
1074 // without a key (#721).
1075 'aiConfigured' => $this->settings->has_ai_provider_configured(),
1076 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
1077 // as placeholder previews when a field is empty (the frontend applies
1078 // these same patterns on output). Typing a value overrides them.
1079 'patternPreviews' => Pattern_Resolver::previews($post_id),
1080 // Token => value map (keys without %), for live client-side preview of
1081 // a custom pattern typed into a metabox field.
1082 'patternVariables' => Pattern_Resolver::variables($post_id),
1083 // The uncapped copy the Content Analysis panel measures. Localized
1084 // here so all six editors get it, rather than each builder adding
1085 // its own (#778). `contentPreview` stays as the AI budget.
1086 'contentFull' => $post instanceof \WP_Post ? $this->get_analysis_content($post) : '',
1087 ];
1088 }
1089
1090 /**
1091 * Latest persisted SEO score for a post.
1092 *
1093 * Read from the scores table — the same source the posts list column and the
1094 * Analysis panel use — so the editor badge agrees with them on load rather
1095 * than showing 0 until the Analysis panel mounts and fetches the score.
1096 *
1097 * The `thinkrank_seo_score` form field is unusable for this: it is only
1098 * written by the AI "Analyze Content" flow and is reset to 0 on every save.
1099 *
1100 * @param int $post_id Post ID
1101 * @return int|null Score, or null when the post has never been analyzed.
1102 */
1103 private function get_persisted_seo_score(int $post_id): ?int {
1104 $existing = $this->seo_calculator->get_existing_score_data($post_id);
1105 $score = $existing['overall_score'] ?? null;
1106
1107 return $score !== null ? (int) $score : null;
1108 }
1109
1110 /**
1111 * Check if link suggestions are enabled for a post type
1112 *
1113 * @param string $post_type Post type to check
1114 * @return bool True if enabled, false otherwise
1115 */
1116 private function is_link_suggestions_enabled(string $post_type): bool {
1117 $settings = get_option('thinkrank_global_seo_settings', []);
1118
1119 if (isset($settings[$post_type]['link_suggestions'])) {
1120 return (bool) $settings[$post_type]['link_suggestions'];
1121 }
1122
1123 return true;
1124 }
1125
1126 /**
1127 * Get current post type in admin context
1128 *
1129 * Handles both classic editor and block editor contexts
1130 *
1131 * @return string|null Current post type or null if not found
1132 */
1133 private function get_current_post_type(): ?string {
1134 global $post, $typenow, $current_screen;
1135
1136 // Try to get post type from various sources
1137 if ($post && !empty($post->post_type)) {
1138 return $post->post_type;
1139 }
1140
1141 if (!empty($typenow)) {
1142 return $typenow;
1143 }
1144
1145 if ($current_screen && !empty($current_screen->post_type)) {
1146 return $current_screen->post_type;
1147 }
1148
1149 // Fallback: check URL parameters for block editor
1150 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1151 if (isset($_GET['post_type'])) {
1152 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1153 return sanitize_text_field(wp_unslash($_GET['post_type']));
1154 }
1155
1156 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1157 if (isset($_GET['post'])) {
1158 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1159 $post_id = absint($_GET['post']);
1160 $post_type = get_post_type($post_id);
1161 if ($post_type) {
1162 return $post_type;
1163 }
1164 }
1165
1166 return null;
1167 }
1168
1169 /**
1170 * Get supported post types
1171 *
1172 * @return array Supported post types
1173 */
1174 public function get_supported_post_types(): array {
1175 $default_types = ['post', 'page'];
1176
1177 // Add WooCommerce product if available
1178 if (class_exists('WooCommerce')) {
1179 $default_types[] = 'product';
1180 }
1181
1182 // Add other common e-commerce post types
1183 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1184 foreach ($ecommerce_types as $type) {
1185 if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1186 $default_types[] = $type;
1187 }
1188 }
1189
1190 // Add custom post types that are public and have UI
1191 $custom_post_types = get_post_types([
1192 'public' => true,
1193 'show_ui' => true,
1194 '_builtin' => false,
1195 ]);
1196
1197 // WordPress internals that should never carry an SEO metabox. Fixed,
1198 // so it is built once rather than per post type.
1199 $wp_internal_types = [
1200 'attachment',
1201 'revision',
1202 'nav_menu_item',
1203 'custom_css',
1204 'customize_changeset',
1205 'oembed_cache',
1206 'user_request',
1207 'wp_block',
1208 'wp_template',
1209 'wp_template_part',
1210 'wp_global_styles',
1211 'wp_navigation',
1212 'acf-field',
1213 'acf-field-group',
1214 ];
1215
1216 // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1217 // layout fragments, not pages with their own SEO. Global SEO already
1218 // refuses them; this list is shared with that policy so the two cannot
1219 // drift apart again (#621).
1220 if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1221 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1222 }
1223
1224 foreach ($custom_post_types as $post_type) {
1225 // Resolved per post type, not hoisted: the shared list runs through
1226 // a public filter that receives the post-type object, so an
1227 // integrator can answer differently for different post types.
1228 $excluded_types = array_merge(
1229 $wp_internal_types,
1230 \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1231 );
1232
1233 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1234 $default_types[] = $post_type;
1235 }
1236 }
1237
1238 return apply_filters('thinkrank_supported_post_types', $default_types);
1239 }
1240
1241 /**
1242 * Transient holding the last redirect error for the current user.
1243 */
1244 private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1245
1246 /**
1247 * Why the redirect field was refused on the most recent persist, if it was.
1248 *
1249 * @var \WP_Error|null
1250 */
1251 private ?\WP_Error $last_redirect_error = null;
1252
1253 /**
1254 * Persist the edit-screen redirect field.
1255 *
1256 * Absent keys are left alone, so a caller that never rendered the field
1257 * (the AJAX save from an editor that submits a subset, an import) cannot
1258 * clear a redirect by omission.
1259 *
1260 * The destination is not post meta — Pro's rules table holds it — so unlike
1261 * every other field here this save can fail for reasons the editor needs to
1262 * hear about: no Pro, plain permalinks, a destination that is the page's own
1263 * URL. Failing silently would be the worst of both, since the field would
1264 * redisplay empty on the next load with no explanation, so the reason is
1265 * stashed for the notice rendered on the next screen.
1266 *
1267 * @param string $object_type 'post' or 'term'.
1268 * @param int $object_id Object ID.
1269 * @param array $src Field name => raw value map.
1270 * @return void
1271 */
1272 private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1273 if (!array_key_exists('thinkrank_redirect_url', $src)) {
1274 return null;
1275 }
1276
1277 $url = (string) $src['thinkrank_redirect_url'];
1278
1279 // With no provider there is nothing to store and nothing to clear.
1280 // Staying quiet when the field was submitted empty keeps every ordinary
1281 // save on a free site from raising an error about a field the editor
1282 // never touched.
1283 if (!Object_Redirect::is_supported()) {
1284 if ('' !== trim($url)) {
1285 return new \WP_Error(
1286 'thinkrank_redirect_unsupported',
1287 __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1288 );
1289 }
1290 return null;
1291 }
1292
1293 $type = array_key_exists('thinkrank_redirect_type', $src)
1294 ? $src['thinkrank_redirect_type']
1295 : Object_Redirect::DEFAULT_TYPE;
1296
1297 $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1298
1299 return is_wp_error($result) ? $result : null;
1300 }
1301
1302 /**
1303 * Why the last persist_metadata() call could not store the redirect.
1304 *
1305 * Every other metabox field either saves or is sanitized into something
1306 * that does; this one can be refused, and each caller reports that
1307 * differently — a notice for the form post, a JSON field for the AJAX save,
1308 * an error message for the MCP ability.
1309 *
1310 * @return \WP_Error|null
1311 */
1312 public function get_last_redirect_error(): ?\WP_Error {
1313 return $this->last_redirect_error;
1314 }
1315
1316 /**
1317 * Remember why a redirect could not be saved, for the next admin screen.
1318 *
1319 * @param \WP_Error $error Failure.
1320 * @return void
1321 */
1322 private function store_redirect_error(\WP_Error $error): void {
1323 $user_id = get_current_user_id();
1324 if ($user_id <= 0) {
1325 return;
1326 }
1327
1328 set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1329 }
1330
1331 /**
1332 * Show, once, why the last redirect save failed.
1333 *
1334 * @return void
1335 */
1336 public function render_redirect_notice(): void {
1337 $user_id = get_current_user_id();
1338 if ($user_id <= 0) {
1339 return;
1340 }
1341
1342 $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1343 $message = get_transient($key);
1344
1345 if (!is_string($message) || '' === $message) {
1346 return;
1347 }
1348
1349 delete_transient($key);
1350
1351 printf(
1352 '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1353 esc_html(
1354 sprintf(
1355 /* translators: %s: reason the redirect was not saved. */
1356 __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1357 $message
1358 )
1359 )
1360 );
1361 }
1362
1363 /**
1364 * Get existing post metadata
1365 *
1366 * @param int $post_id Post ID
1367 * @return array Existing metadata
1368 */
1369 public function get_post_metadata(int $post_id): array {
1370 // One lookup: get() goes through a filter Pro answers from the database.
1371 $redirect = Object_Redirect::get('post', $post_id);
1372
1373 return [
1374 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1375 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1376 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1377 'focus_keywords' => Focus_Keywords::get($post_id),
1378 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1379 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1380 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1381 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1382 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1383 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1384 // Not post meta: the rule in Pro's redirections table is the value.
1385 // See ThinkRank\SEO\Object_Redirect.
1386 'redirect_url' => $redirect['url'],
1387 'redirect_type' => $redirect['type'],
1388 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1389 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1390 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1391 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1392 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1393 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1394 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1395 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1396 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
1397 ];
1398 }
1399
1400 /**
1401 * Get site favicon URL
1402 *
1403 * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1404 * configured does it probe common favicon locations — and that probe is
1405 * cached (including a "no favicon" sentinel) so the editor never fires
1406 * blocking loopback HEAD requests on every load.
1407 *
1408 * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1409 * @return string Favicon URL, or '' when none can be resolved.
1410 */
1411 private function get_site_favicon_url(): string {
1412 // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1413 $site_icon_url = get_site_icon_url();
1414 if ($site_icon_url) {
1415 return $site_icon_url;
1416 }
1417
1418 // No Site Icon configured: fall back to probing common favicon paths.
1419 // Cache the resolved value (empty string included) so the blocking
1420 // loopback probe runs at most once per ~12h per site host instead of on
1421 // every editor load.
1422 $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1423 $cached = get_transient($cache_key);
1424 if (false !== $cached) {
1425 return (string) $cached;
1426 }
1427
1428 $favicon_url = '';
1429
1430 $favicon_paths = [
1431 '/favicon.ico',
1432 '/favicon.png',
1433 '/apple-touch-icon.png',
1434 ];
1435
1436 foreach ($favicon_paths as $path) {
1437 $candidate = home_url($path);
1438 $response = wp_remote_head($candidate, ['timeout' => 3]);
1439 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1440 $favicon_url = $candidate;
1441 break;
1442 }
1443 }
1444
1445 set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1446
1447 return $favicon_url;
1448 }
1449
1450 /**
1451 * Get post featured image URL
1452 *
1453 * @param int $post_id Post ID
1454 * @return string|null Featured image URL or null if not available
1455 */
1456 private function get_post_featured_image_url(int $post_id): ?string {
1457 $thumbnail_id = get_post_thumbnail_id($post_id);
1458 if ($thumbnail_id) {
1459 // Use the full-size image — social share images need a large source
1460 // (the preview scales it down), not a small thumbnail.
1461 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1462 return $image_url ?: null;
1463 }
1464 return null;
1465 }
1466
1467 /**
1468 * Characters of page text an AI caller is given.
1469 *
1470 * A prompt budget, not a description of the page — one caller forwards this
1471 * straight into an AI payload, so it stays capped.
1472 *
1473 * @since 2.10.0
1474 * @var int
1475 */
1476 private const AI_PREVIEW_LENGTH = 4000;
1477
1478 /**
1479 * Safety ceiling for the analysis copy.
1480 *
1481 * High enough that no real article reaches it, low enough that a runaway
1482 * builder tree cannot put a megabyte of text through wp_localize_script
1483 * into every editor page load.
1484 *
1485 * @since 2.10.0
1486 * @var int
1487 */
1488 private const ANALYSIS_MAX_LENGTH = 200000;
1489
1490 /**
1491 * Resolved body text per post, for this request.
1492 *
1493 * Every builder integration localizes get_localized_data() (which carries
1494 * the analysis copy) and then adds get_content_preview() on top, and the
1495 * classic screen renders the preview into the form as well as localizing
1496 * the analysis copy. Each of those resolved the post through
1497 * Builder_Content from scratch, so a builder page walked its whole tree
1498 * twice on every editor load. Both now derive from one resolution.
1499 *
1500 * Keyed on the post's ID and modified time, so a post saved and re-read
1501 * within the same request is resolved again.
1502 *
1503 * @since 2.10.0
1504 * @var array<string,string>
1505 */
1506 private array $resolved_bodies = [];
1507
1508 /**
1509 * The post's body text, resolved through whatever built it.
1510 *
1511 * Body only. The title and excerpt used to be prepended here, which is
1512 * right for an AI prompt but wrong for a measurement: the Analysis panel
1513 * counted them as body copy, so Word Count and Keyword Density in a
1514 * builder included the title and excerpt, while the same panel in the
1515 * block editor (which reads the editor's content) did not. They are added
1516 * back for the AI preview only.
1517 *
1518 * @since 2.10.0
1519 *
1520 * @param \WP_Post $post Post object.
1521 * @return string Plain text, whitespace collapsed, uncapped.
1522 */
1523 private function resolve_post_body(\WP_Post $post): string {
1524 $key = $post->ID . '|' . (string) ($post->post_modified_gmt ?? '');
1525
1526 if (isset($this->resolved_bodies[$key])) {
1527 return $this->resolved_bodies[$key];
1528 }
1529
1530 // Resolve through Builder_Content: a page builder keeps its words
1531 // outside post_content (Oxygen empties it entirely), and the editor
1532 // cannot reach postmeta, so without this the preview handed to the
1533 // browser is just the title and the live panel reports "No content".
1534 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1535 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1536 }
1537
1538 $this->resolved_bodies[$key] = self::collapse_text(\ThinkRank\SEO\Builder_Content::resolve($post));
1539
1540 return $this->resolved_bodies[$key];
1541 }
1542
1543 /**
1544 * Strip tags and collapse whitespace.
1545 *
1546 * @since 2.10.0
1547 *
1548 * @param string $content Markup or text.
1549 * @return string
1550 */
1551 private static function collapse_text(string $content): string {
1552 $content = wp_strip_all_tags($content);
1553
1554 return trim((string) preg_replace('/\s+/', ' ', $content));
1555 }
1556
1557 /**
1558 * The post's text for the editor's Content Analysis panel.
1559 *
1560 * Uncapped, because this is a measurement rather than a budget. In a page
1561 * builder there is no `core/editor` store, no TinyMCE instance and no
1562 * `#content` textarea, so the localized string is the ONLY thing the panel
1563 * can count — and it was being handed the 4,000-character AI preview. Word
1564 * Count, Keyword Density, Readability and Content Quality therefore
1565 * described the first 4,000 characters and nothing after, freezing at
1566 * roughly 600-700 words however long the page grew (#778).
1567 *
1568 * Still bounded, at a ceiling no real article reaches: this rides along on
1569 * every editor page load.
1570 *
1571 * The body only, like the editor content it stands in for; the title and
1572 * excerpt belong to the AI preview (see resolve_post_body()).
1573 *
1574 * @since 2.10.0
1575 *
1576 * @param \WP_Post $post Post object.
1577 * @return string
1578 */
1579 public function get_analysis_content(\WP_Post $post): string {
1580 return \ThinkRank\Core\Seo_Text::trim_to_length(
1581 $this->resolve_post_body($post),
1582 self::ANALYSIS_MAX_LENGTH
1583 );
1584 }
1585
1586 /**
1587 * Get content preview for AI analysis
1588 *
1589 * @param \WP_Post $post Post object
1590 * @return string Content preview
1591 */
1592 public function get_content_preview(\WP_Post $post): string {
1593 // The title and excerpt lead the preview: they are context an AI
1594 // caller wants first, and they are not part of the measured body.
1595 $lead = $post->post_title . "\n\n";
1596
1597 if (!empty($post->post_excerpt)) {
1598 $lead .= $post->post_excerpt . "\n\n";
1599 }
1600
1601 $preview = trim(self::collapse_text($lead) . ' ' . $this->resolve_post_body($post));
1602
1603 // substr() counts BYTES: on Thai or CJK this handed the model a third
1604 // of the intended content and cut the last character in half (#687).
1605 return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH);
1606 }
1607
1608 /**
1609 * AJAX handler for generating post metadata
1610 *
1611 * @return void
1612 */
1613 public function ajax_generate_post_metadata(): void {
1614 // Verify nonce
1615 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1616 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1617 wp_die('Security check failed');
1618 }
1619
1620 // Check permissions
1621 $post_id = absint($_POST['post_id'] ?? 0);
1622 if (!current_user_can('edit_post', $post_id)) {
1623 wp_die('Insufficient permissions');
1624 }
1625
1626 try {
1627 $options = [
1628 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1629 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1630 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1631 ];
1632
1633 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
1634
1635 wp_send_json_success([
1636 'metadata' => $metadata,
1637 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1638 ]);
1639 } catch (\Exception $e) {
1640 wp_send_json_error([
1641 'message' => $e->getMessage(),
1642 ]);
1643 }
1644 }
1645
1646 /**
1647 * AJAX: persist the full set of metabox fields for a post.
1648 *
1649 * Used by editors that don't submit the #post form (Elementor). Expects the
1650 * same field names the classic/block metabox submits, sent as POST params.
1651 * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1652 *
1653 * @return void
1654 */
1655 public function ajax_save_metabox(): void {
1656 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1657 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1658 wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
1659 }
1660
1661 $post_id = absint($_POST['post_id'] ?? 0);
1662 if (!$post_id || !current_user_can('edit_post', $post_id)) {
1663 wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
1664 }
1665
1666 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1667 $this->persist_metadata($post_id, wp_unslash($_POST));
1668
1669 // Everything else saved; only the redirect can have been refused. Report
1670 // it in this response rather than as a notice on some later screen —
1671 // this caller never reloads the page.
1672 $redirect_error = $this->get_last_redirect_error();
1673 if (null !== $redirect_error) {
1674 wp_send_json_error([
1675 'message' => sprintf(
1676 /* translators: %s: reason the redirect was not saved. */
1677 __('Saved, except the redirect: %s', 'thinkrank'),
1678 $redirect_error->get_error_message()
1679 ),
1680 ], 400);
1681 }
1682
1683 wp_send_json_success([
1684 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1685 ]);
1686 }
1687 }
1688