PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 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 All 202 releases
← All changes | includes/Core/Settings.php +435 -78 4.7.0 → 4.9.4 View file →
@@ -9,8 +9,10 @@
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;
13 15 use WPDeveloper\BetterDocs\REST\AIEdit;
14 16 use WPDeveloper\BetterDocs\Utils\AIHelper;
15 17 use WPDeveloper\BetterDocs\Utils\Base;
16 18 use WPDeveloper\BetterDocs\Utils\Database;
@@ -77,8 +79,31 @@
77 79 return esc_url( admin_url( 'admin.php?page=betterdocs-settings' ) );
78 80 }
79 81
80 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 + /**
81 106 * This method is responsible for enqueueing scripts in settings panel
82 107 *
83 108 * @param string $hook
84 109 *
@@ -95,9 +120,9 @@
95 120
96 121 $settings = GlobalFields::normalize( $this->settings_args() );
97 122
98 123 // Mask sensitive API keys before they reach the browser. Non-admins still get them stripped entirely below.
99 - $sensitive_api_keys = array( 'ai_autowrite_api_key', 'ai_chatbot_api_key', 'ga4_api_secret', 'maxmind_license_key', 'maxmind_account_id' );
124 + $sensitive_api_keys = self::sensitive_api_key_fields();
100 125 foreach ( $sensitive_api_keys as $api_key_field ) {
101 126 if ( ! empty( $settings[ 'values' ][ $api_key_field ] ) ) {
102 127 $settings[ 'values' ][ $api_key_field ] = Helper::mask_api_key( $settings[ 'values' ][ $api_key_field ] );
103 128 }
@@ -258,8 +283,12 @@
258 283 'enable_breadcrumb_category' => true,
259 284 'enable_breadcrumb_title' => true,
260 285 'enable_sidebar_cat_list' => true,
261 286 'enable_print_icon' => true,
287 + 'print_enable_logo' => false,
288 + 'print_logo' => array(),
289 + 'print_enable_footer' => false,
290 + 'print_footer_text' => '',
262 291 'enable_tags' => true,
263 292 'email_feedback' => true,
264 293 'feedback_link_text' => __( 'Still stuck? How can we help?', 'betterdocs' ),
265 294 'reaction_feedback_text' => __( 'Thanks for your feedback', 'betterdocs' ),
@@ -272,8 +301,9 @@
272 301 'enable_credit' => false,
273 302 'enable_archive_sidebar' => true,
274 303 'archive_nested_subcategory' => true,
275 304 'archive_enable_pagination' => false,
305 + 'archive_lazy_load_descendants' => false,
276 306 'enable_content_restriction' => false,
277 307 'enable_reporting' => false,
278 308 'enable_sample_data' => false,
279 309 'reporting_day' => 'monday',
@@ -295,8 +325,19 @@
295 325 'ai_edit_actions' => AIEdit::default_actions(),
296 326 'enable_article_summary' => false,
297 327 'article_summary_model' => 'gpt-4o-mini',
298 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' => '',
299 340 'enable_estimated_reading_time' => true,
300 341 'enable_encyclopedia' => false,
301 342 'enable_glossaries' => false,
302 343 'show_glossary_suggestions' => true,
@@ -306,9 +347,40 @@
306 347 'singular_estimated_reading_time_text' => __( 'min read', 'betterdocs' ),
307 348 'betterdocs_access_control_repeater' => array(),
308 349 'internal_knowledge_base_type' => 'basic',
309 350 'betterdocs_access_control_repeater_kb' => array(),
310 - 'enable_git_integration' => false
351 + 'enable_git_integration' => false,
352 + 'enable_ai_actions' => true,
353 + 'enable_markdown_endpoint' => true,
354 + 'ai_actions_button_label' => __( 'Copy page', 'betterdocs' ),
355 + 'ai_actions_prompt_template' => __( 'Read from {URL} so I can ask questions about it.', 'betterdocs' ),
356 + 'ai_actions_copy_page' => true,
357 + 'ai_actions_view_markdown' => true,
358 + 'ai_actions_chatgpt' => true,
359 + 'ai_actions_claude' => true,
360 + 'ai_actions_gemini' => true,
361 + 'ai_actions_perplexity' => false,
362 + 'ai_actions_grok' => false,
363 + 'enable_listen' => true,
364 + 'listen_button_label' => __( 'Listen', 'betterdocs' ),
365 + 'listen_show_speed' => true,
366 + // Words a synthetic voice gets through in a minute at rate 1.
367 + // Deliberately lower than the 200 the reading-time pill assumes — that
368 + // number describes silent reading. Only the player's duration estimate
369 + // uses this; see Core\Listen::WORDS_PER_MINUTE.
370 + 'listen_words_per_minute' => 180,
371 + /**
372 + * MCP master switch. Off by default; the toggle lives on the
373 + * BetterDocs → MCP page, not in the settings tree, and writes
374 + * through POST betterdocs/v1/settings.
375 + *
376 + * The key has to be listed here or `get()` cannot see it at all:
377 + * it answers `$default` for anything absent from the defaults
378 + * array, whatever the stored option holds.
379 + *
380 + * @since 4.9.0
381 + */
382 + 'enable_mcp' => ''
311 383 );
312 384
313 385 $_default = apply_filters( 'betterdocs_default_settings', $_default );
314 386 // $_default = apply_filters_deprecated(
@@ -609,9 +681,9 @@
609 681 // slips into storage, mask(mask) === mask, so an equality-only guard would faithfully
610 682 // preserve the corrupted value forever (round-2 follow-up #1). When the stored value is
611 683 // itself mask-shaped it is unrecoverable — clear it so the UI and the GeoIP/GA4 status
612 684 // surfaces honestly report a missing key instead of failing downstream with 401s.
613 - $sensitive_api_keys = array( 'ai_autowrite_api_key', 'ai_chatbot_api_key', 'ga4_api_secret', 'maxmind_license_key', 'maxmind_account_id' );
685 + $sensitive_api_keys = self::sensitive_api_key_fields();
614 686 foreach ( $sensitive_api_keys as $api_key_field ) {
615 687 if ( ! isset( $settings[ $api_key_field ] ) ) {
616 688 continue;
617 689 }
@@ -636,9 +708,9 @@
636 708 array(
637 709 'tab' => 'tab-betterdocs-ai',
638 710 'context' => 'write_with_ai',
639 711 'token_key' => 'ai_autowrite_max_token',
640 - 'model_key' => 'write_with_ai_model',
712 + 'model_key' => 'ai_model',
641 713 'label' => __( 'Write with AI', 'betterdocs' )
642 714 ),
643 715 array(
644 716 'tab' => 'tab-betterdocs-ai',
@@ -643,9 +715,9 @@
643 715 array(
644 716 'tab' => 'tab-betterdocs-ai',
645 717 'context' => 'article_summary',
646 718 'token_key' => 'article_summary_max_token',
647 - 'model_key' => 'article_summary_model',
719 + 'model_key' => 'ai_model',
648 720 'label' => __( 'AI Doc Summarizer', 'betterdocs' )
649 721 )
650 722 );
651 723 foreach ( $token_pairs as $pair ) {
@@ -680,21 +752,38 @@
680 752 betterdocs()->kbmigration->migrate();
681 753 }
682 754 $_settings = wp_parse_args( $_normalized_settings, $_old_settings );
683 755
684 - // Check if there are actual changes before saving.
685 - // update_option returns false when values serialize identically, which can happen
686 - // due to object caching or type normalization even when user made changes.
687 - $_has_changes = $_settings != $_old_settings;
756 + // Detect whether this save actually changes the effective settings.
757 + //
758 + // The stored option and the submitted payload are normalized differently:
759 + // an optional field can be ABSENT from storage yet arrive as '' (e.g.
760 + // Feedback URL), and array fields can be stored empty ( [] ) while their
761 + // normalized/default form is non-empty (e.g. Instant Answer's
762 + // display_ia_texonomy defaults to ['all']). Comparing the raw arrays
763 + // ( $_settings != $_old_settings ) therefore reported a phantom change on
764 + // every save, leaving the tab perpetually "dirty" and always toasting
765 + // "Changes Saved Successfully." instead of "There are no changes to be
766 + // saved." — see WPDevelopers/betterdocs-pro#78.
767 + //
768 + // Compare like-for-like instead: fill defaults on both sides and run both
769 + // through the same normalization, so semantically-equal states (absent vs
770 + // '', [] vs ['all'], 'on' vs true) collapse to identical values and only a
771 + // real edit registers. This is also more reliable than update_option()'s
772 + // return, which is false whenever values serialize identically under object
773 + // caching or type coercion even when the user did change something (#49).
774 + $_defaults = array_merge( $this->get_default(), $this->get_pro_defaults() );
775 + $_old_normalized = $this->get_normalized_values( wp_parse_args( $_old_settings, $_defaults ), $_defaults );
776 + $_new_normalized = $this->get_normalized_values( wp_parse_args( $_settings, $_defaults ), $_defaults );
777 + $_has_changes = $_new_normalized != $_old_normalized;
688 778
689 779 $_saved = $this->database->save( $this->base_key, $_settings );
690 780
691 781 do_action_ref_array( 'betterdocs::settings::saved', array( $_saved, $_settings, $_old_settings, &$this ) );
692 782
693 - // Return true if save succeeded OR if there were changes to attempt saving.
694 - // This handles cases where update_option returns false due to identical serialization
695 - // (e.g., object caching, type coercion during serialization).
696 - return $_saved || $_has_changes;
783 + // The success / no-changes toast reflects whether the user made a real
784 + // change, not update_option()'s (unreliable) return value.
785 + return $_has_changes;
697 786 }
698 787
699 788 public function views( $hook ) {
700 789 return betterdocs()->views->get( 'admin/settings' );
@@ -1017,8 +1106,17 @@
1017 1106 'enable_disable_text_active' => true,
1018 1107 'default' => 1,
1019 1108 'priority' => 1
1020 1109 ),
1110 + 'archive_lazy_load_descendants' => array(
1111 + 'name' => 'archive_lazy_load_descendants',
1112 + 'type' => 'toggle',
1113 + 'label' => __( 'Lazy Load Sidebar Docs & Subcategories', 'betterdocs' ),
1114 + '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' ),
1115 + 'enable_disable_text_active' => true,
1116 + 'default' => false,
1117 + 'priority' => 2
1118 + ),
1021 1119 'nested_subcategory' => array(
1022 1120 'name' => 'nested_subcategory',
1023 1121 'type' => 'toggle',
1024 1122 'label' => __( 'Nested Sub Category', 'betterdocs' ),
@@ -1023,9 +1121,9 @@
1023 1121 'type' => 'toggle',
1024 1122 'label' => __( 'Nested Sub Category', 'betterdocs' ),
1025 1123 'enable_disable_text_active' => true,
1026 1124 'default' => '',
1027 - 'priority' => 2
1125 + 'priority' => 3
1028 1126 ),
1029 1127 'column_number' => array(
1030 1128 'name' => 'column_number',
1031 1129 'type' => 'number',
@@ -1031,9 +1129,9 @@
1031 1129 'type' => 'number',
1032 1130 'label' => __( 'Number Of Columns', 'betterdocs' ),
1033 1131 'label_subtitle' => __( 'This setting is not applicable for sleek layout.', 'betterdocs' ),
1034 1132 'default' => 3,
1035 - 'priority' => 3
1133 + 'priority' => 4
1036 1134 ),
1037 1135 'posts_number' => apply_filters( 'betterdocs_posts_number', array(
1038 1136 'name' => 'posts_number',
1039 1137 'type' => 'number',
@@ -1039,9 +1137,9 @@
1039 1137 'type' => 'number',
1040 1138 'label' => __( 'Number Of Docs', 'betterdocs' ),
1041 1139 'label_subtitle' => __( 'This setting is not applicable for handbook layout.', 'betterdocs' ),
1042 1140 'default' => 10,
1043 - 'priority' => 4
1141 + 'priority' => 5
1044 1142 ) ),
1045 1143 'post_count' => array(
1046 1144 'name' => 'post_count',
1047 1145 'type' => 'toggle',
@@ -1047,9 +1145,9 @@
1047 1145 'type' => 'toggle',
1048 1146 'label' => __( 'Doc Count', 'betterdocs' ),
1049 1147 'enable_disable_text_active' => true,
1050 1148 'default' => 1,
1051 - 'priority' => 5
1149 + 'priority' => 6
1052 1150 ),
1053 1151 'count_text' => array(
1054 1152 'name' => 'count_text',
1055 1153 'type' => 'text',
@@ -1054,9 +1152,9 @@
1054 1152 'name' => 'count_text',
1055 1153 'type' => 'text',
1056 1154 'label' => __( 'Count Text', 'betterdocs' ),
1057 1155 'default' => __( 'Docs', 'betterdocs' ),
1058 - 'priority' => 6
1156 + 'priority' => 7
1059 1157 ),
1060 1158 'count_text_singular' => array(
1061 1159 'name' => 'count_text_singular',
1062 1160 'type' => 'text',
@@ -1061,9 +1159,9 @@
1061 1159 'name' => 'count_text_singular',
1062 1160 'type' => 'text',
1063 1161 'label' => __( 'Count Text Singular', 'betterdocs' ),
1064 1162 'default' => __( 'Doc', 'betterdocs' ),
1065 - 'priority' => 7
1163 + 'priority' => 8
1066 1164 ),
1067 1165 'exploremore_btn' => array(
1068 1166 'name' => 'exploremore_btn',
1069 1167 'type' => 'toggle',
@@ -1069,9 +1167,9 @@
1069 1167 'type' => 'toggle',
1070 1168 'label' => __( 'Explore More Button', 'betterdocs' ),
1071 1169 'enable_disable_text_active' => true,
1072 1170 'default' => true,
1073 - 'priority' => 8
1171 + 'priority' => 9
1074 1172 ),
1075 1173 'exploremore_btn_txt' => array(
1076 1174 'name' => 'exploremore_btn_txt',
1077 1175 'type' => 'text',
@@ -1076,9 +1174,9 @@
1076 1174 'name' => 'exploremore_btn_txt',
1077 1175 'type' => 'text',
1078 1176 'label' => __( 'Explore More Button Text', 'betterdocs' ),
1079 1177 'default' => __( 'Explore More', 'betterdocs' ),
1080 - 'priority' => 9,
1178 + 'priority' => 10,
1081 1179 'rules' => Rules::is( 'exploremore_btn', true )
1082 1180 ),
1083 1181 'betterdocs_popular_docs_text' => array(
1084 1182 'name' => 'betterdocs_popular_docs_text',
@@ -1084,9 +1182,9 @@
1084 1182 'name' => 'betterdocs_popular_docs_text',
1085 1183 'type' => 'text',
1086 1184 'label' => __( 'Popular Docs Text', 'betterdocs' ),
1087 1185 'default' => __( 'Popular Docs', 'betterdocs' ),
1088 - 'priority' => 10,
1186 + 'priority' => 11,
1089 1187 'is_pro' => true
1090 1188 ),
1091 1189 'betterdocs_popular_docs_number' => array(
1092 1190 'name' => 'betterdocs_popular_docs_number',
@@ -1092,9 +1190,9 @@
1092 1190 'name' => 'betterdocs_popular_docs_number',
1093 1191 'type' => 'number',
1094 1192 'label' => __( 'Popular Docs Number', 'betterdocs' ),
1095 1193 'default' => 10,
1096 - 'priority' => 11,
1194 + 'priority' => 12,
1097 1195 'is_pro' => true
1098 1196 )
1099 1197 )
1100 1198 ),
@@ -1338,8 +1436,44 @@
1338 1436 'enable_disable_text_active' => true,
1339 1437 'default' => 1,
1340 1438 'priority' => 3
1341 1439 ),
1440 + 'print_enable_logo' => array(
1441 + 'name' => 'print_enable_logo',
1442 + 'type' => 'toggle',
1443 + 'label' => __( 'Logo on Printed Doc', 'betterdocs' ),
1444 + 'label_subtitle' => __( 'Show a logo at the top of the printed / PDF page', 'betterdocs' ),
1445 + 'enable_disable_text_active' => true,
1446 + 'default' => 0,
1447 + 'priority' => 4
1448 + ),
1449 + 'print_logo' => array(
1450 + 'name' => 'print_logo',
1451 + 'type' => 'media',
1452 + 'value' => '',
1453 + 'label' => __( 'Print Logo', 'betterdocs' ),
1454 + 'label_subtitle' => __( 'Leave empty to use your site logo, or the site icon when no site logo is set', 'betterdocs' ),
1455 + 'priority' => 5,
1456 + 'rules' => Rules::is( 'print_enable_logo', true )
1457 + ),
1458 + 'print_enable_footer' => array(
1459 + 'name' => 'print_enable_footer',
1460 + 'type' => 'toggle',
1461 + 'label' => __( 'Footer on Printed Doc', 'betterdocs' ),
1462 + 'label_subtitle' => __( 'Show a footer on every page of the printed / PDF document', 'betterdocs' ),
1463 + 'enable_disable_text_active' => true,
1464 + 'default' => 0,
1465 + 'priority' => 6
1466 + ),
1467 + 'print_footer_text' => array(
1468 + 'name' => 'print_footer_text',
1469 + 'type' => 'textarea',
1470 + 'label' => __( 'Print Footer Text', 'betterdocs' ),
1471 + 'label_subtitle' => __( 'Leave empty to use the site name and current year', 'betterdocs' ),
1472 + 'default' => '',
1473 + 'priority' => 7,
1474 + 'rules' => Rules::is( 'print_enable_footer', true )
1475 + ),
1342 1476 'enable_tags' => array(
1343 1477 'name' => 'enable_tags',
1344 1478 'type' => 'toggle',
1345 1479 'label' => __( 'Tags', 'betterdocs' ),
@@ -1344,9 +1478,9 @@
1344 1478 'type' => 'toggle',
1345 1479 'label' => __( 'Tags', 'betterdocs' ),
1346 1480 'enable_disable_text_active' => true,
1347 1481 'default' => 1,
1348 - 'priority' => 4
1482 + 'priority' => 8
1349 1483 ),
1350 1484 'show_last_update_time' => array(
1351 1485 'name' => 'show_last_update_time',
1352 1486 'type' => 'toggle',
@@ -1352,9 +1486,9 @@
1352 1486 'type' => 'toggle',
1353 1487 'label' => __( 'Last Update Time', 'betterdocs' ),
1354 1488 'enable_disable_text_active' => true,
1355 1489 'default' => 1,
1356 - 'priority' => 5
1490 + 'priority' => 9
1357 1491 ),
1358 1492 'enable_navigation' => array(
1359 1493 'name' => 'enable_navigation',
1360 1494 'type' => 'toggle',
@@ -1360,9 +1494,9 @@
1360 1494 'type' => 'toggle',
1361 1495 'label' => __( 'Navigation', 'betterdocs' ),
1362 1496 'enable_disable_text_active' => true,
1363 1497 'default' => 1,
1364 - 'priority' => 6
1498 + 'priority' => 10
1365 1499 ),
1366 1500 'enable_comment' => array(
1367 1501 'name' => 'enable_comment',
1368 1502 'type' => 'toggle',
@@ -1368,9 +1502,9 @@
1368 1502 'type' => 'toggle',
1369 1503 'label' => __( 'Comment', 'betterdocs' ),
1370 1504 'enable_disable_text_active' => true,
1371 1505 'default' => '',
1372 - 'priority' => 7
1506 + 'priority' => 11
1373 1507 ),
1374 1508 'enable_credit' => array(
1375 1509 'name' => 'enable_credit',
1376 1510 'type' => 'toggle',
@@ -1376,9 +1510,9 @@
1376 1510 'type' => 'toggle',
1377 1511 'label' => __( 'Show Powered by BetterDocs', 'betterdocs' ),
1378 1512 'enable_disable_text_active' => true,
1379 1513 'default' => '',
1380 - 'priority' => 8
1514 + 'priority' => 12
1381 1515 ),
1382 1516 'reaction_feedback_text' => array(
1383 1517 'name' => 'reaction_feedback_text',
1384 1518 'type' => 'text',
@@ -1383,9 +1517,9 @@
1383 1517 'name' => 'reaction_feedback_text',
1384 1518 'type' => 'text',
1385 1519 'label' => __( 'Reaction Feedback Text', 'betterdocs' ),
1386 1520 'default' => __( 'Thanks for your feedback.', 'betterdocs' ),
1387 - 'priority' => 9
1521 + 'priority' => 13
1388 1522 ),
1389 1523 'enable_estimated_reading_time' => array(
1390 1524 'name' => 'enable_estimated_reading_time',
1391 1525 'type' => 'toggle',
@@ -1391,9 +1525,9 @@
1391 1525 'type' => 'toggle',
1392 1526 'label' => __( 'Estimated Reading Time', 'betterdocs' ),
1393 1527 'enable_disable_text_active' => true,
1394 1528 'default' => 0,
1395 - 'priority' => 10
1529 + 'priority' => 14
1396 1530 ),
1397 1531 'estimated_reading_time_title' => array(
1398 1532 'name' => 'estimated_reading_time_title',
1399 1533 'type' => 'text',
@@ -1398,9 +1532,9 @@
1398 1532 'name' => 'estimated_reading_time_title',
1399 1533 'type' => 'text',
1400 1534 'label' => __( 'Estimated Reading Time Title', 'betterdocs' ),
1401 1535 'default' => '',
1402 - 'priority' => 11,
1536 + 'priority' => 15,
1403 1537 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1404 1538 ),
1405 1539 'estimated_reading_time_text' => array(
1406 1540 'name' => 'estimated_reading_time_text',
@@ -1406,9 +1540,9 @@
1406 1540 'name' => 'estimated_reading_time_text',
1407 1541 'type' => 'text',
1408 1542 'label' => __( 'Estimated Reading Time Text', 'betterdocs' ),
1409 1543 'default' => __( 'min read', 'betterdocs' ),
1410 - 'priority' => 12,
1544 + 'priority' => 16,
1411 1545 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1412 1546 ),
1413 1547 'singular_estimated_reading_time_text' => array(
1414 1548 'name' => 'singular_estimated_reading_time_text',
@@ -1414,9 +1548,9 @@
1414 1548 'name' => 'singular_estimated_reading_time_text',
1415 1549 'type' => 'text',
1416 1550 'label' => __( 'Estimated Reading Time Text Singular', 'betterdocs' ),
1417 1551 'default' => __( 'min read', 'betterdocs' ),
1418 - 'priority' => 13,
1552 + 'priority' => 17,
1419 1553 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1420 1554 )
1421 1555 )
1422 1556 ),
@@ -1683,8 +1817,202 @@
1683 1817 'enable_disable_text_active' => true,
1684 1818 'default' => false
1685 1819 )
1686 1820 ) )
1821 + ),
1822 + 'layout_single_doc_ai_actions' => array(
1823 + 'id' => 'layout_single_doc_ai_actions',
1824 + 'name' => 'layout_single_doc_ai_actions',
1825 + 'type' => 'section',
1826 + 'label' => __( 'AI Actions', 'betterdocs' ),
1827 + 'priority' => 8,
1828 + 'fields' => apply_filters( 'betterdocs_single_doc_ai_actions', array(
1829 + 'enable_ai_actions' => array(
1830 + 'name' => 'enable_ai_actions',
1831 + 'type' => 'toggle',
1832 + 'priority' => 1,
1833 + 'label' => __( 'Enable AI Actions', 'betterdocs' ),
1834 + 'label_subtitle' => __( 'Show a "Copy page" button beside the doc title so readers can copy the page as Markdown or open it in an AI assistant.', 'betterdocs' ),
1835 + 'enable_disable_text_active' => true,
1836 + 'default' => true
1837 + ),
1838 + 'enable_markdown_endpoint' => array(
1839 + 'name' => 'enable_markdown_endpoint',
1840 + 'type' => 'toggle',
1841 + 'priority' => 2,
1842 + 'label' => __( 'Serve Docs as Markdown', 'betterdocs' ),
1843 + // Plain text, not `<doc-url>.md`: the subtitle is rendered as HTML, so an
1844 + // angle-bracketed placeholder is swallowed as an unknown tag and the
1845 + // sentence read "Publish every doc at .md".
1846 + 'label_subtitle' => __( 'Publish every doc as Markdown at its address plus .md (for example /docs/getting-started.md), so AI assistants can read a clean copy. The Listen player uses it too. Required by "Copy page", "View as Markdown", and the Claude and Google AI Studio actions.', 'betterdocs' ),
1847 + 'enable_disable_text_active' => true,
1848 + 'default' => true
1849 + ),
1850 + // AI Actions and the Markdown endpoint are deliberately independent: the
1851 + // `.md` copies also serve AI assistants directly and the Listen player.
1852 + // So switching AI Actions off leaves Markdown on — say so where the
1853 + // admin is looking, rather than leaving a lone "Enabled" toggle under a
1854 + // feature that reads as switched off.
1855 + 'ai_actions_markdown_notice' => array(
1856 + 'name' => 'ai_actions_markdown_notice',
1857 + 'type' => 'html',
1858 + 'priority' => 3,
1859 + 'html' => sprintf(
1860 + '<div class="betterdocs-ai-actions-markdown-notice" style="margin:8px 0 0;padding:12px 16px;background:#f0f6ff;border-left:4px solid #5a6bff;border-radius:4px;font-size:13px;color:#202223;line-height:1.5;">%s</div>',
1861 + esc_html__( 'AI Actions is off, but your docs are still published as Markdown (.md) for AI assistants and the Listen player. Turn off "Serve Docs as Markdown" above if you don\'t want that.', 'betterdocs' )
1862 + ),
1863 + 'rules' => Rules::logicalRule( array(
1864 + Rules::is( 'enable_ai_actions', false ),
1865 + Rules::is( 'enable_markdown_endpoint', true )
1866 + ), 'and' )
1867 + ),
1868 + 'ai_actions_button_label' => array(
1869 + 'name' => 'ai_actions_button_label',
1870 + 'type' => 'text',
1871 + 'priority' => 4,
1872 + 'label' => __( 'Button Label', 'betterdocs' ),
1873 + 'default' => __( 'Copy page', 'betterdocs' ),
1874 + 'rules' => Rules::is( 'enable_ai_actions', true )
1875 + ),
1876 + 'ai_actions_prompt_template' => array(
1877 + 'name' => 'ai_actions_prompt_template',
1878 + 'type' => 'text',
1879 + 'priority' => 5,
1880 + 'label' => __( 'AI Prompt Template', 'betterdocs' ),
1881 + 'label_subtitle' => __( 'Sent to the AI assistant when a reader opens this doc there. Use {URL} for the doc address.', 'betterdocs' ),
1882 + 'default' => __( 'Read from {URL} so I can ask questions about it.', 'betterdocs' ),
1883 + 'rules' => Rules::is( 'enable_ai_actions', true )
1884 + ),
1885 + 'ai_actions_copy_page' => array(
1886 + 'name' => 'ai_actions_copy_page',
1887 + 'type' => 'toggle',
1888 + 'priority' => 6,
1889 + 'label' => __( 'Copy Page', 'betterdocs' ),
1890 + 'enable_disable_text_active' => true,
1891 + 'default' => true,
1892 + 'rules' => Rules::logicalRule( array(
1893 + Rules::is( 'enable_ai_actions', true ),
1894 + Rules::is( 'enable_markdown_endpoint', true )
1895 + ), 'and' )
1896 + ),
1897 + 'ai_actions_view_markdown' => array(
1898 + 'name' => 'ai_actions_view_markdown',
1899 + 'type' => 'toggle',
1900 + 'priority' => 7,
1901 + 'label' => __( 'View as Markdown', 'betterdocs' ),
1902 + 'enable_disable_text_active' => true,
1903 + 'default' => true,
1904 + 'rules' => Rules::logicalRule( array(
1905 + Rules::is( 'enable_ai_actions', true ),
1906 + Rules::is( 'enable_markdown_endpoint', true )
1907 + ), 'and' )
1908 + ),
1909 + 'ai_actions_chatgpt' => array(
1910 + 'name' => 'ai_actions_chatgpt',
1911 + 'type' => 'toggle',
1912 + 'priority' => 8,
1913 + 'label' => __( 'Open in ChatGPT', 'betterdocs' ),
1914 + 'enable_disable_text_active' => true,
1915 + 'default' => true,
1916 + 'rules' => Rules::is( 'enable_ai_actions', true )
1917 + ),
1918 + 'ai_actions_claude' => array(
1919 + 'name' => 'ai_actions_claude',
1920 + 'type' => 'toggle',
1921 + 'priority' => 9,
1922 + 'label' => __( 'Open in Claude', 'betterdocs' ),
1923 + 'enable_disable_text_active' => true,
1924 + 'default' => true,
1925 + 'rules' => Rules::logicalRule( array(
1926 + Rules::is( 'enable_ai_actions', true ),
1927 + Rules::is( 'enable_markdown_endpoint', true )
1928 + ), 'and' )
1929 + ),
1930 + 'ai_actions_gemini' => array(
1931 + 'name' => 'ai_actions_gemini',
1932 + 'type' => 'toggle',
1933 + 'priority' => 10,
1934 + 'label' => __( 'Open in Google AI Studio', 'betterdocs' ),
1935 + 'label_subtitle' => __( 'Gemini has no way to receive a prompt from a link, so this opens Google AI Studio instead. A Google account is required.', 'betterdocs' ),
1936 + 'enable_disable_text_active' => true,
1937 + 'default' => true,
1938 + 'rules' => Rules::logicalRule( array(
1939 + Rules::is( 'enable_ai_actions', true ),
1940 + Rules::is( 'enable_markdown_endpoint', true )
1941 + ), 'and' )
1942 + ),
1943 + 'ai_actions_perplexity' => array(
1944 + 'name' => 'ai_actions_perplexity',
1945 + 'type' => 'toggle',
1946 + 'priority' => 11,
1947 + 'label' => __( 'Open in Perplexity', 'betterdocs' ),
1948 + 'enable_disable_text_active' => true,
1949 + 'default' => false,
1950 + 'rules' => Rules::logicalRule( array(
1951 + Rules::is( 'enable_ai_actions', true ),
1952 + Rules::is( 'enable_markdown_endpoint', true )
1953 + ), 'and' )
1954 + ),
1955 + 'ai_actions_grok' => array(
1956 + 'name' => 'ai_actions_grok',
1957 + 'type' => 'toggle',
1958 + 'priority' => 12,
1959 + 'label' => __( 'Open in Grok', 'betterdocs' ),
1960 + 'enable_disable_text_active' => true,
1961 + 'default' => false,
1962 + 'rules' => Rules::logicalRule( array(
1963 + Rules::is( 'enable_ai_actions', true ),
1964 + Rules::is( 'enable_markdown_endpoint', true )
1965 + ), 'and' )
1966 + )
1967 + ) )
1968 + ),
1969 + 'layout_single_doc_listen' => array(
1970 + 'id' => 'layout_single_doc_listen',
1971 + 'name' => 'layout_single_doc_listen',
1972 + 'type' => 'section',
1973 + 'label' => __( 'Listen', 'betterdocs' ),
1974 + 'priority' => 9,
1975 + 'fields' => apply_filters( 'betterdocs_single_doc_listen', array(
1976 + 'enable_listen' => array(
1977 + 'name' => 'enable_listen',
1978 + 'type' => 'toggle',
1979 + 'priority' => 1,
1980 + 'label' => __( 'Enable Listen', 'betterdocs' ),
1981 + 'label_subtitle' => __( 'Show a "Listen" button beside the reading time. Pressed, it turns into a small audio player that reads the doc aloud using the visitor\'s browser — no audio files and no third-party service.', 'betterdocs' ),
1982 + 'enable_disable_text_active' => true,
1983 + 'default' => true
1984 + ),
1985 + 'listen_button_label' => array(
1986 + 'name' => 'listen_button_label',
1987 + 'type' => 'text',
1988 + 'priority' => 2,
1989 + 'label' => __( 'Button Label', 'betterdocs' ),
1990 + 'default' => __( 'Listen', 'betterdocs' ),
1991 + 'rules' => Rules::is( 'enable_listen', true )
1992 + ),
1993 + 'listen_show_speed' => array(
1994 + 'name' => 'listen_show_speed',
1995 + 'type' => 'toggle',
1996 + 'priority' => 3,
1997 + 'label' => __( 'Speed Control', 'betterdocs' ),
1998 + 'label_subtitle' => __( 'Let readers cycle the playback speed between 0.75× and 2×.', 'betterdocs' ),
1999 + 'enable_disable_text_active' => true,
2000 + 'default' => true,
2001 + 'rules' => Rules::is( 'enable_listen', true )
2002 + ),
2003 + 'listen_words_per_minute' => array(
2004 + 'name' => 'listen_words_per_minute',
2005 + 'type' => 'number',
2006 + 'priority' => 4,
2007 + 'label' => __( 'Words Per Minute', 'betterdocs' ),
2008 + 'label_subtitle' => __( 'Used to estimate the total playing time the player shows. Browser voices read at roughly 180 words a minute at normal speed; raise this if the countdown finishes early.', 'betterdocs' ),
2009 + 'default' => 180,
2010 + 'min' => 60,
2011 + 'max' => 400,
2012 + 'rules' => Rules::is( 'enable_listen', true )
2013 + )
2014 + ) )
1687 2015 )
1688 2016 )
1689 2017 )
1690 2018 )
@@ -2296,18 +2624,84 @@
2296 2624 'open-ai-settings' => array(
2297 2625 'id' => 'open-ai-settings',
2298 2626 'name' => 'open-ai-settings',
2299 2627 'type' => 'section',
2300 - 'label' => __( 'API Settings', 'betterdocs' ),
2628 + 'label' => __( 'AI Platform & API', 'betterdocs' ),
2301 2629 'priority' => 1,
2302 2630 'fields' => array(
2631 + 'ai_platform' => array(
2632 + 'name' => 'ai_platform',
2633 + 'type' => 'select',
2634 + 'label' => __( 'AI Platform', 'betterdocs' ),
2635 + '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' ),
2636 + 'priority' => 1,
2637 + 'multiple' => false,
2638 + 'default' => 'openai',
2639 + // DeepSeek and OpenRouter are temporarily hidden from the
2640 + // dropdown. Filter the options here, NOT ModelRegistry::platforms() —
2641 + // sensitive_api_key_fields() loops the registry to mask each
2642 + // platform's key field, so trimming the registry would silently
2643 + // un-mask those keys. Re-enable later by dropping the array_diff_key.
2644 + 'options' => GlobalFields::normalize_fields(
2645 + array_diff_key( ModelRegistry::platforms(), array_flip( array( 'deepseek', 'openrouter' ) ) )
2646 + )
2647 + ),
2648 + // OpenAI's key field keeps its original name so installs
2649 + // that already saved a Write with AI key keep it.
2303 2650 'ai_autowrite_api_key' => array(
2304 2651 'name' => 'ai_autowrite_api_key',
2305 2652 'type' => 'text',
2306 - 'label' => __( 'API Key', 'betterdocs' ),
2307 - '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/' ) ),
2653 + 'label' => __( 'OpenAI API Key', 'betterdocs' ),
2654 + '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/' ) ),
2308 2655 'default' => '',
2309 - 'priority' => 1
2656 + 'priority' => 2,
2657 + 'rules' => Rules::is( 'ai_platform', 'openai' )
2658 + ),
2659 + 'ai_api_key_gemini' => array(
2660 + 'name' => 'ai_api_key_gemini',
2661 + 'type' => 'text',
2662 + 'label' => __( 'Google Gemini API Key', 'betterdocs' ),
2663 + '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' ) ),
2664 + 'default' => '',
2665 + 'priority' => 2,
2666 + 'rules' => Rules::is( 'ai_platform', 'gemini' )
2667 + ),
2668 + 'ai_api_key_claude' => array(
2669 + 'name' => 'ai_api_key_claude',
2670 + 'type' => 'text',
2671 + 'label' => __( 'Anthropic Claude API Key', 'betterdocs' ),
2672 + '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' ) ),
2673 + 'default' => '',
2674 + 'priority' => 2,
2675 + 'rules' => Rules::is( 'ai_platform', 'claude' )
2676 + ),
2677 + 'ai_api_key_deepseek' => array(
2678 + 'name' => 'ai_api_key_deepseek',
2679 + 'type' => 'text',
2680 + 'label' => __( 'DeepSeek API Key', 'betterdocs' ),
2681 + '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' ) ),
2682 + 'default' => '',
2683 + 'priority' => 2,
2684 + 'rules' => Rules::is( 'ai_platform', 'deepseek' )
2685 + ),
2686 + 'ai_api_key_openrouter' => array(
2687 + 'name' => 'ai_api_key_openrouter',
2688 + 'type' => 'text',
2689 + 'label' => __( 'OpenRouter API Key', 'betterdocs' ),
2690 + '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' ) ),
2691 + 'default' => '',
2692 + 'priority' => 2,
2693 + 'rules' => Rules::is( 'ai_platform', 'openrouter' )
2694 + ),
2695 + 'ai_model' => array(
2696 + 'name' => 'ai_model',
2697 + 'type' => 'platform_model_select',
2698 + 'label' => __( 'Model', 'betterdocs' ),
2699 + 'label_subtitle' => __( 'Available models depend on the selected platform.', 'betterdocs' ),
2700 + 'priority' => 10,
2701 + 'default' => 'gpt-4o-mini',
2702 + 'catalogue' => ModelRegistry::catalogue(),
2703 + 'defaults' => ModelRegistry::defaults()
2310 2704 )
2311 2705 )
2312 2706 ),
2313 2707 'write-with-ai' => array(
@@ -2372,9 +2766,10 @@
2372 2766 'priority' => 6,
2373 2767 'label' => __( 'Write Glossaries with AI', 'betterdocs' ),
2374 2768 'label_subtitle' => __( 'Generate AI based Glossary definitions from the Glossaries admin page', 'betterdocs' ),
2375 2769 'enable_disable_text_active' => true,
2376 - 'default' => true
2770 + 'default' => true,
2771 + 'is_pro' => true
2377 2772 ),
2378 2773 'enable_docs_ai_suite' => array(
2379 2774 'name' => 'enable_docs_ai_suite',
2380 2775 'type' => 'toggle',
@@ -2383,27 +2778,8 @@
2383 2778 'label_subtitle' => __( 'Add BetterDocs AI actions for suggesting categories, tags and excerpts inside the native Docs editor panels.', 'betterdocs' ),
2384 2779 'enable_disable_text_active' => true,
2385 2780 'default' => true
2386 2781 ),
2387 - 'write_with_ai_model' => array(
2388 - 'name' => 'write_with_ai_model',
2389 - 'type' => 'select',
2390 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2391 - 'priority' => 20,
2392 - 'multiple' => false,
2393 - 'default' => 'gpt-4o-mini',
2394 - 'options' => GlobalFields::normalize_fields( array(
2395 - 'gpt-4o-mini' => 'GPT-4o Mini',
2396 - 'gpt-4o' => 'GPT-4o',
2397 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2398 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2399 - 'gpt-4.1' => 'GPT-4.1',
2400 - 'gpt-5-nano' => 'GPT-5 Nano',
2401 - 'gpt-5-mini' => 'GPT-5 Mini',
2402 - 'gpt-5' => 'GPT-5',
2403 - 'gpt-5.5' => 'GPT-5.5'
2404 - ) )
2405 - ),
2406 2782 'ai_autowrite_max_token' => array(
2407 2783 'name' => 'ai_autowrite_max_token',
2408 2784 'type' => 'min_token_number',
2409 2785 'label' => __( 'Set Max Tokens', 'betterdocs' ),
@@ -2410,9 +2786,9 @@
2410 2786 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2411 2787 __( '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' ) ),
2412 2788 'default' => 2500,
2413 2789 'priority' => 10,
2414 - 'model_field' => 'write_with_ai_model',
2790 + 'model_field' => 'ai_model',
2415 2791 'context' => 'write_with_ai',
2416 2792 'min_token_map' => AIHelper::get_min_tokens_map( 'write_with_ai' ),
2417 2793 'classes' => 'wprf-type-text'
2418 2794 )
@@ -2485,31 +2861,12 @@
2485 2861 'label_subtitle' => sprintf( // translators: %s is a link to more information about token limits.
2486 2862 __( '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' ) ),
2487 2863 'default' => 1500,
2488 2864 'priority' => 5,
2489 - 'model_field' => 'article_summary_model',
2865 + 'model_field' => 'ai_model',
2490 2866 'context' => 'article_summary',
2491 2867 'min_token_map' => AIHelper::get_min_tokens_map( 'article_summary' ),
2492 2868 'classes' => 'wprf-type-text'
2493 - ),
2494 - 'article_summary_model' => array(
2495 - 'name' => 'article_summary_model',
2496 - 'type' => 'select',
2497 - 'label' => __( 'OpenAI Model*', 'betterdocs' ),
2498 - 'priority' => 10,
2499 - 'multiple' => false,
2500 - 'default' => 'gpt-4o-mini',
2501 - 'options' => GlobalFields::normalize_fields( array(
2502 - 'gpt-4o-mini' => 'GPT-4o Mini',
2503 - 'gpt-4o' => 'GPT-4o',
2504 - 'gpt-4.1-nano' => 'GPT-4.1 Nano',
2505 - 'gpt-4.1-mini' => 'GPT-4.1 Mini',
2506 - 'gpt-4.1' => 'GPT-4.1',
2507 - 'gpt-5-nano' => 'GPT-5 Nano',
2508 - 'gpt-5-mini' => 'GPT-5 Mini',
2509 - 'gpt-5' => 'GPT-5',
2510 - 'gpt-5.5' => 'GPT-5.5'
2511 - ) )
2512 2869 )
2513 2870 )
2514 2871 )
2515 2872 )