PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 +348 -41 1.28.0 → 2.9.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 }
@@ -483,9 +503,22 @@
483 503 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
484 504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
485 505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
486 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'] ?? '')); ?>" />
487 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; ?>
488 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')); ?>" />
489 522 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
490 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'] ?? '')); ?>" />
491 524 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
@@ -532,8 +565,16 @@
532 565 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
533 566 // the shared persistence routine.
534 567 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
535 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 + }
536 577 }
537 578
538 579 /**
539 580 * Persist metabox fields for a post from a form-field-name => value map,
@@ -567,19 +608,25 @@
567 608 * @return void
568 609 */
569 610 private function persist_metadata(int $post_id, array $src): void {
570 611 // Title & meta description are handled separately below: they can be
571 - // written out-of-band (Auto AI on publish, bulk optimization, imports)
612 + // written out-of-band (Auto AI on publish, imports)
572 613 // after an editor was opened, so a plain save from that now-stale editor
573 614 // would clobber the generated value with a blank. Focus keywords are
574 615 // likewise handled separately (array meta) via Focus_Keywords below.
575 - $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', 'sanitize_text_field');
576 - $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', 'sanitize_textarea_field');
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']);
577 622
578 623 $fields = [
579 624 'thinkrank_seo_score' => 'absint',
580 625 'thinkrank_generated_at' => 'sanitize_text_field',
581 626 'thinkrank_pillar_content' => 'sanitize_text_field',
627 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
582 629 ];
583 630
584 631 // Focus keywords: prefer the JSON array field; fall back to the legacy
585 632 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
@@ -593,10 +640,22 @@
593 640 }
594 641
595 642 // Update the post slug (post_name) when the metabox permalink field
596 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).
597 650 if (isset($src['thinkrank_post_slug'])) {
598 - $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 + );
599 658 }
600 659
601 660 // Save canonical URL separately with URL sanitization
602 661 if (isset($src['thinkrank_canonical_url'])) {
@@ -607,8 +666,10 @@
607 666 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
608 667 }
609 668 }
610 669
670 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671 +
611 672 foreach ($fields as $field => $sanitize_callback) {
612 673 if (isset($src[$field])) {
613 674 $value = call_user_func($sanitize_callback, $src[$field]);
614 675 update_post_meta($post_id, "_{$field}", $value);
@@ -615,8 +676,9 @@
615 676 }
616 677 }
617 678
618 679 $this->save_robots_meta($post_id, $src);
680 + $this->save_visibility_meta($post_id, $src);
619 681 $this->save_social_meta($post_id, $src);
620 682
621 683 // Update last modified timestamp
622 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
@@ -624,10 +686,10 @@
624 686
625 687 /**
626 688 * Persist one SEO text field with an out-of-band-write guard.
627 689 *
628 - * Auto AI (on publish), bulk optimization and imports write the SEO title /
629 - * meta description directly to post meta. When that happens after an editor
690 + * Auto AI (on publish) and imports write the SEO title / meta description
691 + * directly to post meta. When that happens after an editor
630 692 * was opened, the editor's hidden input is a stale blank; a normal save
631 693 * would overwrite the freshly generated value with that blank. This guard
632 694 * skips the write only when the submitted value is empty AND it was also
633 695 * empty when the form was rendered (the `<field>__orig` mirror), yet the
@@ -677,9 +739,9 @@
677 739 * @param int $post_id Post to update.
678 740 * @param string $raw_slug Desired slug from the metabox.
679 741 * @return void
680 742 */
681 - 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 {
682 744 static $updating = false;
683 745 if ($updating) {
684 746 return;
685 747 }
@@ -693,12 +755,28 @@
693 755 return;
694 756 }
695 757
696 758 $desired = sanitize_title($raw_slug);
697 - if ($desired === '' || $desired === $post->post_name) {
759 + if ($desired === '') {
698 760 return;
699 761 }
700 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 +
701 779 $updating = true;
702 780 wp_update_post([
703 781 'ID' => $post_id,
704 782 'post_name' => $desired,
@@ -722,9 +800,11 @@
722 800 foreach ($text_fields as $field => $meta_key) {
723 801 if (!isset($src[$field])) {
724 802 continue;
725 803 }
726 - $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]);
727 807 if ($value === '') {
728 808 delete_post_meta($post_id, $meta_key);
729 809 } else {
730 810 update_post_meta($post_id, $meta_key, $value);
@@ -773,8 +853,43 @@
773 853 }
774 854
775 855
776 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 + /**
777 892 * Enqueue meta box scripts
778 893 *
779 894 * @param string $hook Current admin page hook
780 895 * @return void
@@ -780,15 +895,15 @@
780 895 * @return void
781 896 */
782 897 public function enqueue_metabox_scripts(string $hook): void {
783 898 // Only load on post edit screens (including block editor)
784 - if (!in_array($hook, ['post.php', 'post-new.php'])) {
899 + if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
785 900 return;
786 901 }
787 902
788 903 // Get current post type - handle both classic and block editor contexts
789 904 $current_post_type = $this->get_current_post_type();
790 - 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)) {
791 906 return;
792 907 }
793 908
794 909 // Get post object for additional data
@@ -906,16 +1021,44 @@
906 1021 'postModified' => $post ? get_the_modified_date('c', $post) : '',
907 1022 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
908 1023 'postStatus' => get_post_status($post_id),
909 1024 'isPro' => Plan_Config::is_pro(),
910 - // Whether any AI provider API key is configured — gates the
911 - // "Generate with AI" button in the metabox
912 - 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
913 - || !empty($this->settings->get('claude_api_key', ''))
914 - || !empty($this->settings->get('gemini_api_key', ''))
915 - || !empty($this->settings->get('openrouter_api_key', '')),
916 - // Focus keywords plan limits (max_keywords; 0 = unlimited).
917 - 'focusKeywords' => Plan_Config::focus_keywords(),
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 + ),
1056 + // Whether the selected AI provider is configured — gates the
1057 + // "Generate with AI" button in the metabox. An OpenAI-compatible
1058 + // endpoint counts once it has a base URL and a model id, with or
1059 + // without a key (#721).
1060 + 'aiConfigured' => $this->settings->has_ai_provider_configured(),
918 1061 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
919 1062 // as placeholder previews when a field is empty (the frontend applies
920 1063 // these same patterns on output). Typing a value overrides them.
921 1064 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -1019,9 +1162,9 @@
1019 1162
1020 1163 // Add other common e-commerce post types
1021 1164 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1022 1165 foreach ($ecommerce_types as $type) {
1023 - if (post_type_exists($type) && !in_array($type, $default_types)) {
1166 + if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1024 1167 $default_types[] = $type;
1025 1168 }
1026 1169 }
1027 1170
@@ -1031,28 +1174,45 @@
1031 1174 'show_ui' => true,
1032 1175 '_builtin' => false,
1033 1176 ]);
1034 1177
1178 + // WordPress internals that should never carry an SEO metabox. Fixed,
1179 + // so it is built once rather than per post type.
1180 + $wp_internal_types = [
1181 + 'attachment',
1182 + 'revision',
1183 + 'nav_menu_item',
1184 + 'custom_css',
1185 + 'customize_changeset',
1186 + 'oembed_cache',
1187 + 'user_request',
1188 + 'wp_block',
1189 + 'wp_template',
1190 + 'wp_template_part',
1191 + 'wp_global_styles',
1192 + 'wp_navigation',
1193 + 'acf-field',
1194 + 'acf-field-group',
1195 + ];
1196 +
1197 + // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1198 + // layout fragments, not pages with their own SEO. Global SEO already
1199 + // refuses them; this list is shared with that policy so the two cannot
1200 + // drift apart again (#621).
1201 + if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1202 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1203 + }
1204 +
1035 1205 foreach ($custom_post_types as $post_type) {
1036 - // Skip certain post types that shouldn't have SEO metabox
1037 - $excluded_types = [
1038 - 'attachment',
1039 - 'revision',
1040 - 'nav_menu_item',
1041 - 'custom_css',
1042 - 'customize_changeset',
1043 - 'oembed_cache',
1044 - 'user_request',
1045 - 'wp_block',
1046 - 'wp_template',
1047 - 'wp_template_part',
1048 - 'wp_global_styles',
1049 - 'wp_navigation',
1050 - 'acf-field',
1051 - 'acf-field-group',
1052 - ];
1206 + // Resolved per post type, not hoisted: the shared list runs through
1207 + // a public filter that receives the post-type object, so an
1208 + // integrator can answer differently for different post types.
1209 + $excluded_types = array_merge(
1210 + $wp_internal_types,
1211 + \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1212 + );
1053 1213
1054 - if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) {
1214 + if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1055 1215 $default_types[] = $post_type;
1056 1216 }
1057 1217 }
1058 1218
@@ -1059,8 +1219,130 @@
1059 1219 return apply_filters('thinkrank_supported_post_types', $default_types);
1060 1220 }
1061 1221
1062 1222 /**
1223 + * Transient holding the last redirect error for the current user.
1224 + */
1225 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1226 +
1227 + /**
1228 + * Why the redirect field was refused on the most recent persist, if it was.
1229 + *
1230 + * @var \WP_Error|null
1231 + */
1232 + private ?\WP_Error $last_redirect_error = null;
1233 +
1234 + /**
1235 + * Persist the edit-screen redirect field.
1236 + *
1237 + * Absent keys are left alone, so a caller that never rendered the field
1238 + * (the AJAX save from an editor that submits a subset, an import) cannot
1239 + * clear a redirect by omission.
1240 + *
1241 + * The destination is not post meta — Pro's rules table holds it — so unlike
1242 + * every other field here this save can fail for reasons the editor needs to
1243 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1244 + * URL. Failing silently would be the worst of both, since the field would
1245 + * redisplay empty on the next load with no explanation, so the reason is
1246 + * stashed for the notice rendered on the next screen.
1247 + *
1248 + * @param string $object_type 'post' or 'term'.
1249 + * @param int $object_id Object ID.
1250 + * @param array $src Field name => raw value map.
1251 + * @return void
1252 + */
1253 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1254 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1255 + return null;
1256 + }
1257 +
1258 + $url = (string) $src['thinkrank_redirect_url'];
1259 +
1260 + // With no provider there is nothing to store and nothing to clear.
1261 + // Staying quiet when the field was submitted empty keeps every ordinary
1262 + // save on a free site from raising an error about a field the editor
1263 + // never touched.
1264 + if (!Object_Redirect::is_supported()) {
1265 + if ('' !== trim($url)) {
1266 + return new \WP_Error(
1267 + 'thinkrank_redirect_unsupported',
1268 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1269 + );
1270 + }
1271 + return null;
1272 + }
1273 +
1274 + $type = array_key_exists('thinkrank_redirect_type', $src)
1275 + ? $src['thinkrank_redirect_type']
1276 + : Object_Redirect::DEFAULT_TYPE;
1277 +
1278 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1279 +
1280 + return is_wp_error($result) ? $result : null;
1281 + }
1282 +
1283 + /**
1284 + * Why the last persist_metadata() call could not store the redirect.
1285 + *
1286 + * Every other metabox field either saves or is sanitized into something
1287 + * that does; this one can be refused, and each caller reports that
1288 + * differently — a notice for the form post, a JSON field for the AJAX save,
1289 + * an error message for the MCP ability.
1290 + *
1291 + * @return \WP_Error|null
1292 + */
1293 + public function get_last_redirect_error(): ?\WP_Error {
1294 + return $this->last_redirect_error;
1295 + }
1296 +
1297 + /**
1298 + * Remember why a redirect could not be saved, for the next admin screen.
1299 + *
1300 + * @param \WP_Error $error Failure.
1301 + * @return void
1302 + */
1303 + private function store_redirect_error(\WP_Error $error): void {
1304 + $user_id = get_current_user_id();
1305 + if ($user_id <= 0) {
1306 + return;
1307 + }
1308 +
1309 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1310 + }
1311 +
1312 + /**
1313 + * Show, once, why the last redirect save failed.
1314 + *
1315 + * @return void
1316 + */
1317 + public function render_redirect_notice(): void {
1318 + $user_id = get_current_user_id();
1319 + if ($user_id <= 0) {
1320 + return;
1321 + }
1322 +
1323 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1324 + $message = get_transient($key);
1325 +
1326 + if (!is_string($message) || '' === $message) {
1327 + return;
1328 + }
1329 +
1330 + delete_transient($key);
1331 +
1332 + printf(
1333 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1334 + esc_html(
1335 + sprintf(
1336 + /* translators: %s: reason the redirect was not saved. */
1337 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1338 + $message
1339 + )
1340 + )
1341 + );
1342 + }
1343 +
1344 + /**
1063 1345 * Get existing post metadata
1064 1346 *
1065 1347 * @param int $post_id Post ID
1066 1348 * @return array Existing metadata
@@ -1065,8 +1347,11 @@
1065 1347 * @param int $post_id Post ID
1066 1348 * @return array Existing metadata
1067 1349 */
1068 1350 public function get_post_metadata(int $post_id): array {
1351 + // One lookup: get() goes through a filter Pro answers from the database.
1352 + $redirect = Object_Redirect::get('post', $post_id);
1353 +
1069 1354 return [
1070 1355 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1071 1356 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1072 1357 'focus_keyword' => Focus_Keywords::get_primary($post_id),
@@ -1073,9 +1358,15 @@
1073 1358 'focus_keywords' => Focus_Keywords::get($post_id),
1074 1359 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1075 1360 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1076 1361 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1362 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1363 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1077 1364 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1365 + // Not post meta: the rule in Pro's redirections table is the value.
1366 + // See ThinkRank\SEO\Object_Redirect.
1367 + 'redirect_url' => $redirect['url'],
1368 + 'redirect_type' => $redirect['type'],
1078 1369 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1079 1370 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1080 1371 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1081 1372 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
@@ -1179,9 +1470,11 @@
1179 1470 // Clean and limit content
1180 1471 $content = wp_strip_all_tags($content);
1181 1472 $content = preg_replace('/\s+/', ' ', $content);
1182 1473
1183 - return trim(substr($content, 0, 4000));
1474 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1475 + // of the intended content and cut the last character in half (#687).
1476 + return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000));
1184 1477 }
1185 1478
1186 1479 /**
1187 1480 * AJAX handler for generating post metadata
@@ -1242,8 +1535,22 @@
1242 1535 }
1243 1536
1244 1537 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1245 1538 $this->persist_metadata($post_id, wp_unslash($_POST));
1539 +
1540 + // Everything else saved; only the redirect can have been refused. Report
1541 + // it in this response rather than as a notice on some later screen —
1542 + // this caller never reloads the page.
1543 + $redirect_error = $this->get_last_redirect_error();
1544 + if (null !== $redirect_error) {
1545 + wp_send_json_error([
1546 + 'message' => sprintf(
1547 + /* translators: %s: reason the redirect was not saved. */
1548 + __('Saved, except the redirect: %s', 'thinkrank'),
1549 + $redirect_error->get_error_message()
1550 + ),
1551 + ], 400);
1552 + }
1246 1553
1247 1554 wp_send_json_success([
1248 1555 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1249 1556 ]);