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.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/core/class-settings.php +226 -52 2.1.12.7.0 View file →
@@ -58,8 +58,47 @@
58 58 * the feature resolves %separator% from Site Identity (#318).
59 59 *
60 60 * @since 1.29.1
61 61 */
62 + /**
63 + * AI providers this plugin supports.
64 + *
65 + * Single source of truth for the REST enum, the connection test's dispatch
66 + * and sanitize_setting(), so the three cannot disagree about what is legal.
67 + *
68 + * @since 2.2.0
69 + * @var string[]
70 + */
71 + public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter'];
72 +
73 + /**
74 + * The stored value meaning "the user has not chosen a provider yet".
75 + *
76 + * A fresh install ships with no provider selected: picking one is the
77 + * user's call, and pre-selecting OpenAI made the settings screen open with
78 + * a warning about a missing key for a provider nobody had asked for (#572).
79 + * Every write path accepts this alongside SUPPORTED_AI_PROVIDERS.
80 + *
81 + * @since 2.1.3
82 + */
83 + public const AI_PROVIDER_NONE = '';
84 +
85 + /**
86 + * One-time marker for {@see Settings::retire_seeded_ai_provider()}.
87 + */
88 + private const PROVIDER_MIGRATION_OPTION = 'thinkrank_ai_provider_migration';
89 + private const PROVIDER_MIGRATION_VERSION = '1';
90 +
91 + /**
92 + * Legal values for the `ai_provider` setting, including "not chosen".
93 + *
94 + * @since 2.1.3
95 + * @return string[]
96 + */
97 + public static function selectable_ai_providers(): array {
98 + return array_merge([self::AI_PROVIDER_NONE], self::SUPPORTED_AI_PROVIDERS);
99 + }
100 +
62 101 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
63 102 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
64 103
65 104 /**
@@ -77,8 +116,12 @@
77 116 */
78 117 private const EMPTY_IS_A_VALUE = [
79 118 'author_archives_title',
80 119 'author_archives_meta_desc',
120 + // '' is the "no provider chosen" state, not "fall back to the default".
121 + // Without this, deselecting a provider would be undone by any caller
122 + // that passes its own fallback to get() (#572).
123 + 'ai_provider',
81 124 ];
82 125
83 126 /**
84 127 * Shared singleton instance
@@ -130,9 +173,9 @@
130 173 * @var array
131 174 */
132 175 private array $defaults = [
133 176 // AI Settings
134 - 'ai_provider' => 'openai',
177 + 'ai_provider' => self::AI_PROVIDER_NONE,
135 178 'openai_api_key' => '',
136 179 'openai_model' => self::DEFAULT_OPENAI_MODEL,
137 180 'claude_api_key' => '',
138 181 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
@@ -188,37 +231,8 @@
188 231 'seo_score_threshold' => 70,
189 232 'enable_meta_generation' => true,
190 233 'enable_schema_markup' => true,
191 234
192 - // Auto AI Optimization (on-publish metadata fill; #248 P1)
193 - 'auto_ai_meta_enabled' => false,
194 - 'auto_ai_meta_post_types' => ['post'],
195 -
196 - // Brand Visibility v2. The brand profile drives question generation
197 - // and mention detection; per-platform keys let this feature query
198 - // several assistants without changing the site-wide AI provider.
199 - 'bv_brand_name' => '',
200 - 'bv_variants' => [],
201 - 'bv_location' => '',
202 - 'bv_category' => '',
203 - 'bv_description' => '',
204 - 'bv_competitors' => [],
205 - 'bv_queries' => [],
206 - 'bv_platforms' => ['chatgpt'],
207 - 'bv_samples' => 1,
208 - 'bv_key_chatgpt' => '',
209 - 'bv_key_gemini' => '',
210 - 'bv_key_claude' => '',
211 - 'bv_key_perplexity' => '',
212 - // Empty = use the platform's default model (see Brand_Visibility_Providers).
213 - 'bv_model_chatgpt' => '',
214 - 'bv_model_gemini' => '',
215 - 'bv_model_claude' => '',
216 - 'bv_model_perplexity' => '',
217 -
218 - // AI Brand Visibility (BYO-key checks; #248 P1)
219 - 'brand_visibility_queries' => [],
220 -
221 235 // Author Archives Settings
222 236 'author_archives_enabled' => true,
223 237 'author_archives_index' => true,
224 238 'author_archives_show_empty' => false,
@@ -235,8 +249,16 @@
235 249 'retry_attempts' => 3,
236 250 // Exposes the standalone Migration admin page for re-running SEO data
237 251 // imports after setup. Hidden by default; opt-in for advanced/support use.
238 252 'enable_migration_tools' => false,
253 + // Exposes ThinkRank's own export / restore. Off by default, like the
254 + // migration toggle above: both are occasional, admin-only tools, and a
255 + // menu item nobody asked for is a menu item in the way. Your data is
256 + // never locked in — the switch is one click away in
257 + // Settings > Import / Export, and turning it on immediately restores
258 + // the screen and the menu item. Off hides the export card and, unless
259 + // migration tools are on, the Import / Export menu item with it.
260 + 'enable_import_export' => false,
239 261
240 262 // Privacy Settings
241 263 'data_retention_days' => 90,
242 264 'anonymize_logs' => true,
@@ -245,16 +267,8 @@
245 267 // Integration Settings
246 268 'google_analytics_id' => '',
247 269 'search_console_property' => '',
248 270
249 - // GA4 Tracking Settings
250 - 'ga4_measurement_id' => '',
251 - 'ga4_auto_inject' => false,
252 - 'ga4_anonymize_ip' => false,
253 - 'ga4_exclude_admin' => false,
254 - 'ga4_tracking_verified' => false,
255 - 'ga4_last_verification' => '',
256 -
257 271 // SEO Analytics Settings
258 272 'seo_analytics_enabled' => false,
259 273 'seo_analytics_setup_completed' => false,
260 274 'seo_analytics_google_analytics_property_id' => '',
@@ -303,11 +317,102 @@
303 317 * @return void
304 318 */
305 319 public function init(): void {
306 320 add_action('admin_init', [$this, 'register_settings']);
321 + // Admin-only: a front-end pageview can never need this migration, and
322 + // the marker is a non-autoloaded option, so hooking it unconditionally
323 + // bought one dedicated query on every request for the life of the
324 + // install (#588).
325 + if (is_admin()) {
326 + add_action('init', [self::class, 'retire_seeded_ai_provider']);
327 + }
307 328 }
308 329
309 330 /**
331 + * Clear the OpenAI selection that older versions seeded on activation.
332 + *
333 + * Changing the default only helps installs created after the change. Every
334 + * site activated before it still carries `ai_provider = 'openai'` written by
335 + * Activator::set_default_options(), and still opens Settings warning about a
336 + * missing key for a provider nobody picked — the whole complaint in #572.
337 + *
338 + * "OpenAI with no OpenAI key" is provably not a user's choice: the settings
339 + * form refuses to save a provider without a key, so the only way to reach
340 + * that state is the old activation seed. A site that genuinely chose OpenAI
341 + * has a key and is left alone, as is any site on another provider.
342 + *
343 + * Version-gated so it runs once and never fights a user who later clears
344 + * their key but keeps the provider selected.
345 + *
346 + * @since 2.1.3
347 + *
348 + * @return void
349 + */
350 + public static function retire_seeded_ai_provider(): void {
351 + if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) {
352 + self::promote_migration_marker_to_autoload();
353 + return;
354 + }
355 +
356 + // Record first: a site that somehow fails the checks below must not
357 + // re-test on every request for the rest of its life. Autoloaded on
358 + // purpose — it is a short write-once flag that is read on every admin
359 + // request, which is exactly what autoload is for; storing it
360 + // non-autoloaded bought a dedicated query per request instead (#588).
361 + update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true);
362 +
363 + if (get_option('thinkrank_ai_provider', null) !== 'openai') {
364 + return;
365 + }
366 +
367 + // Read the stored option directly rather than through Settings::get().
368 + // Only emptiness matters here, and get() adds two things this decision
369 + // must not depend on: a per-instance cache that may already be primed,
370 + // and decryption that can yield '' for a key that is genuinely present.
371 + // The raw option is non-empty whenever a key exists, encrypted or not.
372 + if ('' !== (string) get_option('thinkrank_openai_api_key', '')) {
373 + // A real choice, backed by a key. Leave it.
374 + return;
375 + }
376 +
377 + // Write through the shared instance, not a throwaway one: set() refreshes
378 + // only the cache of the object it is called on, so a private instance
379 + // would leave the registered component serving the old value for the
380 + // rest of this request — including to the admin page it localizes.
381 + self::instance()->set('ai_provider', self::AI_PROVIDER_NONE);
382 + }
383 +
384 + /**
385 + * Move a legacy migration marker into the autoloaded set.
386 + *
387 + * Sites that ran the migration on 2.1.3 wrote the marker with
388 + * `autoload = false`, and the version gate above returns before the write
389 + * that would correct it — so those installs keep paying a dedicated query
390 + * to read a one-byte flag on every admin request, which is the cost #588
391 + * was about. Promote it once.
392 + *
393 + * Free to test: alloptions is loaded from the object cache once per request
394 + * regardless, and an autoloaded marker is in it, so the steady state after
395 + * the promotion is a cache lookup and nothing else. wp_set_option_autoload()
396 + * arrived in WP 6.4 and the plugin supports 6.0, hence the guard.
397 + *
398 + * @since 2.2.0
399 + *
400 + * @return void
401 + */
402 + private static function promote_migration_marker_to_autoload(): void {
403 + if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) {
404 + return;
405 + }
406 +
407 + if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) {
408 + return;
409 + }
410 +
411 + wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true);
412 + }
413 +
414 + /**
310 415 * Register WordPress settings
311 416 *
312 417 * @return void
313 418 */
@@ -436,10 +541,14 @@
436 541 if (!array_key_exists($key, $this->defaults)) {
437 542 return false;
438 543 }
439 544
545 + // Apply the declared per-key sanitizer before anything is stored. This
546 + // is the path essentially every caller takes, so skipping it left the
547 + // whole sanitize_setting() switch unreachable — max_tokens,
548 + // cache_duration and temperature persisted whatever string arrived.
549 + $value = $this->sanitize_setting($key, $value);
440 550
441 -
442 551 // Encrypt if needed
443 552 $encrypted_value = $this->maybe_encrypt($key, $value);
444 553
445 554 // Save to WordPress options or user meta
@@ -520,8 +629,23 @@
520 629 return $result;
521 630 }
522 631
523 632 /**
633 + * Setting keys whose values are encrypted at rest.
634 + *
635 + * Exposed so callers that must never emit a credential — the data exporter
636 + * in particular — can filter against the same list this class encrypts
637 + * with, instead of keeping a copy that silently drifts when a key is added.
638 + *
639 + * @since 2.2.0
640 + *
641 + * @return string[] Setting keys.
642 + */
643 + public function get_encrypted_keys(): array {
644 + return $this->encrypted_keys;
645 + }
646 +
647 + /**
524 648 * Get all settings (optimized with bulk caching)
525 649 *
526 650 * @param int $user_id User ID (0 for global)
527 651 * @return array All settings
@@ -588,8 +712,44 @@
588 712 return $sanitized;
589 713 }
590 714
591 715 /**
716 + * Sanitize a nested array, preserving its shape.
717 + *
718 + * Scalars keep their type (an int threshold stays an int); strings are
719 + * text-sanitized; objects are dropped, since no setting stores one.
720 + *
721 + * @since 2.2.0
722 + * @param array $value Array to sanitize.
723 + * @param int $depth Current recursion depth.
724 + * @return array
725 + */
726 + private function sanitize_array_recursive(array $value, int $depth = 0): array {
727 + // Settings are configuration, not arbitrary payloads; a cap keeps a
728 + // malformed deep structure from recursing without bound.
729 + if ($depth > 10) {
730 + return [];
731 + }
732 +
733 + $sanitized = [];
734 + foreach ($value as $item_key => $item) {
735 + $key = is_string($item_key) ? sanitize_key($item_key) : $item_key;
736 +
737 + if (is_array($item)) {
738 + $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1);
739 + } elseif (is_object($item)) {
740 + continue;
741 + } elseif (is_bool($item) || is_int($item) || is_float($item)) {
742 + $sanitized[$key] = $item;
743 + } else {
744 + $sanitized[$key] = sanitize_text_field((string) $item);
745 + }
746 + }
747 +
748 + return $sanitized;
749 + }
750 +
751 + /**
592 752 * Sanitize individual setting
593 753 *
594 754 * @param string $key Setting key
595 755 * @param mixed $value Setting value
@@ -614,10 +774,17 @@
614 774 case 'openrouter_api_key':
615 775 return sanitize_text_field($value);
616 776
617 777 case 'ai_provider':
618 - return sanitize_key($value);
778 + // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
779 + // deliberate "no provider chosen" state, so it must survive here
780 + // rather than being folded back into the default (#572).
781 + $provider = sanitize_key($value);
619 782
783 + return in_array($provider, self::selectable_ai_providers(), true)
784 + ? $provider
785 + : $this->defaults['ai_provider'];
786 +
620 787 case 'openai_model':
621 788 case 'claude_model':
622 789 case 'gemini_model':
623 790 case 'openrouter_model':
@@ -654,8 +821,17 @@
654 821 foreach ($value as $threshold_key => $threshold_value) {
655 822 if (is_array($threshold_value)) {
656 823 continue;
657 824 }
825 + if (is_bool($threshold_value)) {
826 + // Keep booleans as booleans. is_numeric() is false for
827 + // one, so it used to fall to the string arm and a true
828 + // came back as "1" — invisible while this ran only on
829 + // the register_setting() path, now that set() routes
830 + // every write through here it is a type change on save.
831 + $thresholds[sanitize_key($threshold_key)] = $threshold_value;
832 + continue;
833 + }
658 834 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
659 835 ? $threshold_value + 0
660 836 : sanitize_text_field((string) $threshold_value);
661 837 }
@@ -667,9 +843,12 @@
667 843 return sanitize_text_field($value);
668 844
669 845 case 'robots_txt_content':
670 846 // Multi-line content — sanitize_text_field() collapses newlines
671 - // and would flatten the whole file onto a single line.
847 + // and would flatten the whole file onto a single line. set()
848 + // routes every write through here, so a caller that chose
849 + // sanitize_textarea_field() itself is otherwise silently
850 + // overridden by the default: arm below (#587).
672 851 return sanitize_textarea_field($value);
673 852
674 853 default:
675 854 if (is_bool($value)) {
@@ -676,19 +855,14 @@
676 855 return (bool) $value;
677 856 } elseif (is_string($value)) {
678 857 return sanitize_text_field($value);
679 858 } elseif (is_array($value)) {
680 - // Flat list/map of scalars; nested members are dropped rather
681 - // than passed to a string sanitizer that would fatal on them.
682 - $sanitized = [];
683 - foreach ($value as $item_key => $item) {
684 - if (is_array($item) || is_object($item)) {
685 - continue;
686 - }
687 - $sanitized[is_string($item_key) ? sanitize_key($item_key) : $item_key] =
688 - sanitize_text_field((string) $item);
689 - }
690 - return $sanitized;
859 + // Recurse rather than drop. Skipping nested members was
860 + // harmless while this ran only on the register_setting()
861 + // path, but set() now routes every write through here, and
862 + // structured settings (lists of maps) were being silently
863 + // emptied on save.
864 + return $this->sanitize_array_recursive($value);
691 865 }
692 866 return $value;
693 867 }
694 868 }