PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 All 201 releases
← All changes | includes/Core/Settings.php +339 -85 4.5.4 → 4.9.3 View file →
@@ -1,12 +1,19 @@
1 1 <?php
2 +namespace WPDeveloper\BetterDocs\Core;
2 3
3 -namespace WPDeveloper\BetterDocs\Core;
4 +if ( ! defined( 'ABSPATH' ) ) {
5 + exit;
6 +}
4 7
8 +
5 9 use WP_Error;
6 10 use WP_User;
7 11 use WPDeveloper\BetterDocs\Admin\Builder\GlobalFields;
8 12 use WPDeveloper\BetterDocs\Admin\Builder\Rules;
13 +use WPDeveloper\BetterDocs\AI\ModelRegistry;
14 +use WPDeveloper\BetterDocs\AI\ProviderFactory;
15 +use WPDeveloper\BetterDocs\REST\AIEdit;
9 16 use WPDeveloper\BetterDocs\Utils\AIHelper;
10 17 use WPDeveloper\BetterDocs\Utils\Base;
11 18 use WPDeveloper\BetterDocs\Utils\Database;
12 19 use WPDeveloper\BetterDocs\Utils\Helper;
@@ -72,8 +79,31 @@
72 79 return esc_url( admin_url( 'admin.php?page=betterdocs-settings' ) );
73 80 }
74 81
75 82 /**
83 + * Settings keys holding secret API keys. These are masked before reaching
84 + * the browser, stripped for non-admins, and never persisted as their mask.
85 + *
86 + * Covers the AI Chatbot key plus every content-suite platform key. OpenAI's
87 + * is `ai_autowrite_api_key` (see ProviderFactory::key_field_for), hence the
88 + * dedupe.
89 + *
90 + * @return array<int,string>
91 + */
92 + public static function sensitive_api_key_fields() {
93 + $fields = array(
94 + ProviderFactory::OPENAI_KEY_FIELD,
95 + 'ai_chatbot_api_key',
96 + );
97 + foreach ( array_keys( ModelRegistry::platforms() ) as $platform ) {
98 + $fields[] = ProviderFactory::key_field_for( $platform );
99 + }
100 + // Add-ons (e.g. the AI Chatbot) register their own per-platform keys here
101 + // so they are masked in the browser and stripped for non-admins.
102 + return apply_filters( 'betterdocs_sensitive_api_key_fields', array_values( array_unique( $fields ) ) );
103 + }
104 +
105 + /**
76 106 * This method is responsible for enqueueing scripts in settings panel
77 107 *
78 108 * @param string $hook
79 109 *
@@ -90,9 +120,9 @@
90 120
91 121 $settings = GlobalFields::normalize( $this->settings_args() );
92 122
93 123 // Mask sensitive API keys before they reach the browser. Non-admins still get them stripped entirely below.
94 - $sensitive_api_keys = array( 'ai_autowrite_api_key', 'ai_chatbot_api_key' );
124 + $sensitive_api_keys = self::sensitive_api_key_fields();
95 125 foreach ( $sensitive_api_keys as $api_key_field ) {
96 126 if ( ! empty( $settings[ 'values' ][ $api_key_field ] ) ) {
97 127 $settings[ 'values' ][ $api_key_field ] = Helper::mask_api_key( $settings[ 'values' ][ $api_key_field ] );
98 128 }
@@ -198,8 +228,9 @@
198 228 */
199 229 public function get_default() {
200 230 $_default = array(
201 231 'multiple_kb' => '',
232 + 'enable_ai_sample_docs' => true,
202 233 'enable_export_faq' => true,
203 234 'builtin_doc_page' => true,
204 235 'breadcrumb_doc_title' => __( 'Docs', 'betterdocs' ),
205 236 'enable_category_hierarchy_slugs' => false,
@@ -252,8 +283,12 @@
252 283 'enable_breadcrumb_category' => true,
253 284 'enable_breadcrumb_title' => true,
254 285 'enable_sidebar_cat_list' => true,
255 286 'enable_print_icon' => true,
287 + 'print_enable_logo' => false,
288 + 'print_logo' => array(),
289 + 'print_enable_footer' => false,
290 + 'print_footer_text' => '',
256 291 'enable_tags' => true,
257 292 'email_feedback' => true,
258 293 'feedback_link_text' => __( 'Still stuck? How can we help?', 'betterdocs' ),
259 294 'reaction_feedback_text' => __( 'Thanks for your feedback', 'betterdocs' ),
@@ -266,8 +301,9 @@
266 301 'enable_credit' => false,
267 302 'enable_archive_sidebar' => true,
268 303 'archive_nested_subcategory' => true,
269 304 'archive_enable_pagination' => false,
305 + 'archive_lazy_load_descendants' => false,
270 306 'enable_content_restriction' => false,
271 307 'enable_reporting' => false,
272 308 'enable_sample_data' => false,
273 309 'reporting_day' => 'monday',
@@ -274,14 +310,34 @@
274 310 'reporting_email' => get_option( 'admin_email' ),
275 311 'enable_write_with_ai' => true,
276 312 'enable_faq_write_with_ai' => true,
277 313 'enable_glossaries_write_with_ai' => true,
314 + 'enable_docs_ai_suite' => true,
278 315 'write_with_ai_model' => 'gpt-4o-mini',
279 316 'ai_autowrite_api_key' => '',
280 317 'ai_autowrite_max_token' => 2500,
318 + 'write_with_ai_instructions' => array(
319 + array(
320 + 'id' => 'default',
321 + 'title' => __( 'Default/Core', 'betterdocs' ),
322 + 'content' => WriteWithAI::default_instruction_content()
323 + )
324 + ),
325 + 'ai_edit_actions' => AIEdit::default_actions(),
281 326 'enable_article_summary' => false,
282 327 'article_summary_model' => 'gpt-4o-mini',
283 328 'article_summary_max_token' => 1500,
329 + // Multi-platform AI (content suite). `ai_platform` selects the active
330 + // provider; `ai_model` is the single global model; keys are stored
331 + // per platform so switching never loses a saved key. OpenAI reuses
332 + // `ai_autowrite_api_key` above — it has always held an OpenAI key,
333 + // so nothing needs migrating.
334 + 'ai_platform' => 'openai',
335 + 'ai_model' => 'gpt-4o-mini',
336 + 'ai_api_key_gemini' => '',
337 + 'ai_api_key_claude' => '',
338 + 'ai_api_key_deepseek' => '',
339 + 'ai_api_key_openrouter' => '',
284 340 'enable_estimated_reading_time' => true,
285 341 'enable_encyclopedia' => false,
286 342 'enable_glossaries' => false,
287 343 'show_glossary_suggestions' => true,
@@ -291,9 +347,21 @@
291 347 'singular_estimated_reading_time_text' => __( 'min read', 'betterdocs' ),
292 348 'betterdocs_access_control_repeater' => array(),
293 349 'internal_knowledge_base_type' => 'basic',
294 350 'betterdocs_access_control_repeater_kb' => array(),
295 - 'enable_git_integration' => false
351 + 'enable_git_integration' => false,
352 + /**
353 + * MCP master switch. Off by default; the toggle lives on the
354 + * BetterDocs → MCP page, not in the settings tree, and writes
355 + * through POST betterdocs/v1/settings.
356 + *
357 + * The key has to be listed here or `get()` cannot see it at all:
358 + * it answers `$default` for anything absent from the defaults
359 + * array, whatever the stored option holds.
360 + *
361 + * @since 4.9.0
362 + */
363 + 'enable_mcp' => ''
296 364 );
297 365
298 366 $_default = apply_filters( 'betterdocs_default_settings', $_default );
299 367 // $_default = apply_filters_deprecated(
@@ -425,9 +493,10 @@
425 493 wp_send_json_error();
426 494 }
427 495
428 496 if ( isset( $_POST[ 'mode' ] ) ) {
429 - if ( $this->save( 'dark_mode', rest_sanitize_boolean( $_POST[ 'mode' ] ) ) ) { // phpcs:ignore
497 + $mode = sanitize_text_field( wp_unslash( $_POST[ 'mode' ] ) );
498 + if ( $this->save( 'dark_mode', rest_sanitize_boolean( $mode ) ) ) {
430 499 wp_send_json_success();
431 500 }
432 501 }
433 502
@@ -585,19 +654,27 @@
585 654 }
586 655
587 656 $_old_settings = $this->database->get( $this->base_key, $this->get_default() );
588 657
589 - // The frontend only ever sees masked API keys. If a submitted value matches the mask of
590 - // the stored value, the user did not change it — restore the original so the mask string
591 - // is never persisted.
592 - $sensitive_api_keys = array( 'ai_autowrite_api_key', 'ai_chatbot_api_key' );
658 + // The frontend only ever sees masked API keys. A submitted value that still looks like a
659 + // mask (contains a run of asterisks — no real key does) means "unchanged": restore the
660 + // stored original so a mask string is never persisted. Guarding on the mask SHAPE rather
661 + // than strict equality with mask(stored) closes the corruption loop QA hit: once a mask
662 + // slips into storage, mask(mask) === mask, so an equality-only guard would faithfully
663 + // preserve the corrupted value forever (round-2 follow-up #1). When the stored value is
664 + // itself mask-shaped it is unrecoverable — clear it so the UI and the GeoIP/GA4 status
665 + // surfaces honestly report a missing key instead of failing downstream with 401s.
666 + $sensitive_api_keys = self::sensitive_api_key_fields();
593 667 foreach ( $sensitive_api_keys as $api_key_field ) {
594 668 if ( ! isset( $settings[ $api_key_field ] ) ) {
595 669 continue;
596 670 }
597 - $stored = isset( $_old_settings[ $api_key_field ] ) ? $_old_settings[ $api_key_field ] : '';
598 - if ( '' !== $stored && trim( (string) $settings[ $api_key_field ] ) === Helper::mask_api_key( $stored ) ) {
599 - $settings[ $api_key_field ] = $stored;
671 + $incoming = trim( (string) $settings[ $api_key_field ] );
672 + $stored = isset( $_old_settings[ $api_key_field ] ) ? (string) $_old_settings[ $api_key_field ] : '';
673 + $stored_is_mask = '' !== $stored && preg_match( '/\*{4,}/', $stored );
674 +
675 + if ( '' !== $incoming && preg_match( '/\*{4,}/', $incoming ) ) {
676 + $settings[ $api_key_field ] = $stored_is_mask ? '' : $stored;
600 677 }
601 678 }
602 679
603 680 // Minimum-token policy: reject saves where a feature's max_token field is below the
@@ -612,9 +689,9 @@
612 689 array(
613 690 'tab' => 'tab-betterdocs-ai',
614 691 'context' => 'write_with_ai',
615 692 'token_key' => 'ai_autowrite_max_token',
616 - 'model_key' => 'write_with_ai_model',
693 + 'model_key' => 'ai_model',
617 694 'label' => __( 'Write with AI', 'betterdocs' )
618 695 ),
619 696 array(
620 697 'tab' => 'tab-betterdocs-ai',
@@ -619,9 +696,9 @@
619 696 array(
620 697 'tab' => 'tab-betterdocs-ai',
621 698 'context' => 'article_summary',
622 699 'token_key' => 'article_summary_max_token',
623 - 'model_key' => 'article_summary_model',
700 + 'model_key' => 'ai_model',
624 701 'label' => __( 'AI Doc Summarizer', 'betterdocs' )
625 702 )
626 703 );
627 704 foreach ( $token_pairs as $pair ) {
@@ -656,21 +733,38 @@
656 733 betterdocs()->kbmigration->migrate();
657 734 }
658 735 $_settings = wp_parse_args( $_normalized_settings, $_old_settings );
659 736
660 - // Check if there are actual changes before saving.
661 - // update_option returns false when values serialize identically, which can happen
662 - // due to object caching or type normalization even when user made changes.
663 - $_has_changes = $_settings != $_old_settings;
737 + // Detect whether this save actually changes the effective settings.
738 + //
739 + // The stored option and the submitted payload are normalized differently:
740 + // an optional field can be ABSENT from storage yet arrive as '' (e.g.
741 + // Feedback URL), and array fields can be stored empty ( [] ) while their
742 + // normalized/default form is non-empty (e.g. Instant Answer's
743 + // display_ia_texonomy defaults to ['all']). Comparing the raw arrays
744 + // ( $_settings != $_old_settings ) therefore reported a phantom change on
745 + // every save, leaving the tab perpetually "dirty" and always toasting
746 + // "Changes Saved Successfully." instead of "There are no changes to be
747 + // saved." — see WPDevelopers/betterdocs-pro#78.
748 + //
749 + // Compare like-for-like instead: fill defaults on both sides and run both
750 + // through the same normalization, so semantically-equal states (absent vs
751 + // '', [] vs ['all'], 'on' vs true) collapse to identical values and only a
752 + // real edit registers. This is also more reliable than update_option()'s
753 + // return, which is false whenever values serialize identically under object
754 + // caching or type coercion even when the user did change something (#49).
755 + $_defaults = array_merge( $this->get_default(), $this->get_pro_defaults() );
756 + $_old_normalized = $this->get_normalized_values( wp_parse_args( $_old_settings, $_defaults ), $_defaults );
757 + $_new_normalized = $this->get_normalized_values( wp_parse_args( $_settings, $_defaults ), $_defaults );
758 + $_has_changes = $_new_normalized != $_old_normalized;
664 759
665 760 $_saved = $this->database->save( $this->base_key, $_settings );
666 761
667 762 do_action_ref_array( 'betterdocs::settings::saved', array( $_saved, $_settings, $_old_settings, &$this ) );
668 763
669 - // Return true if save succeeded OR if there were changes to attempt saving.
670 - // This handles cases where update_option returns false due to identical serialization
671 - // (e.g., object caching, type coercion during serialization).
672 - return $_saved || $_has_changes;
764 + // The success / no-changes toast reflects whether the user made a real
765 + // change, not update_option()'s (unreliable) return value.
766 + return $_has_changes;
673 767 }
674 768
675 769 public function views( $hook ) {
676 770 return betterdocs()->views->get( 'admin/settings' );
@@ -993,8 +1087,17 @@
993 1087 'enable_disable_text_active' => true,
994 1088 'default' => 1,
995 1089 'priority' => 1
996 1090 ),
1091 + 'archive_lazy_load_descendants' => array(
1092 + 'name' => 'archive_lazy_load_descendants',
1093 + 'type' => 'toggle',
1094 + 'label' => __( 'Lazy Load Sidebar Docs & Subcategories', 'betterdocs' ),
1095 + 'label_subtitle' => __( 'Applies to the Sidebar layout wherever it appears — single docs, category archives, and the Sidebar widget/block. Off by default: enable to render only the active category upfront and fetch the rest on click (or on scroll for the Memphis layout), cutting initial HTML size on large knowledge bases.', 'betterdocs' ),
1096 + 'enable_disable_text_active' => true,
1097 + 'default' => false,
1098 + 'priority' => 2
1099 + ),
997 1100 'nested_subcategory' => array(
998 1101 'name' => 'nested_subcategory',
999 1102 'type' => 'toggle',
1000 1103 'label' => __( 'Nested Sub Category', 'betterdocs' ),
@@ -999,9 +1102,9 @@
999 1102 'type' => 'toggle',
1000 1103 'label' => __( 'Nested Sub Category', 'betterdocs' ),
1001 1104 'enable_disable_text_active' => true,
1002 1105 'default' => '',
1003 - 'priority' => 2
1106 + 'priority' => 3
1004 1107 ),
1005 1108 'column_number' => array(
1006 1109 'name' => 'column_number',
1007 1110 'type' => 'number',
@@ -1007,9 +1110,9 @@
1007 1110 'type' => 'number',
1008 1111 'label' => __( 'Number Of Columns', 'betterdocs' ),
1009 1112 'label_subtitle' => __( 'This setting is not applicable for sleek layout.', 'betterdocs' ),
1010 1113 'default' => 3,
1011 - 'priority' => 3
1114 + 'priority' => 4
1012 1115 ),
1013 1116 'posts_number' => apply_filters( 'betterdocs_posts_number', array(
1014 1117 'name' => 'posts_number',
1015 1118 'type' => 'number',
@@ -1015,9 +1118,9 @@
1015 1118 'type' => 'number',
1016 1119 'label' => __( 'Number Of Docs', 'betterdocs' ),
1017 1120 'label_subtitle' => __( 'This setting is not applicable for handbook layout.', 'betterdocs' ),
1018 1121 'default' => 10,
1019 - 'priority' => 4
1122 + 'priority' => 5
1020 1123 ) ),
1021 1124 'post_count' => array(
1022 1125 'name' => 'post_count',
1023 1126 'type' => 'toggle',
@@ -1023,9 +1126,9 @@
1023 1126 'type' => 'toggle',
1024 1127 'label' => __( 'Doc Count', 'betterdocs' ),
1025 1128 'enable_disable_text_active' => true,
1026 1129 'default' => 1,
1027 - 'priority' => 5
1130 + 'priority' => 6
1028 1131 ),
1029 1132 'count_text' => array(
1030 1133 'name' => 'count_text',
1031 1134 'type' => 'text',
@@ -1030,9 +1133,9 @@
1030 1133 'name' => 'count_text',
1031 1134 'type' => 'text',
1032 1135 'label' => __( 'Count Text', 'betterdocs' ),
1033 1136 'default' => __( 'Docs', 'betterdocs' ),
1034 - 'priority' => 6
1137 + 'priority' => 7
1035 1138 ),
1036 1139 'count_text_singular' => array(
1037 1140 'name' => 'count_text_singular',
1038 1141 'type' => 'text',
@@ -1037,9 +1140,9 @@
1037 1140 'name' => 'count_text_singular',
1038 1141 'type' => 'text',
1039 1142 'label' => __( 'Count Text Singular', 'betterdocs' ),
1040 1143 'default' => __( 'Doc', 'betterdocs' ),
1041 - 'priority' => 7
1144 + 'priority' => 8
1042 1145 ),
1043 1146 'exploremore_btn' => array(
1044 1147 'name' => 'exploremore_btn',
1045 1148 'type' => 'toggle',
@@ -1045,9 +1148,9 @@
1045 1148 'type' => 'toggle',
1046 1149 'label' => __( 'Explore More Button', 'betterdocs' ),
1047 1150 'enable_disable_text_active' => true,
1048 1151 'default' => true,
1049 - 'priority' => 8
1152 + 'priority' => 9
1050 1153 ),
1051 1154 'exploremore_btn_txt' => array(
1052 1155 'name' => 'exploremore_btn_txt',
1053 1156 'type' => 'text',
@@ -1052,9 +1155,9 @@
1052 1155 'name' => 'exploremore_btn_txt',
1053 1156 'type' => 'text',
1054 1157 'label' => __( 'Explore More Button Text', 'betterdocs' ),
1055 1158 'default' => __( 'Explore More', 'betterdocs' ),
1056 - 'priority' => 9,
1159 + 'priority' => 10,
1057 1160 'rules' => Rules::is( 'exploremore_btn', true )
1058 1161 ),
1059 1162 'betterdocs_popular_docs_text' => array(
1060 1163 'name' => 'betterdocs_popular_docs_text',
@@ -1060,9 +1163,9 @@
1060 1163 'name' => 'betterdocs_popular_docs_text',
1061 1164 'type' => 'text',
1062 1165 'label' => __( 'Popular Docs Text', 'betterdocs' ),
1063 1166 'default' => __( 'Popular Docs', 'betterdocs' ),
1064 - 'priority' => 10,
1167 + 'priority' => 11,
1065 1168 'is_pro' => true
1066 1169 ),
1067 1170 'betterdocs_popular_docs_number' => array(
1068 1171 'name' => 'betterdocs_popular_docs_number',
@@ -1068,9 +1171,9 @@
1068 1171 'name' => 'betterdocs_popular_docs_number',
1069 1172 'type' => 'number',
1070 1173 'label' => __( 'Popular Docs Number', 'betterdocs' ),
1071 1174 'default' => 10,
1072 - 'priority' => 11,
1175 + 'priority' => 12,
1073 1176 'is_pro' => true
1074 1177 )
1075 1178 )
1076 1179 ),
@@ -1097,8 +1200,9 @@
1097 1200 'default' => '',
1098 1201 'priority' => 2,
1099 1202 'is_pro' => true
1100 1203 ) ),
1204 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy public filter name for category-exclude settings field, retained for back-compat.
1101 1205 'child_category_exclude' => apply_filters( 'child_category_exclude', array(
1102 1206 'name' => 'child_category_exclude',
1103 1207 'type' => 'toggle',
1104 1208 'label' => __( 'Exclude Child Terms In Category Search', 'betterdocs' ),
@@ -1250,8 +1354,9 @@
1250 1354 )
1251 1355 )
1252 1356 )
1253 1357 ),
1358 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy public filter name for single-doc setting section, retained for back-compat.
1254 1359 'layout_single_doc' => apply_filters( 'single_doc_setting_section', array(
1255 1360 'id' => 'layout_single_doc',
1256 1361 'name' => 'layout_single_doc',
1257 1362 'type' => 'section',
@@ -1312,8 +1417,44 @@
1312 1417 'enable_disable_text_active' => true,
1313 1418 'default' => 1,
1314 1419 'priority' => 3
1315 1420 ),
1421 + 'print_enable_logo' => array(
1422 + 'name' => 'print_enable_logo',
1423 + 'type' => 'toggle',
1424 + 'label' => __( 'Logo on Printed Doc', 'betterdocs' ),
1425 + 'label_subtitle' => __( 'Show a logo at the top of the printed / PDF page', 'betterdocs' ),
1426 + 'enable_disable_text_active' => true,
1427 + 'default' => 0,
1428 + 'priority' => 4
1429 + ),
1430 + 'print_logo' => array(
1431 + 'name' => 'print_logo',
1432 + 'type' => 'media',
1433 + 'value' => '',
1434 + 'label' => __( 'Print Logo', 'betterdocs' ),
1435 + 'label_subtitle' => __( 'Leave empty to use your site logo, or the site icon when no site logo is set', 'betterdocs' ),
1436 + 'priority' => 5,
1437 + 'rules' => Rules::is( 'print_enable_logo', true )
1438 + ),
1439 + 'print_enable_footer' => array(
1440 + 'name' => 'print_enable_footer',
1441 + 'type' => 'toggle',
1442 + 'label' => __( 'Footer on Printed Doc', 'betterdocs' ),
1443 + 'label_subtitle' => __( 'Show a footer on every page of the printed / PDF document', 'betterdocs' ),
1444 + 'enable_disable_text_active' => true,
1445 + 'default' => 0,
1446 + 'priority' => 6
1447 + ),
1448 + 'print_footer_text' => array(
1449 + 'name' => 'print_footer_text',
1450 + 'type' => 'textarea',
1451 + 'label' => __( 'Print Footer Text', 'betterdocs' ),
1452 + 'label_subtitle' => __( 'Leave empty to use the site name and current year', 'betterdocs' ),
1453 + 'default' => '',
1454 + 'priority' => 7,
1455 + 'rules' => Rules::is( 'print_enable_footer', true )
1456 + ),
1316 1457 'enable_tags' => array(
1317 1458 'name' => 'enable_tags',
1318 1459 'type' => 'toggle',
1319 1460 'label' => __( 'Tags', 'betterdocs' ),
@@ -1318,9 +1459,9 @@
1318 1459 'type' => 'toggle',
1319 1460 'label' => __( 'Tags', 'betterdocs' ),
1320 1461 'enable_disable_text_active' => true,
1321 1462 'default' => 1,
1322 - 'priority' => 4
1463 + 'priority' => 8
1323 1464 ),
1324 1465 'show_last_update_time' => array(
1325 1466 'name' => 'show_last_update_time',
1326 1467 'type' => 'toggle',
@@ -1326,9 +1467,9 @@
1326 1467 'type' => 'toggle',
1327 1468 'label' => __( 'Last Update Time', 'betterdocs' ),
1328 1469 'enable_disable_text_active' => true,
1329 1470 'default' => 1,
1330 - 'priority' => 5
1471 + 'priority' => 9
1331 1472 ),
1332 1473 'enable_navigation' => array(
1333 1474 'name' => 'enable_navigation',
1334 1475 'type' => 'toggle',
@@ -1334,9 +1475,9 @@
1334 1475 'type' => 'toggle',
1335 1476 'label' => __( 'Navigation', 'betterdocs' ),
1336 1477 'enable_disable_text_active' => true,
1337 1478 'default' => 1,
1338 - 'priority' => 6
1479 + 'priority' => 10
1339 1480 ),
1340 1481 'enable_comment' => array(
1341 1482 'name' => 'enable_comment',
1342 1483 'type' => 'toggle',
@@ -1342,9 +1483,9 @@
1342 1483 'type' => 'toggle',
1343 1484 'label' => __( 'Comment', 'betterdocs' ),
1344 1485 'enable_disable_text_active' => true,
1345 1486 'default' => '',
1346 - 'priority' => 7
1487 + 'priority' => 11
1347 1488 ),
1348 1489 'enable_credit' => array(
1349 1490 'name' => 'enable_credit',
1350 1491 'type' => 'toggle',
@@ -1350,9 +1491,9 @@
1350 1491 'type' => 'toggle',
1351 1492 'label' => __( 'Show Powered by BetterDocs', 'betterdocs' ),
1352 1493 'enable_disable_text_active' => true,
1353 1494 'default' => '',
1354 - 'priority' => 8
1495 + 'priority' => 12
1355 1496 ),
1356 1497 'reaction_feedback_text' => array(
1357 1498 'name' => 'reaction_feedback_text',
1358 1499 'type' => 'text',
@@ -1357,9 +1498,9 @@
1357 1498 'name' => 'reaction_feedback_text',
1358 1499 'type' => 'text',
1359 1500 'label' => __( 'Reaction Feedback Text', 'betterdocs' ),
1360 1501 'default' => __( 'Thanks for your feedback.', 'betterdocs' ),
1361 - 'priority' => 9
1502 + 'priority' => 13
1362 1503 ),
1363 1504 'enable_estimated_reading_time' => array(
1364 1505 'name' => 'enable_estimated_reading_time',
1365 1506 'type' => 'toggle',
@@ -1365,9 +1506,9 @@
1365 1506 'type' => 'toggle',
1366 1507 'label' => __( 'Estimated Reading Time', 'betterdocs' ),
1367 1508 'enable_disable_text_active' => true,
1368 1509 'default' => 0,
1369 - 'priority' => 10
1510 + 'priority' => 14
1370 1511 ),
1371 1512 'estimated_reading_time_title' => array(
1372 1513 'name' => 'estimated_reading_time_title',
1373 1514 'type' => 'text',
@@ -1372,9 +1513,9 @@
1372 1513 'name' => 'estimated_reading_time_title',
1373 1514 'type' => 'text',
1374 1515 'label' => __( 'Estimated Reading Time Title', 'betterdocs' ),
1375 1516 'default' => '',
1376 - 'priority' => 11,
1517 + 'priority' => 15,
1377 1518 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1378 1519 ),
1379 1520 'estimated_reading_time_text' => array(
1380 1521 'name' => 'estimated_reading_time_text',
@@ -1380,9 +1521,9 @@
1380 1521 'name' => 'estimated_reading_time_text',
1381 1522 'type' => 'text',
1382 1523 'label' => __( 'Estimated Reading Time Text', 'betterdocs' ),
1383 1524 'default' => __( 'min read', 'betterdocs' ),
1384 - 'priority' => 12,
1525 + 'priority' => 16,
1385 1526 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1386 1527 ),
1387 1528 'singular_estimated_reading_time_text' => array(
1388 1529 'name' => 'singular_estimated_reading_time_text',
@@ -1388,9 +1529,9 @@
1388 1529 'name' => 'singular_estimated_reading_time_text',
1389 1530 'type' => 'text',
1390 1531 'label' => __( 'Estimated Reading Time Text Singular', 'betterdocs' ),
1391 1532 'default' => __( 'min read', 'betterdocs' ),
1392 - 'priority' => 13,
1533 + 'priority' => 17,
1393 1534 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1394 1535 )
1395 1536 )
1396 1537 ),
@@ -2270,18 +2411,84 @@
2270 2411 'open-ai-settings' => array(
2271 2412 'id' => 'open-ai-settings',
2272 2413 'name' => 'open-ai-settings',
2273 2414 'type' => 'section',
2274 - 'label' => __( 'API Settings', 'betterdocs' ),
2415 + 'label' => __( 'AI Platform & API', 'betterdocs' ),
2275 2416 'priority' => 1,
2276 2417 'fields' => array(
2418 + 'ai_platform' => array(
2419 + 'name' => 'ai_platform',
2420 + 'type' => 'select',
2421 + 'label' => __( 'AI Platform', 'betterdocs' ),
2422 + 'label_subtitle' => __( 'Choose which AI platform powers Write with AI, AI Edit, the Doc Summarizer and Quality Score. The model list and API key field below adapt to your choice.', 'betterdocs' ),
2423 + 'priority' => 1,
2424 + 'multiple' => false,
2425 + 'default' => 'openai',
2426 + // DeepSeek and OpenRouter are temporarily hidden from the
2427 + // dropdown. Filter the options here, NOT ModelRegistry::platforms() —
2428 + // sensitive_api_key_fields() loops the registry to mask each
2429 + // platform's key field, so trimming the registry would silently
2430 + // un-mask those keys. Re-enable later by dropping the array_diff_key.
2431 + 'options' => GlobalFields::normalize_fields(
2432 + array_diff_key( ModelRegistry::platforms(), array_flip( array( 'deepseek', 'openrouter' ) ) )
2433 + )
2434 + ),
2435 + // OpenAI's key field keeps its original name so installs
2436 + // that already saved a Write with AI key keep it.
2277 2437 'ai_autowrite_api_key' => array(
2278 2438 'name' => 'ai_autowrite_api_key',
2279 2439 'type' => 'text',
2280 - 'label' => __( 'API Key', 'betterdocs' ),
2281 - 'label_subtitle' => sprintf( /* translators: %s is a link to the documentation about generating an OpenAI API key. */__( 'Check out this <a target="_blank" href="%s">documentation</a> to find out how to generate your OpenAI API Key.', 'betterdocs' ), esc_url( 'https://betterdocs.co/docs/write-with-ai/' ) ),
2440 + 'label' => __( 'OpenAI API Key', 'betterdocs' ),
2441 + 'label_subtitle' => sprintf( /* translators: %s: documentation URL */ __( 'Check out this <a target="_blank" href="%s">documentation</a> to generate your OpenAI API key.', 'betterdocs' ), esc_url( 'https://betterdocs.co/docs/write-with-ai/' ) ),
2282 2442 'default' => '',
2283 - 'priority' => 1
2443 + 'priority' => 2,
2444 + 'rules' => Rules::is( 'ai_platform', 'openai' )
2445 + ),
2446 + 'ai_api_key_gemini' => array(
2447 + 'name' => 'ai_api_key_gemini',
2448 + 'type' => 'text',
2449 + 'label' => __( 'Google Gemini API Key', 'betterdocs' ),
2450 + 'label_subtitle' => sprintf( /* translators: %s: Google AI Studio URL */ __( 'Generate a key from <a target="_blank" href="%s">Google AI Studio</a>.', 'betterdocs' ), esc_url( 'https://aistudio.google.com/app/apikey' ) ),
2451 + 'default' => '',
2452 + 'priority' => 2,
2453 + 'rules' => Rules::is( 'ai_platform', 'gemini' )
2454 + ),
2455 + 'ai_api_key_claude' => array(
2456 + 'name' => 'ai_api_key_claude',
2457 + 'type' => 'text',
2458 + 'label' => __( 'Anthropic Claude API Key', 'betterdocs' ),
2459 + 'label_subtitle' => sprintf( /* translators: %s: Anthropic Console URL */ __( 'Generate a key from the <a target="_blank" href="%s">Anthropic Console</a>.', 'betterdocs' ), esc_url( 'https://console.anthropic.com/settings/keys' ) ),
2460 + 'default' => '',
2461 + 'priority' => 2,
2462 + 'rules' => Rules::is( 'ai_platform', 'claude' )
2463 + ),
2464 + 'ai_api_key_deepseek' => array(
2465 + 'name' => 'ai_api_key_deepseek',
2466 + 'type' => 'text',
2467 + 'label' => __( 'DeepSeek API Key', 'betterdocs' ),
2468 + 'label_subtitle' => sprintf( /* translators: %s: DeepSeek platform URL */ __( 'Generate a key from the <a target="_blank" href="%s">DeepSeek platform</a>.', 'betterdocs' ), esc_url( 'https://platform.deepseek.com/api_keys' ) ),
2469 + 'default' => '',
2470 + 'priority' => 2,
2471 + 'rules' => Rules::is( 'ai_platform', 'deepseek' )
2472 + ),
2473 + 'ai_api_key_openrouter' => array(
2474 + 'name' => 'ai_api_key_openrouter',
2475 + 'type' => 'text',
2476 + 'label' => __( 'OpenRouter API Key', 'betterdocs' ),
2477 + 'label_subtitle' => sprintf( /* translators: %s: OpenRouter keys URL */ __( 'Generate a key from <a target="_blank" href="%s">OpenRouter</a>.', 'betterdocs' ), esc_url( 'https://openrouter.ai/keys' ) ),
2478 + 'default' => '',
2479 + 'priority' => 2,
2480 + 'rules' => Rules::is( 'ai_platform', 'openrouter' )
2481 + ),
2482 + 'ai_model' => array(
2483 + 'name' => 'ai_model',
2484 + 'type' => 'platform_model_select',
2485 + 'label' => __( 'Model', 'betterdocs' ),
2486 + 'label_subtitle' => __( 'Available models depend on the selected platform.', 'betterdocs' ),
2487 + 'priority' => 10,
2488 + 'default' => 'gpt-4o-mini',
2489 + 'catalogue' => ModelRegistry::catalogue(),
2490 + 'defaults' => ModelRegistry::defaults()
2284 2491 )
2285 2492 )
2286 2493 ),
2287 2494 'write-with-ai' => array(
@@ -2290,8 +2497,39 @@
2290 2497 'type' => 'section',
2291 2498 'label' => __( 'Write with AI', 'betterdocs' ),
2292 2499 'priority' => 5,
2293 2500 'fields' => array(
2501 + 'write-with-ai-subtabs' => array(
2502 + 'id' => 'write-with-ai-subtabs',
2503 + 'name' => 'write_with_ai_subtabs',
2504 + 'label' => __( 'Write with AI', 'betterdocs' ),
2505 + 'classes' => 'tab-nested-layout',
2506 + 'type' => 'tab',
2507 + 'active' => 'wwa-configuration',
2508 + 'completionTrack' => true,
2509 + 'sidebar' => false,
2510 + 'save' => false,
2511 + 'title' => false,
2512 + 'config' => array(
2513 + 'active' => 'wwa-configuration',
2514 + 'sidebar' => false,
2515 + 'title' => false
2516 + ),
2517 + 'submit' => array(
2518 + 'show' => false
2519 + ),
2520 + 'step' => array(
2521 + 'show' => false
2522 + ),
2523 + 'priority' => 5,
2524 + 'fields' => array(
2525 + 'wwa-configuration' => array(
2526 + 'id' => 'wwa-configuration',
2527 + 'name' => 'wwa-configuration',
2528 + 'type' => 'section',
2529 + 'label' => __( 'Configuration', 'betterdocs' ),
2530 + 'priority' => 1,
2531 + 'fields' => array(
2294 2532 'enable_write_with_ai' => array(
2295 2533 'name' => 'enable_write_with_ai',
2296 2534 'type' => 'toggle',
2297 2535 'priority' => 0,
@@ -2315,29 +2553,20 @@
2315 2553 'priority' => 6,
2316 2554 'label' => __( 'Write Glossaries with AI', 'betterdocs' ),
2317 2555 'label_subtitle' => __( 'Generate AI based Glossary definitions from the Glossaries admin page', 'betterdocs' ),
2318 2556 'enable_disable_text_active' => true,
2557 + 'default' => true,
2558 + 'is_pro' => true
2559 + ),
2560 + 'enable_docs_ai_suite' => array(
2561 + 'name' => 'enable_docs_ai_suite',
2562 + 'type' => 'toggle',
2563 + 'priority' => 7,
2564 + 'label' => __( 'Enhance Docs with AI', 'betterdocs' ),
2565 + 'label_subtitle' => __( 'Add BetterDocs AI actions for suggesting categories, tags and excerpts inside the native Docs editor panels.', 'betterdocs' ),
2566 + 'enable_disable_text_active' => true,
2319 2567 'default' => true
2320 2568 ),
2321 - 'write_with_ai_model' => array(
2322 - 'name' => 'write_with_ai_model',
2323 - 'type' => 'select',
2324 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2325 - 'priority' => 20,
2326 - 'multiple' => false,
2327 - 'default' => 'gpt-4o-mini',
2328 - 'options' => GlobalFields::normalize_fields( array(
2329 - 'gpt-4o-mini' => 'GPT-4o Mini',
2330 - 'gpt-4o' => 'GPT-4o',
2331 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2332 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2333 - 'gpt-4.1' => 'GPT-4.1',
2334 - 'gpt-5-nano' => 'GPT-5 Nano',
2335 - 'gpt-5-mini' => 'GPT-5 Mini',
2336 - 'gpt-5' => 'GPT-5',
2337 - 'gpt-5.5' => 'GPT-5.5'
2338 - ) )
2339 - ),
2340 2569 'ai_autowrite_max_token' => array(
2341 2570 'name' => 'ai_autowrite_max_token',
2342 2571 'type' => 'min_token_number',
2343 2572 'label' => __( 'Set Max Tokens', 'betterdocs' ),
@@ -2344,13 +2573,57 @@
2344 2573 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2345 2574 __( 'Documentation will be generated based on the Token Limits you have set. For more information on Token Limits, you can check out this <a target="_blank" href="%s">link</a>.', 'betterdocs' ), esc_url( 'https://platform.openai.com/account/limits' ) ),
2346 2575 'default' => 2500,
2347 2576 'priority' => 10,
2348 - 'model_field' => 'write_with_ai_model',
2577 + 'model_field' => 'ai_model',
2349 2578 'context' => 'write_with_ai',
2350 2579 'min_token_map' => AIHelper::get_min_tokens_map( 'write_with_ai' ),
2351 2580 'classes' => 'wprf-type-text'
2352 2581 )
2582 + )
2583 + ),
2584 + 'wwa-personalize' => array(
2585 + 'id' => 'wwa-personalize',
2586 + 'name' => 'wwa-personalize',
2587 + 'type' => 'section',
2588 + 'label' => __( 'Write AI Personalize', 'betterdocs' ),
2589 + 'priority' => 5,
2590 + 'fields' => array(
2591 + 'write_with_ai_instructions' => array(
2592 + 'name' => 'write_with_ai_instructions',
2593 + 'type' => 'wwa_instructions',
2594 + 'label' => __( 'Instructions', 'betterdocs' ),
2595 + 'label_subtitle' => __( 'Add reusable instruction sets to steer how BetterDocs AI writes. The "Default/Core" set is always applied; extra sets can be picked in the Write with AI popup.', 'betterdocs' ),
2596 + 'priority' => 1,
2597 + 'default' => array(
2598 + array(
2599 + 'id' => 'default',
2600 + 'title' => __( 'Default/Core', 'betterdocs' ),
2601 + 'content' => WriteWithAI::default_instruction_content()
2602 + )
2603 + )
2604 + )
2605 + )
2606 + ),
2607 + 'edit-ai-personalize' => array(
2608 + 'id' => 'edit-ai-personalize',
2609 + 'name' => 'edit-ai-personalize',
2610 + 'type' => 'section',
2611 + 'label' => __( 'Edit AI Personalize', 'betterdocs' ),
2612 + 'priority' => 7,
2613 + 'fields' => array(
2614 + 'ai_edit_actions' => array(
2615 + 'name' => 'ai_edit_actions',
2616 + 'type' => 'ai_edit_actions',
2617 + 'label' => __( 'Actions', 'betterdocs' ),
2618 + 'label_subtitle' => __( 'Choose which Edit with AI actions appear in the editor. Toggle actions on or off, edit each action\'s instruction, or add your own custom actions. All predefined actions are enabled by default.', 'betterdocs' ),
2619 + 'priority' => 1,
2620 + 'default' => AIEdit::default_actions()
2621 + )
2622 + )
2623 + )
2624 + )
2625 + )
2353 2626 )
2354 2627 ),
2355 2628 'article-summary' => array(
2356 2629 'id' => 'article-summary',
@@ -2375,31 +2648,12 @@
2375 2648 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2376 2649 __( 'Single Doc summarizer will be generated based on the token limits you have set. For more information on Token Limits, you can check out this <a target="_blank" href="%s">link</a>.', 'betterdocs' ), esc_url( 'https://platform.openai.com/account/limits' ) ),
2377 2650 'default' => 1500,
2378 2651 'priority' => 5,
2379 - 'model_field' => 'article_summary_model',
2652 + 'model_field' => 'ai_model',
2380 2653 'context' => 'article_summary',
2381 2654 'min_token_map' => AIHelper::get_min_tokens_map( 'article_summary' ),
2382 2655 'classes' => 'wprf-type-text'
2383 - ),
2384 - 'article_summary_model' => array(
2385 - 'name' => 'article_summary_model',
2386 - 'type' => 'select',
2387 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2388 - 'priority' => 10,
2389 - 'multiple' => false,
2390 - 'default' => 'gpt-4o-mini',
2391 - 'options' => GlobalFields::normalize_fields( array(
2392 - 'gpt-4o-mini' => 'GPT-4o Mini',
2393 - 'gpt-4o' => 'GPT-4o',
2394 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2395 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2396 - 'gpt-4.1' => 'GPT-4.1',
2397 - 'gpt-5-nano' => 'GPT-5 Nano',
2398 - 'gpt-5-mini' => 'GPT-5 Mini',
2399 - 'gpt-5' => 'GPT-5',
2400 - 'gpt-5.5' => 'GPT-5.5'
2401 - ) )
2402 2656 )
2403 2657 )
2404 2658 )
2405 2659 )