| @@ -83,12 +83,21 @@ | ||
| 83 | 83 | 'gemini_api_key', |
| 84 | 84 | 'gemini_model', |
| 85 | 85 | 'openrouter_api_key', |
| 86 | 86 | 'openrouter_model', |
| 87 | + 'openai_compatible_base_url', | |
| 88 | + 'openai_compatible_api_key', | |
| 89 | + 'openai_compatible_model', | |
| 90 | + 'openai_compatible_timeout', | |
| 91 | + 'openai_compatible_supports_images', | |
| 92 | + 'openai_compatible_json_mode', | |
| 93 | + 'openai_compatible_price_per_million', | |
| 87 | 94 | 'max_tokens', |
| 88 | 95 | 'temperature', |
| 89 | 96 | 'cache_duration', |
| 90 | 97 | 'max_requests_per_minute', |
| 98 | + 'ai_daily_request_limit', | |
| 99 | + 'ai_paused', | |
| 91 | 100 | 'enable_logging', |
| 92 | 101 | 'debug_mode', |
| 93 | 102 | 'api_timeout', |
| 94 | 103 | 'retry_attempts', |
| @@ -257,12 +266,8 @@ | ||
| 257 | 266 | 'seo_analytics_google_analytics_property_id', |
| 258 | 267 | 'ga_analytics_account_id', |
| 259 | 268 | 'ga_analytics_data_stream_id', |
| 260 | 269 | |
| 261 | - // GA4 Tracking Code Injection (Pro) | |
| 262 | - 'ga4_auto_inject', | |
| 263 | - 'ga4_measurement_id', | |
| 264 | - | |
| 265 | 270 | // Search Console configuration |
| 266 | 271 | 'search_console_property', |
| 267 | 272 | |
| 268 | 273 | // AI features |
| @@ -325,13 +330,17 @@ | ||
| 325 | 330 | * @param array $settings Settings to update |
| 326 | 331 | * @param string $category Settings category |
| 327 | 332 | * @param string $context_type Optional. Context type for SEO settings |
| 328 | 333 | * @param int|null $context_id Optional. Context ID for SEO settings |
| 329 | - * @return bool Success status | |
| 334 | + * @return bool|null True on success, false on failure, null when this store | |
| 335 | + * does not own the category (nothing was attempted). | |
| 330 | 336 | */ |
| 331 | - public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): bool { | |
| 337 | + public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): ?bool { | |
| 338 | + // Unknown here means "not this store's category", not "the write | |
| 339 | + // failed" — the caller may still have a dedicated manager that owns it | |
| 340 | + // (#371). Failing closed made those saves report 500 after committing. | |
| 332 | 341 | if (!isset($this->settings_categories[$category])) { |
| 333 | - return false; | |
| 342 | + return null; | |
| 334 | 343 | } |
| 335 | 344 | |
| 336 | 345 | $category_config = $this->settings_categories[$category]; |
| 337 | 346 | |
| @@ -450,8 +459,23 @@ | ||
| 450 | 459 | * @since 1.0.0 |
| 451 | 460 | * |
| 452 | 461 | * @return array Categories information |
| 453 | 462 | */ |
| 463 | + /** | |
| 464 | + * The setting keys a category defines. | |
| 465 | + * | |
| 466 | + * Exposed so callers can reject keys a category does not define instead of | |
| 467 | + * persisting whatever they are handed (#395). | |
| 468 | + * | |
| 469 | + * @since 2.0.1 | |
| 470 | + * | |
| 471 | + * @param string $category Category name. | |
| 472 | + * @return string[] Setting keys, or [] when the category is unknown here. | |
| 473 | + */ | |
| 474 | + public function get_category_keys(string $category): array { | |
| 475 | + return $this->settings_categories[$category]['keys'] ?? []; | |
| 476 | + } | |
| 477 | + | |
| 454 | 478 | public function get_categories(): array { |
| 455 | 479 | $categories = []; |
| 456 | 480 | |
| 457 | 481 | foreach ($this->settings_categories as $key => $config) { |
| @@ -625,8 +649,16 @@ | ||
| 625 | 649 | * @return array Core settings for category |
| 626 | 650 | */ |
| 627 | 651 | private function get_core_settings_by_category(string $category): array { |
| 628 | 652 | $category_config = $this->settings_categories[$category]; |
| 653 | + | |
| 654 | + // Prime the option cache in one query before the loop. Every | |
| 655 | + // thinkrank_* option is autoload=off, so WordPress cannot serve them | |
| 656 | + // from `alloptions` and each Settings->get() below was its own | |
| 657 | + // round-trip — 16 of them on every anonymous front-end request, on | |
| 658 | + // pages that use none of the values (#393). | |
| 659 | + $this->core_settings->prime($category_config['keys']); | |
| 660 | + | |
| 629 | 661 | $settings = []; |
| 630 | 662 | |
| 631 | 663 | foreach ($category_config['keys'] as $key) { |
| 632 | 664 | $settings[$key] = $this->core_settings->get($key); |
| @@ -710,11 +742,12 @@ | ||
| 710 | 742 | * @param array $settings Settings to update |
| 711 | 743 | * @param string $category Category name |
| 712 | 744 | * @param string $context_type Context type |
| 713 | 745 | * @param int|null $context_id Context ID |
| 714 | - * @return bool Success status | |
| 746 | + * @return bool|null True on success, false on failure, null when the SEO | |
| 747 | + * store does not own the category. | |
| 715 | 748 | */ |
| 716 | - private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): bool { | |
| 749 | + private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool { | |
| 717 | 750 | return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category); |
| 718 | 751 | } |
| 719 | 752 | |
| 720 | 753 | /** |
| @@ -781,8 +814,10 @@ | ||
| 781 | 814 | switch ($key) { |
| 782 | 815 | case 'openai_api_key': |
| 783 | 816 | case 'claude_api_key': |
| 784 | 817 | case 'openrouter_api_key': |
| 818 | + case 'openai_compatible_api_key': | |
| 819 | + case 'openai_compatible_model': | |
| 785 | 820 | if (!empty($value) && !is_string($value)) { |
| 786 | 821 | $validation['valid'] = false; |
| 787 | 822 | $validation['errors'][] = "{$key} must be a string"; |
| 788 | 823 | } |
| @@ -787,15 +822,29 @@ | ||
| 787 | 822 | $validation['errors'][] = "{$key} must be a string"; |
| 788 | 823 | } |
| 789 | 824 | break; |
| 790 | 825 | |
| 826 | + case 'openai_compatible_base_url': | |
| 827 | + // An unreachable or dangerous URL is refused with a reason | |
| 828 | + // rather than quietly stored (see Endpoint_URL_Validator). | |
| 829 | + if (!empty($value)) { | |
| 830 | + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $value); | |
| 831 | + if (is_wp_error($validated)) { | |
| 832 | + $validation['valid'] = false; | |
| 833 | + $validation['errors'][] = $validated->get_error_message(); | |
| 834 | + } | |
| 835 | + } | |
| 836 | + break; | |
| 837 | + | |
| 791 | 838 | case 'max_tokens': |
| 792 | 839 | case 'cache_duration': |
| 793 | 840 | case 'max_requests_per_minute': |
| 841 | + case 'ai_daily_request_limit': | |
| 794 | 842 | case 'seo_score_threshold': |
| 795 | 843 | case 'api_timeout': |
| 796 | 844 | case 'retry_attempts': |
| 797 | 845 | case 'data_retention_days': |
| 846 | + case 'openai_compatible_timeout': | |
| 798 | 847 | if (!is_numeric($value) || $value < 0) { |
| 799 | 848 | $validation['valid'] = false; |
| 800 | 849 | $validation['errors'][] = "{$key} must be a positive number"; |
| 801 | 850 | } |
| @@ -808,11 +857,15 @@ | ||
| 808 | 857 | } |
| 809 | 858 | break; |
| 810 | 859 | |
| 811 | 860 | case 'ai_provider': |
| 812 | - if (!in_array($value, ['openai', 'claude', 'gemini', 'openrouter'], true)) { | |
| 861 | + // '' is legal: it is Settings::AI_PROVIDER_NONE, the state a | |
| 862 | + // fresh install starts in and the one a user returns to by | |
| 863 | + // deselecting their provider (#572). | |
| 864 | + if (!in_array($value, \ThinkRank\Core\Settings::selectable_ai_providers(), true)) { | |
| 813 | 865 | $validation['valid'] = false; |
| 814 | - $validation['errors'][] = "ai_provider must be 'openai', 'claude', 'gemini', or 'openrouter'"; | |
| 866 | + $validation['errors'][] = "ai_provider must be empty (no provider) or one of: " | |
| 867 | + . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS); | |
| 815 | 868 | } |
| 816 | 869 | break; |
| 817 | 870 | |
| 818 | 871 | case 'dashboard_widgets': |