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 +329 -83 4.6.1 → 4.9.3 View file →
@@ -9,8 +9,11 @@
9 9 use WP_Error;
10 10 use WP_User;
11 11 use WPDeveloper\BetterDocs\Admin\Builder\GlobalFields;
12 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;
13 16 use WPDeveloper\BetterDocs\Utils\AIHelper;
14 17 use WPDeveloper\BetterDocs\Utils\Base;
15 18 use WPDeveloper\BetterDocs\Utils\Database;
16 19 use WPDeveloper\BetterDocs\Utils\Helper;
@@ -76,8 +79,31 @@
76 79 return esc_url( admin_url( 'admin.php?page=betterdocs-settings' ) );
77 80 }
78 81
79 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 + /**
80 106 * This method is responsible for enqueueing scripts in settings panel
81 107 *
82 108 * @param string $hook
83 109 *
@@ -94,9 +120,9 @@
94 120
95 121 $settings = GlobalFields::normalize( $this->settings_args() );
96 122
97 123 // Mask sensitive API keys before they reach the browser. Non-admins still get them stripped entirely below.
98 - $sensitive_api_keys = array( 'ai_autowrite_api_key', 'ai_chatbot_api_key' );
124 + $sensitive_api_keys = self::sensitive_api_key_fields();
99 125 foreach ( $sensitive_api_keys as $api_key_field ) {
100 126 if ( ! empty( $settings[ 'values' ][ $api_key_field ] ) ) {
101 127 $settings[ 'values' ][ $api_key_field ] = Helper::mask_api_key( $settings[ 'values' ][ $api_key_field ] );
102 128 }
@@ -257,8 +283,12 @@
257 283 'enable_breadcrumb_category' => true,
258 284 'enable_breadcrumb_title' => true,
259 285 'enable_sidebar_cat_list' => true,
260 286 'enable_print_icon' => true,
287 + 'print_enable_logo' => false,
288 + 'print_logo' => array(),
289 + 'print_enable_footer' => false,
290 + 'print_footer_text' => '',
261 291 'enable_tags' => true,
262 292 'email_feedback' => true,
263 293 'feedback_link_text' => __( 'Still stuck? How can we help?', 'betterdocs' ),
264 294 'reaction_feedback_text' => __( 'Thanks for your feedback', 'betterdocs' ),
@@ -271,8 +301,9 @@
271 301 'enable_credit' => false,
272 302 'enable_archive_sidebar' => true,
273 303 'archive_nested_subcategory' => true,
274 304 'archive_enable_pagination' => false,
305 + 'archive_lazy_load_descendants' => false,
275 306 'enable_content_restriction' => false,
276 307 'enable_reporting' => false,
277 308 'enable_sample_data' => false,
278 309 'reporting_day' => 'monday',
@@ -279,14 +310,34 @@
279 310 'reporting_email' => get_option( 'admin_email' ),
280 311 'enable_write_with_ai' => true,
281 312 'enable_faq_write_with_ai' => true,
282 313 'enable_glossaries_write_with_ai' => true,
314 + 'enable_docs_ai_suite' => true,
283 315 'write_with_ai_model' => 'gpt-4o-mini',
284 316 'ai_autowrite_api_key' => '',
285 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(),
286 326 'enable_article_summary' => false,
287 327 'article_summary_model' => 'gpt-4o-mini',
288 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' => '',
289 340 'enable_estimated_reading_time' => true,
290 341 'enable_encyclopedia' => false,
291 342 'enable_glossaries' => false,
292 343 'show_glossary_suggestions' => true,
@@ -296,9 +347,21 @@
296 347 'singular_estimated_reading_time_text' => __( 'min read', 'betterdocs' ),
297 348 'betterdocs_access_control_repeater' => array(),
298 349 'internal_knowledge_base_type' => 'basic',
299 350 'betterdocs_access_control_repeater_kb' => array(),
300 - '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' => ''
301 364 );
302 365
303 366 $_default = apply_filters( 'betterdocs_default_settings', $_default );
304 367 // $_default = apply_filters_deprecated(
@@ -591,19 +654,27 @@
591 654 }
592 655
593 656 $_old_settings = $this->database->get( $this->base_key, $this->get_default() );
594 657
595 - // The frontend only ever sees masked API keys. If a submitted value matches the mask of
596 - // the stored value, the user did not change it — restore the original so the mask string
597 - // is never persisted.
598 - $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();
599 667 foreach ( $sensitive_api_keys as $api_key_field ) {
600 668 if ( ! isset( $settings[ $api_key_field ] ) ) {
601 669 continue;
602 670 }
603 - $stored = isset( $_old_settings[ $api_key_field ] ) ? $_old_settings[ $api_key_field ] : '';
604 - if ( '' !== $stored && trim( (string) $settings[ $api_key_field ] ) === Helper::mask_api_key( $stored ) ) {
605 - $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;
606 677 }
607 678 }
608 679
609 680 // Minimum-token policy: reject saves where a feature's max_token field is below the
@@ -618,9 +689,9 @@
618 689 array(
619 690 'tab' => 'tab-betterdocs-ai',
620 691 'context' => 'write_with_ai',
621 692 'token_key' => 'ai_autowrite_max_token',
622 - 'model_key' => 'write_with_ai_model',
693 + 'model_key' => 'ai_model',
623 694 'label' => __( 'Write with AI', 'betterdocs' )
624 695 ),
625 696 array(
626 697 'tab' => 'tab-betterdocs-ai',
@@ -625,9 +696,9 @@
625 696 array(
626 697 'tab' => 'tab-betterdocs-ai',
627 698 'context' => 'article_summary',
628 699 'token_key' => 'article_summary_max_token',
629 - 'model_key' => 'article_summary_model',
700 + 'model_key' => 'ai_model',
630 701 'label' => __( 'AI Doc Summarizer', 'betterdocs' )
631 702 )
632 703 );
633 704 foreach ( $token_pairs as $pair ) {
@@ -662,21 +733,38 @@
662 733 betterdocs()->kbmigration->migrate();
663 734 }
664 735 $_settings = wp_parse_args( $_normalized_settings, $_old_settings );
665 736
666 - // Check if there are actual changes before saving.
667 - // update_option returns false when values serialize identically, which can happen
668 - // due to object caching or type normalization even when user made changes.
669 - $_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;
670 759
671 760 $_saved = $this->database->save( $this->base_key, $_settings );
672 761
673 762 do_action_ref_array( 'betterdocs::settings::saved', array( $_saved, $_settings, $_old_settings, &$this ) );
674 763
675 - // Return true if save succeeded OR if there were changes to attempt saving.
676 - // This handles cases where update_option returns false due to identical serialization
677 - // (e.g., object caching, type coercion during serialization).
678 - 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;
679 767 }
680 768
681 769 public function views( $hook ) {
682 770 return betterdocs()->views->get( 'admin/settings' );
@@ -999,8 +1087,17 @@
999 1087 'enable_disable_text_active' => true,
1000 1088 'default' => 1,
1001 1089 'priority' => 1
1002 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 + ),
1003 1100 'nested_subcategory' => array(
1004 1101 'name' => 'nested_subcategory',
1005 1102 'type' => 'toggle',
1006 1103 'label' => __( 'Nested Sub Category', 'betterdocs' ),
@@ -1005,9 +1102,9 @@
1005 1102 'type' => 'toggle',
1006 1103 'label' => __( 'Nested Sub Category', 'betterdocs' ),
1007 1104 'enable_disable_text_active' => true,
1008 1105 'default' => '',
1009 - 'priority' => 2
1106 + 'priority' => 3
1010 1107 ),
1011 1108 'column_number' => array(
1012 1109 'name' => 'column_number',
1013 1110 'type' => 'number',
@@ -1013,9 +1110,9 @@
1013 1110 'type' => 'number',
1014 1111 'label' => __( 'Number Of Columns', 'betterdocs' ),
1015 1112 'label_subtitle' => __( 'This setting is not applicable for sleek layout.', 'betterdocs' ),
1016 1113 'default' => 3,
1017 - 'priority' => 3
1114 + 'priority' => 4
1018 1115 ),
1019 1116 'posts_number' => apply_filters( 'betterdocs_posts_number', array(
1020 1117 'name' => 'posts_number',
1021 1118 'type' => 'number',
@@ -1021,9 +1118,9 @@
1021 1118 'type' => 'number',
1022 1119 'label' => __( 'Number Of Docs', 'betterdocs' ),
1023 1120 'label_subtitle' => __( 'This setting is not applicable for handbook layout.', 'betterdocs' ),
1024 1121 'default' => 10,
1025 - 'priority' => 4
1122 + 'priority' => 5
1026 1123 ) ),
1027 1124 'post_count' => array(
1028 1125 'name' => 'post_count',
1029 1126 'type' => 'toggle',
@@ -1029,9 +1126,9 @@
1029 1126 'type' => 'toggle',
1030 1127 'label' => __( 'Doc Count', 'betterdocs' ),
1031 1128 'enable_disable_text_active' => true,
1032 1129 'default' => 1,
1033 - 'priority' => 5
1130 + 'priority' => 6
1034 1131 ),
1035 1132 'count_text' => array(
1036 1133 'name' => 'count_text',
1037 1134 'type' => 'text',
@@ -1036,9 +1133,9 @@
1036 1133 'name' => 'count_text',
1037 1134 'type' => 'text',
1038 1135 'label' => __( 'Count Text', 'betterdocs' ),
1039 1136 'default' => __( 'Docs', 'betterdocs' ),
1040 - 'priority' => 6
1137 + 'priority' => 7
1041 1138 ),
1042 1139 'count_text_singular' => array(
1043 1140 'name' => 'count_text_singular',
1044 1141 'type' => 'text',
@@ -1043,9 +1140,9 @@
1043 1140 'name' => 'count_text_singular',
1044 1141 'type' => 'text',
1045 1142 'label' => __( 'Count Text Singular', 'betterdocs' ),
1046 1143 'default' => __( 'Doc', 'betterdocs' ),
1047 - 'priority' => 7
1144 + 'priority' => 8
1048 1145 ),
1049 1146 'exploremore_btn' => array(
1050 1147 'name' => 'exploremore_btn',
1051 1148 'type' => 'toggle',
@@ -1051,9 +1148,9 @@
1051 1148 'type' => 'toggle',
1052 1149 'label' => __( 'Explore More Button', 'betterdocs' ),
1053 1150 'enable_disable_text_active' => true,
1054 1151 'default' => true,
1055 - 'priority' => 8
1152 + 'priority' => 9
1056 1153 ),
1057 1154 'exploremore_btn_txt' => array(
1058 1155 'name' => 'exploremore_btn_txt',
1059 1156 'type' => 'text',
@@ -1058,9 +1155,9 @@
1058 1155 'name' => 'exploremore_btn_txt',
1059 1156 'type' => 'text',
1060 1157 'label' => __( 'Explore More Button Text', 'betterdocs' ),
1061 1158 'default' => __( 'Explore More', 'betterdocs' ),
1062 - 'priority' => 9,
1159 + 'priority' => 10,
1063 1160 'rules' => Rules::is( 'exploremore_btn', true )
1064 1161 ),
1065 1162 'betterdocs_popular_docs_text' => array(
1066 1163 'name' => 'betterdocs_popular_docs_text',
@@ -1066,9 +1163,9 @@
1066 1163 'name' => 'betterdocs_popular_docs_text',
1067 1164 'type' => 'text',
1068 1165 'label' => __( 'Popular Docs Text', 'betterdocs' ),
1069 1166 'default' => __( 'Popular Docs', 'betterdocs' ),
1070 - 'priority' => 10,
1167 + 'priority' => 11,
1071 1168 'is_pro' => true
1072 1169 ),
1073 1170 'betterdocs_popular_docs_number' => array(
1074 1171 'name' => 'betterdocs_popular_docs_number',
@@ -1074,9 +1171,9 @@
1074 1171 'name' => 'betterdocs_popular_docs_number',
1075 1172 'type' => 'number',
1076 1173 'label' => __( 'Popular Docs Number', 'betterdocs' ),
1077 1174 'default' => 10,
1078 - 'priority' => 11,
1175 + 'priority' => 12,
1079 1176 'is_pro' => true
1080 1177 )
1081 1178 )
1082 1179 ),
@@ -1320,8 +1417,44 @@
1320 1417 'enable_disable_text_active' => true,
1321 1418 'default' => 1,
1322 1419 'priority' => 3
1323 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 + ),
1324 1457 'enable_tags' => array(
1325 1458 'name' => 'enable_tags',
1326 1459 'type' => 'toggle',
1327 1460 'label' => __( 'Tags', 'betterdocs' ),
@@ -1326,9 +1459,9 @@
1326 1459 'type' => 'toggle',
1327 1460 'label' => __( 'Tags', 'betterdocs' ),
1328 1461 'enable_disable_text_active' => true,
1329 1462 'default' => 1,
1330 - 'priority' => 4
1463 + 'priority' => 8
1331 1464 ),
1332 1465 'show_last_update_time' => array(
1333 1466 'name' => 'show_last_update_time',
1334 1467 'type' => 'toggle',
@@ -1334,9 +1467,9 @@
1334 1467 'type' => 'toggle',
1335 1468 'label' => __( 'Last Update Time', 'betterdocs' ),
1336 1469 'enable_disable_text_active' => true,
1337 1470 'default' => 1,
1338 - 'priority' => 5
1471 + 'priority' => 9
1339 1472 ),
1340 1473 'enable_navigation' => array(
1341 1474 'name' => 'enable_navigation',
1342 1475 'type' => 'toggle',
@@ -1342,9 +1475,9 @@
1342 1475 'type' => 'toggle',
1343 1476 'label' => __( 'Navigation', 'betterdocs' ),
1344 1477 'enable_disable_text_active' => true,
1345 1478 'default' => 1,
1346 - 'priority' => 6
1479 + 'priority' => 10
1347 1480 ),
1348 1481 'enable_comment' => array(
1349 1482 'name' => 'enable_comment',
1350 1483 'type' => 'toggle',
@@ -1350,9 +1483,9 @@
1350 1483 'type' => 'toggle',
1351 1484 'label' => __( 'Comment', 'betterdocs' ),
1352 1485 'enable_disable_text_active' => true,
1353 1486 'default' => '',
1354 - 'priority' => 7
1487 + 'priority' => 11
1355 1488 ),
1356 1489 'enable_credit' => array(
1357 1490 'name' => 'enable_credit',
1358 1491 'type' => 'toggle',
@@ -1358,9 +1491,9 @@
1358 1491 'type' => 'toggle',
1359 1492 'label' => __( 'Show Powered by BetterDocs', 'betterdocs' ),
1360 1493 'enable_disable_text_active' => true,
1361 1494 'default' => '',
1362 - 'priority' => 8
1495 + 'priority' => 12
1363 1496 ),
1364 1497 'reaction_feedback_text' => array(
1365 1498 'name' => 'reaction_feedback_text',
1366 1499 'type' => 'text',
@@ -1365,9 +1498,9 @@
1365 1498 'name' => 'reaction_feedback_text',
1366 1499 'type' => 'text',
1367 1500 'label' => __( 'Reaction Feedback Text', 'betterdocs' ),
1368 1501 'default' => __( 'Thanks for your feedback.', 'betterdocs' ),
1369 - 'priority' => 9
1502 + 'priority' => 13
1370 1503 ),
1371 1504 'enable_estimated_reading_time' => array(
1372 1505 'name' => 'enable_estimated_reading_time',
1373 1506 'type' => 'toggle',
@@ -1373,9 +1506,9 @@
1373 1506 'type' => 'toggle',
1374 1507 'label' => __( 'Estimated Reading Time', 'betterdocs' ),
1375 1508 'enable_disable_text_active' => true,
1376 1509 'default' => 0,
1377 - 'priority' => 10
1510 + 'priority' => 14
1378 1511 ),
1379 1512 'estimated_reading_time_title' => array(
1380 1513 'name' => 'estimated_reading_time_title',
1381 1514 'type' => 'text',
@@ -1380,9 +1513,9 @@
1380 1513 'name' => 'estimated_reading_time_title',
1381 1514 'type' => 'text',
1382 1515 'label' => __( 'Estimated Reading Time Title', 'betterdocs' ),
1383 1516 'default' => '',
1384 - 'priority' => 11,
1517 + 'priority' => 15,
1385 1518 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1386 1519 ),
1387 1520 'estimated_reading_time_text' => array(
1388 1521 'name' => 'estimated_reading_time_text',
@@ -1388,9 +1521,9 @@
1388 1521 'name' => 'estimated_reading_time_text',
1389 1522 'type' => 'text',
1390 1523 'label' => __( 'Estimated Reading Time Text', 'betterdocs' ),
1391 1524 'default' => __( 'min read', 'betterdocs' ),
1392 - 'priority' => 12,
1525 + 'priority' => 16,
1393 1526 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1394 1527 ),
1395 1528 'singular_estimated_reading_time_text' => array(
1396 1529 'name' => 'singular_estimated_reading_time_text',
@@ -1396,9 +1529,9 @@
1396 1529 'name' => 'singular_estimated_reading_time_text',
1397 1530 'type' => 'text',
1398 1531 'label' => __( 'Estimated Reading Time Text Singular', 'betterdocs' ),
1399 1532 'default' => __( 'min read', 'betterdocs' ),
1400 - 'priority' => 13,
1533 + 'priority' => 17,
1401 1534 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1402 1535 )
1403 1536 )
1404 1537 ),
@@ -2278,18 +2411,84 @@
2278 2411 'open-ai-settings' => array(
2279 2412 'id' => 'open-ai-settings',
2280 2413 'name' => 'open-ai-settings',
2281 2414 'type' => 'section',
2282 - 'label' => __( 'API Settings', 'betterdocs' ),
2415 + 'label' => __( 'AI Platform & API', 'betterdocs' ),
2283 2416 'priority' => 1,
2284 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.
2285 2437 'ai_autowrite_api_key' => array(
2286 2438 'name' => 'ai_autowrite_api_key',
2287 2439 'type' => 'text',
2288 - 'label' => __( 'API Key', 'betterdocs' ),
2289 - '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/' ) ),
2290 2442 'default' => '',
2291 - '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()
2292 2491 )
2293 2492 )
2294 2493 ),
2295 2494 'write-with-ai' => array(
@@ -2298,8 +2497,39 @@
2298 2497 'type' => 'section',
2299 2498 'label' => __( 'Write with AI', 'betterdocs' ),
2300 2499 'priority' => 5,
2301 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(
2302 2532 'enable_write_with_ai' => array(
2303 2533 'name' => 'enable_write_with_ai',
2304 2534 'type' => 'toggle',
2305 2535 'priority' => 0,
@@ -2323,29 +2553,20 @@
2323 2553 'priority' => 6,
2324 2554 'label' => __( 'Write Glossaries with AI', 'betterdocs' ),
2325 2555 'label_subtitle' => __( 'Generate AI based Glossary definitions from the Glossaries admin page', 'betterdocs' ),
2326 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,
2327 2567 'default' => true
2328 2568 ),
2329 - 'write_with_ai_model' => array(
2330 - 'name' => 'write_with_ai_model',
2331 - 'type' => 'select',
2332 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2333 - 'priority' => 20,
2334 - 'multiple' => false,
2335 - 'default' => 'gpt-4o-mini',
2336 - 'options' => GlobalFields::normalize_fields( array(
2337 - 'gpt-4o-mini' => 'GPT-4o Mini',
2338 - 'gpt-4o' => 'GPT-4o',
2339 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2340 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2341 - 'gpt-4.1' => 'GPT-4.1',
2342 - 'gpt-5-nano' => 'GPT-5 Nano',
2343 - 'gpt-5-mini' => 'GPT-5 Mini',
2344 - 'gpt-5' => 'GPT-5',
2345 - 'gpt-5.5' => 'GPT-5.5'
2346 - ) )
2347 - ),
2348 2569 'ai_autowrite_max_token' => array(
2349 2570 'name' => 'ai_autowrite_max_token',
2350 2571 'type' => 'min_token_number',
2351 2572 'label' => __( 'Set Max Tokens', 'betterdocs' ),
@@ -2352,13 +2573,57 @@
2352 2573 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2353 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' ) ),
2354 2575 'default' => 2500,
2355 2576 'priority' => 10,
2356 - 'model_field' => 'write_with_ai_model',
2577 + 'model_field' => 'ai_model',
2357 2578 'context' => 'write_with_ai',
2358 2579 'min_token_map' => AIHelper::get_min_tokens_map( 'write_with_ai' ),
2359 2580 'classes' => 'wprf-type-text'
2360 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 + )
2361 2626 )
2362 2627 ),
2363 2628 'article-summary' => array(
2364 2629 'id' => 'article-summary',
@@ -2383,31 +2648,12 @@
2383 2648 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2384 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' ) ),
2385 2650 'default' => 1500,
2386 2651 'priority' => 5,
2387 - 'model_field' => 'article_summary_model',
2652 + 'model_field' => 'ai_model',
2388 2653 'context' => 'article_summary',
2389 2654 'min_token_map' => AIHelper::get_min_tokens_map( 'article_summary' ),
2390 2655 'classes' => 'wprf-type-text'
2391 - ),
2392 - 'article_summary_model' => array(
2393 - 'name' => 'article_summary_model',
2394 - 'type' => 'select',
2395 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2396 - 'priority' => 10,
2397 - 'multiple' => false,
2398 - 'default' => 'gpt-4o-mini',
2399 - 'options' => GlobalFields::normalize_fields( array(
2400 - 'gpt-4o-mini' => 'GPT-4o Mini',
2401 - 'gpt-4o' => 'GPT-4o',
2402 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2403 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2404 - 'gpt-4.1' => 'GPT-4.1',
2405 - 'gpt-5-nano' => 'GPT-5 Nano',
2406 - 'gpt-5-mini' => 'GPT-5 Mini',
2407 - 'gpt-5' => 'GPT-5',
2408 - 'gpt-5.5' => 'GPT-5.5'
2409 - ) )
2410 2656 )
2411 2657 )
2412 2658 )
2413 2659 )