PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/admin/class-metabox-manager.php +392 -34 1.27.0 → 2.7.0 View file →
@@ -18,8 +18,9 @@
18 18 use ThinkRank\Core\Settings;
19 19 use ThinkRank\Core\Database;
20 20 use ThinkRank\Core\Plan_Config;
21 21 use ThinkRank\SEO\Focus_Keywords;
22 +use ThinkRank\SEO\Object_Redirect;
22 23 use ThinkRank\SEO\Pattern_Resolver;
23 24
24 25 // Prevent direct access
25 26 if (!defined('ABSPATH')) {
@@ -77,8 +78,9 @@
77 78 public function init(): void {
78 79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
79 80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
80 81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 + add_action('admin_notices', [$this, 'render_redirect_notice']);
81 83 add_action('init', [$this, 'register_meta_fields']);
82 84
83 85 // AJAX handlers for meta box functionality
84 86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
@@ -206,8 +208,26 @@
206 208 return current_user_can('edit_posts') || current_user_can('edit_pages');
207 209 }
208 210 ]);
209 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 +
210 230 register_post_meta('', '_thinkrank_primary_category', [
211 231 'show_in_rest' => true,
212 232 'single' => true,
213 233 'type' => 'integer',
@@ -258,9 +278,9 @@
258 278 * @param mixed $data Data to sanitize
259 279 * @param int $depth Current recursion depth
260 280 * @return mixed Sanitized data
261 281 */
262 - private function sanitize_json_recursively($data, int $depth = 0): mixed {
282 + private function sanitize_json_recursively($data, int $depth = 0) {
263 283 // Prevent deep recursion attacks
264 284 if ($depth > 10) {
265 285 return null;
266 286 }
@@ -337,9 +357,9 @@
337 357 * @param int $depth Current recursion depth
338 358 *
339 359 * @return mixed Sanitized data
340 360 */
341 - private function sanitize_json_ld_recursively( $data, int $depth = 0 ): mixed {
361 + private function sanitize_json_ld_recursively( $data, int $depth = 0 ) {
342 362 // Prevent deep recursion attacks
343 363 if ( $depth > 10 ) {
344 364 return null;
345 365 }
@@ -475,14 +495,30 @@
475 495
476 496 <!-- Hidden form fields for React to read initial data -->
477 497 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
478 498 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
499 + <?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. ?>
500 + <input type="hidden" id="thinkrank_seo_title__orig" name="thinkrank_seo_title__orig" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
501 + <input type="hidden" id="thinkrank_meta_description__orig" name="thinkrank_meta_description__orig" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
479 502 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
480 503 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
481 504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
482 505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
483 506 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
507 + <input type="hidden" id="thinkrank_exclude_from_search" name="thinkrank_exclude_from_search" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_search'] ?? '')); ?>" />
508 + <input type="hidden" id="thinkrank_exclude_from_archives" name="thinkrank_exclude_from_archives" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_archives'] ?? '')); ?>" />
484 509 <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
510 + <?php
511 + // The redirect lives in Pro's rules table, not post meta, so nothing
512 + // else hands it to the React app. Without these the field loads
513 + // empty, and its own hidden input then posts that empty value on the
514 + // next save, which Object_Redirect reads as "remove the redirect".
515 + // Rendered only when a provider can store it, matching MetaboxApp.
516 + if (Object_Redirect::is_supported()) :
517 + ?>
518 + <input type="hidden" id="thinkrank_redirect_url" name="thinkrank_redirect_url" value="<?php echo esc_attr((string) ($existing_metadata['redirect_url'] ?? '')); ?>" />
519 + <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)); ?>" />
520 + <?php endif; ?>
485 521 <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')); ?>" />
486 522 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
487 523 <input type="hidden" id="thinkrank_advanced_robots_meta" name="thinkrank_advanced_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['advanced_robots_meta'] ?? '')); ?>" />
488 524 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
@@ -529,8 +565,16 @@
529 565 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
530 566 // the shared persistence routine.
531 567 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
532 568 $this->persist_metadata($post_id, wp_unslash($_POST));
569 +
570 + // The redirect is the one field here that can be refused outright. The
571 + // response to this request is a redirect back to the editor, so the
572 + // reason has to survive one page load to be seen at all.
573 + $redirect_error = $this->get_last_redirect_error();
574 + if (null !== $redirect_error) {
575 + $this->store_redirect_error($redirect_error);
576 + }
533 577 }
534 578
535 579 /**
536 580 * Persist metabox fields for a post from a form-field-name => value map,
@@ -563,16 +607,26 @@
563 607 * @param array $src Field name => raw value map (unslashed).
564 608 * @return void
565 609 */
566 610 private function persist_metadata(int $post_id, array $src): void {
567 - // Save metadata. Focus keywords are handled separately (array meta) via
568 - // Focus_Keywords below, so they are intentionally absent from this list.
611 + // Title & meta description are handled separately below: they can be
612 + // written out-of-band (Auto AI on publish, imports)
613 + // after an editor was opened, so a plain save from that now-stale editor
614 + // would clobber the generated value with a blank. Focus keywords are
615 + // likewise handled separately (array meta) via Focus_Keywords below.
616 + //
617 + // Both fields may hold variable tags, so they are sanitized as templates:
618 + // sanitize_text_field()/sanitize_textarea_field() strip %date% and
619 + // %category% as percent-encoding and store "te%" / "tegory%" (#521).
620 + $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']);
621 + $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']);
622 +
569 623 $fields = [
570 - 'thinkrank_seo_title' => 'sanitize_text_field',
571 - 'thinkrank_meta_description' => 'sanitize_textarea_field',
572 624 'thinkrank_seo_score' => 'absint',
573 625 'thinkrank_generated_at' => 'sanitize_text_field',
574 626 'thinkrank_pillar_content' => 'sanitize_text_field',
627 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
575 629 ];
576 630
577 631 // Focus keywords: prefer the JSON array field; fall back to the legacy
578 632 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
@@ -586,10 +640,22 @@
586 640 }
587 641
588 642 // Update the post slug (post_name) when the metabox permalink field
589 643 // was edited. This touches the WP post itself, not post meta.
644 + //
645 + // The baseline is what the field was RENDERED with. Without it the
646 + // guard here was a bare isset(), and the hidden input is always
647 + // posted — so a user who edited WordPress's own permalink field in
648 + // the Classic Editor had their new slug written by core and then
649 + // overwritten by this page-load snapshot (#441).
590 650 if (isset($src['thinkrank_post_slug'])) {
591 - $this->maybe_update_slug($post_id, (string) $src['thinkrank_post_slug']);
651 + $this->maybe_update_slug(
652 + $post_id,
653 + (string) $src['thinkrank_post_slug'],
654 + isset($src['thinkrank_post_slug_baseline'])
655 + ? (string) $src['thinkrank_post_slug_baseline']
656 + : null
657 + );
592 658 }
593 659
594 660 // Save canonical URL separately with URL sanitization
595 661 if (isset($src['thinkrank_canonical_url'])) {
@@ -600,8 +666,10 @@
600 666 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
601 667 }
602 668 }
603 669
670 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671 +
604 672 foreach ($fields as $field => $sanitize_callback) {
605 673 if (isset($src[$field])) {
606 674 $value = call_user_func($sanitize_callback, $src[$field]);
607 675 update_post_meta($post_id, "_{$field}", $value);
@@ -608,8 +676,9 @@
608 676 }
609 677 }
610 678
611 679 $this->save_robots_meta($post_id, $src);
680 + $this->save_visibility_meta($post_id, $src);
612 681 $this->save_social_meta($post_id, $src);
613 682
614 683 // Update last modified timestamp
615 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
@@ -615,8 +684,51 @@
615 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
616 685 }
617 686
618 687 /**
688 + * Persist one SEO text field with an out-of-band-write guard.
689 + *
690 + * Auto AI (on publish) and imports write the SEO title / meta description
691 + * directly to post meta. When that happens after an editor
692 + * was opened, the editor's hidden input is a stale blank; a normal save
693 + * would overwrite the freshly generated value with that blank. This guard
694 + * skips the write only when the submitted value is empty AND it was also
695 + * empty when the form was rendered (the `<field>__orig` mirror), yet the
696 + * stored value is now non-empty — i.e. a background writer won the race.
697 + *
698 + * A deliberate clear still applies: if the field held a value at render and
699 + * is submitted empty, `$orig` is non-empty so the guard does not trigger.
700 + * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
701 + * paths) keep the plain write behavior.
702 + *
703 + * @param int $post_id Post being saved.
704 + * @param array $src Unslashed field map.
705 + * @param string $field POST field name (e.g. thinkrank_seo_title).
706 + * @param string $meta_key Target post meta key.
707 + * @param callable $sanitize Sanitizer applied to the submitted value.
708 + * @return void
709 + */
710 + private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
711 + if (!isset($src[$field])) {
712 + return; // Field absent from this submit → leave the stored value untouched.
713 + }
714 +
715 + $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
716 +
717 + if ('' === $submitted && isset($src[$field . '__orig'])) {
718 + $orig = (string) $src[$field . '__orig'];
719 + $stored = (string) get_post_meta($post_id, $meta_key, true);
720 + // Blank now, blank at render, but populated in storage → a background
721 + // write landed after this editor loaded; don't clobber it.
722 + if ('' === $orig && '' !== $stored) {
723 + return;
724 + }
725 + }
726 +
727 + update_post_meta($post_id, $meta_key, $submitted);
728 + }
729 +
730 + /**
619 731 * Update the post slug (post_name) from the metabox permalink field.
620 732 *
621 733 * Runs inside the save_post cycle, so wp_update_post() would recurse — a
622 734 * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
@@ -627,9 +739,9 @@
627 739 * @param int $post_id Post to update.
628 740 * @param string $raw_slug Desired slug from the metabox.
629 741 * @return void
630 742 */
631 - private function maybe_update_slug(int $post_id, string $raw_slug): void {
743 + private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void {
632 744 static $updating = false;
633 745 if ($updating) {
634 746 return;
635 747 }
@@ -643,12 +755,28 @@
643 755 return;
644 756 }
645 757
646 758 $desired = sanitize_title($raw_slug);
647 - if ($desired === '' || $desired === $post->post_name) {
759 + if ($desired === '') {
648 760 return;
649 761 }
650 762
763 + // Unchanged from what the form was rendered with, so the user did not
764 + // choose this value — they left it alone. Writing it back would undo
765 + // whatever core already saved from WordPress's own permalink field a
766 + // moment ago, on the same save_post priority (#441).
767 + //
768 + // Compared against the BASELINE rather than the current post_name on
769 + // purpose: by the time this runs core has already updated post_name,
770 + // so that comparison cannot tell a deliberate edit from a stale one.
771 + if ($baseline !== null && $desired === sanitize_title($baseline)) {
772 + return;
773 + }
774 +
775 + if ($desired === $post->post_name) {
776 + return;
777 + }
778 +
651 779 $updating = true;
652 780 wp_update_post([
653 781 'ID' => $post_id,
654 782 'post_name' => $desired,
@@ -672,9 +800,11 @@
672 800 foreach ($text_fields as $field => $meta_key) {
673 801 if (!isset($src[$field])) {
674 802 continue;
675 803 }
676 - $value = sanitize_textarea_field((string) $src[$field]);
804 + // Template fields: the frontend resolves their variable tags, so the
805 + // %tokens% have to survive the save (#521).
806 + $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]);
677 807 if ($value === '') {
678 808 delete_post_meta($post_id, $meta_key);
679 809 } else {
680 810 update_post_meta($post_id, $meta_key, $value);
@@ -723,8 +853,43 @@
723 853 }
724 854
725 855
726 856 /**
857 + * Save the per-post listing-visibility switches.
858 + *
859 + * Stored as 1 or deleted rather than 1/0: the excluded set is read with a
860 + * `meta_value = '1'` query, so a row holding 0 would be dead weight on every
861 + * post anyone ever unticked. Deleting keeps the postmeta table proportional
862 + * to the number of posts actually hidden.
863 + *
864 + * @since 2.7.0
865 + *
866 + * @param int $post_id Post being saved.
867 + * @param array $src Submitted fields.
868 + * @return void
869 + */
870 + private function save_visibility_meta(int $post_id, array $src): void {
871 + $fields = [
872 + 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META,
873 + 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META,
874 + ];
875 +
876 + foreach ($fields as $field => $meta_key) {
877 + if (!isset($src[$field])) {
878 + continue;
879 + }
880 +
881 + if ((bool) $src[$field]) {
882 + update_post_meta($post_id, $meta_key, 1);
883 + } else {
884 + delete_post_meta($post_id, $meta_key);
885 + }
886 + }
887 +
888 + \ThinkRank\SEO\Content_Visibility::flush();
889 + }
890 +
891 + /**
727 892 * Enqueue meta box scripts
728 893 *
729 894 * @param string $hook Current admin page hook
730 895 * @return void
@@ -730,15 +895,15 @@
730 895 * @return void
731 896 */
732 897 public function enqueue_metabox_scripts(string $hook): void {
733 898 // Only load on post edit screens (including block editor)
734 - if (!in_array($hook, ['post.php', 'post-new.php'])) {
899 + if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
735 900 return;
736 901 }
737 902
738 903 // Get current post type - handle both classic and block editor contexts
739 904 $current_post_type = $this->get_current_post_type();
740 - if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types())) {
905 + if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
741 906 return;
742 907 }
743 908
744 909 // Get post object for additional data
@@ -856,8 +1021,39 @@
856 1021 'postModified' => $post ? get_the_modified_date('c', $post) : '',
857 1022 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
858 1023 'postStatus' => get_post_status($post_id),
859 1024 'isPro' => Plan_Config::is_pro(),
1025 + // Whether a provider (Pro's Redirections feature) can actually store
1026 + // a redirect. False renders the field as an upsell rather than an
1027 + // input that accepts text nothing will ever act on.
1028 + 'redirectSupported' => Object_Redirect::is_supported(),
1029 + 'redirectTypes' => Object_Redirect::TYPES,
1030 + /**
1031 + * Filter the editor SEO panel's post-load refresh behaviour.
1032 + *
1033 + * The panel re-checks `/metadata/{id}` after load so values written
1034 + * by a background writer (Auto AI on publish, imports) appear
1035 + * without a reload. It only polls while the server
1036 + * reports a write in flight, but the poll lives in JavaScript, so
1037 + * the switch has to be localized into the bundle rather than being
1038 + * a PHP-side filter alone (#329).
1039 + *
1040 + * Set `enabled` to false to switch the refresh off entirely.
1041 + *
1042 + * @since 1.30.0
1043 + *
1044 + * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
1045 + * @param int $post_id Post being edited.
1046 + */
1047 + 'seoRefresh' => apply_filters(
1048 + 'thinkrank_metabox_seo_refresh',
1049 + [
1050 + 'enabled' => true,
1051 + 'intervalMs' => 4000,
1052 + 'maxTicks' => 10,
1053 + ],
1054 + $post_id
1055 + ),
860 1056 // Whether any AI provider API key is configured — gates the
861 1057 // "Generate with AI" button in the metabox
862 1058 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
863 1059 || !empty($this->settings->get('claude_api_key', ''))
@@ -862,10 +1058,8 @@
862 1058 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
863 1059 || !empty($this->settings->get('claude_api_key', ''))
864 1060 || !empty($this->settings->get('gemini_api_key', ''))
865 1061 || !empty($this->settings->get('openrouter_api_key', '')),
866 - // Focus keywords plan limits (max_keywords; 0 = unlimited).
867 - 'focusKeywords' => Plan_Config::focus_keywords(),
868 1062 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
869 1063 // as placeholder previews when a field is empty (the frontend applies
870 1064 // these same patterns on output). Typing a value overrides them.
871 1065 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -969,9 +1163,9 @@
969 1163
970 1164 // Add other common e-commerce post types
971 1165 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
972 1166 foreach ($ecommerce_types as $type) {
973 - if (post_type_exists($type) && !in_array($type, $default_types)) {
1167 + if (post_type_exists($type) && !in_array($type, $default_types, true)) {
974 1168 $default_types[] = $type;
975 1169 }
976 1170 }
977 1171
@@ -981,28 +1175,45 @@
981 1175 'show_ui' => true,
982 1176 '_builtin' => false,
983 1177 ]);
984 1178
1179 + // WordPress internals that should never carry an SEO metabox. Fixed,
1180 + // so it is built once rather than per post type.
1181 + $wp_internal_types = [
1182 + 'attachment',
1183 + 'revision',
1184 + 'nav_menu_item',
1185 + 'custom_css',
1186 + 'customize_changeset',
1187 + 'oembed_cache',
1188 + 'user_request',
1189 + 'wp_block',
1190 + 'wp_template',
1191 + 'wp_template_part',
1192 + 'wp_global_styles',
1193 + 'wp_navigation',
1194 + 'acf-field',
1195 + 'acf-field-group',
1196 + ];
1197 +
1198 + // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1199 + // layout fragments, not pages with their own SEO. Global SEO already
1200 + // refuses them; this list is shared with that policy so the two cannot
1201 + // drift apart again (#621).
1202 + if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1203 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1204 + }
1205 +
985 1206 foreach ($custom_post_types as $post_type) {
986 - // Skip certain post types that shouldn't have SEO metabox
987 - $excluded_types = [
988 - 'attachment',
989 - 'revision',
990 - 'nav_menu_item',
991 - 'custom_css',
992 - 'customize_changeset',
993 - 'oembed_cache',
994 - 'user_request',
995 - 'wp_block',
996 - 'wp_template',
997 - 'wp_template_part',
998 - 'wp_global_styles',
999 - 'wp_navigation',
1000 - 'acf-field',
1001 - 'acf-field-group',
1002 - ];
1207 + // Resolved per post type, not hoisted: the shared list runs through
1208 + // a public filter that receives the post-type object, so an
1209 + // integrator can answer differently for different post types.
1210 + $excluded_types = array_merge(
1211 + $wp_internal_types,
1212 + \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1213 + );
1003 1214
1004 - if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) {
1215 + if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1005 1216 $default_types[] = $post_type;
1006 1217 }
1007 1218 }
1008 1219
@@ -1009,8 +1220,130 @@
1009 1220 return apply_filters('thinkrank_supported_post_types', $default_types);
1010 1221 }
1011 1222
1012 1223 /**
1224 + * Transient holding the last redirect error for the current user.
1225 + */
1226 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1227 +
1228 + /**
1229 + * Why the redirect field was refused on the most recent persist, if it was.
1230 + *
1231 + * @var \WP_Error|null
1232 + */
1233 + private ?\WP_Error $last_redirect_error = null;
1234 +
1235 + /**
1236 + * Persist the edit-screen redirect field.
1237 + *
1238 + * Absent keys are left alone, so a caller that never rendered the field
1239 + * (the AJAX save from an editor that submits a subset, an import) cannot
1240 + * clear a redirect by omission.
1241 + *
1242 + * The destination is not post meta — Pro's rules table holds it — so unlike
1243 + * every other field here this save can fail for reasons the editor needs to
1244 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1245 + * URL. Failing silently would be the worst of both, since the field would
1246 + * redisplay empty on the next load with no explanation, so the reason is
1247 + * stashed for the notice rendered on the next screen.
1248 + *
1249 + * @param string $object_type 'post' or 'term'.
1250 + * @param int $object_id Object ID.
1251 + * @param array $src Field name => raw value map.
1252 + * @return void
1253 + */
1254 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1255 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1256 + return null;
1257 + }
1258 +
1259 + $url = (string) $src['thinkrank_redirect_url'];
1260 +
1261 + // With no provider there is nothing to store and nothing to clear.
1262 + // Staying quiet when the field was submitted empty keeps every ordinary
1263 + // save on a free site from raising an error about a field the editor
1264 + // never touched.
1265 + if (!Object_Redirect::is_supported()) {
1266 + if ('' !== trim($url)) {
1267 + return new \WP_Error(
1268 + 'thinkrank_redirect_unsupported',
1269 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1270 + );
1271 + }
1272 + return null;
1273 + }
1274 +
1275 + $type = array_key_exists('thinkrank_redirect_type', $src)
1276 + ? $src['thinkrank_redirect_type']
1277 + : Object_Redirect::DEFAULT_TYPE;
1278 +
1279 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1280 +
1281 + return is_wp_error($result) ? $result : null;
1282 + }
1283 +
1284 + /**
1285 + * Why the last persist_metadata() call could not store the redirect.
1286 + *
1287 + * Every other metabox field either saves or is sanitized into something
1288 + * that does; this one can be refused, and each caller reports that
1289 + * differently — a notice for the form post, a JSON field for the AJAX save,
1290 + * an error message for the MCP ability.
1291 + *
1292 + * @return \WP_Error|null
1293 + */
1294 + public function get_last_redirect_error(): ?\WP_Error {
1295 + return $this->last_redirect_error;
1296 + }
1297 +
1298 + /**
1299 + * Remember why a redirect could not be saved, for the next admin screen.
1300 + *
1301 + * @param \WP_Error $error Failure.
1302 + * @return void
1303 + */
1304 + private function store_redirect_error(\WP_Error $error): void {
1305 + $user_id = get_current_user_id();
1306 + if ($user_id <= 0) {
1307 + return;
1308 + }
1309 +
1310 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1311 + }
1312 +
1313 + /**
1314 + * Show, once, why the last redirect save failed.
1315 + *
1316 + * @return void
1317 + */
1318 + public function render_redirect_notice(): void {
1319 + $user_id = get_current_user_id();
1320 + if ($user_id <= 0) {
1321 + return;
1322 + }
1323 +
1324 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1325 + $message = get_transient($key);
1326 +
1327 + if (!is_string($message) || '' === $message) {
1328 + return;
1329 + }
1330 +
1331 + delete_transient($key);
1332 +
1333 + printf(
1334 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1335 + esc_html(
1336 + sprintf(
1337 + /* translators: %s: reason the redirect was not saved. */
1338 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1339 + $message
1340 + )
1341 + )
1342 + );
1343 + }
1344 +
1345 + /**
1013 1346 * Get existing post metadata
1014 1347 *
1015 1348 * @param int $post_id Post ID
1016 1349 * @return array Existing metadata
@@ -1015,8 +1348,11 @@
1015 1348 * @param int $post_id Post ID
1016 1349 * @return array Existing metadata
1017 1350 */
1018 1351 public function get_post_metadata(int $post_id): array {
1352 + // One lookup: get() goes through a filter Pro answers from the database.
1353 + $redirect = Object_Redirect::get('post', $post_id);
1354 +
1019 1355 return [
1020 1356 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1021 1357 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1022 1358 'focus_keyword' => Focus_Keywords::get_primary($post_id),
@@ -1023,9 +1359,15 @@
1023 1359 'focus_keywords' => Focus_Keywords::get($post_id),
1024 1360 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1025 1361 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1026 1362 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1363 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1364 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1027 1365 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1366 + // Not post meta: the rule in Pro's redirections table is the value.
1367 + // See ThinkRank\SEO\Object_Redirect.
1368 + 'redirect_url' => $redirect['url'],
1369 + 'redirect_type' => $redirect['type'],
1028 1370 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1029 1371 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1030 1372 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1031 1373 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
@@ -1129,9 +1471,11 @@
1129 1471 // Clean and limit content
1130 1472 $content = wp_strip_all_tags($content);
1131 1473 $content = preg_replace('/\s+/', ' ', $content);
1132 1474
1133 - return trim(substr($content, 0, 4000));
1475 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1476 + // of the intended content and cut the last character in half (#687).
1477 + return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000));
1134 1478 }
1135 1479
1136 1480 /**
1137 1481 * AJAX handler for generating post metadata
@@ -1192,8 +1536,22 @@
1192 1536 }
1193 1537
1194 1538 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1195 1539 $this->persist_metadata($post_id, wp_unslash($_POST));
1540 +
1541 + // Everything else saved; only the redirect can have been refused. Report
1542 + // it in this response rather than as a notice on some later screen —
1543 + // this caller never reloads the page.
1544 + $redirect_error = $this->get_last_redirect_error();
1545 + if (null !== $redirect_error) {
1546 + wp_send_json_error([
1547 + 'message' => sprintf(
1548 + /* translators: %s: reason the redirect was not saved. */
1549 + __('Saved, except the redirect: %s', 'thinkrank'),
1550 + $redirect_error->get_error_message()
1551 + ),
1552 + ], 400);
1553 + }
1196 1554
1197 1555 wp_send_json_success([
1198 1556 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1199 1557 ]);