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 +105 -77 4.8.0 → 4.9.3 View file →
@@ -10,8 +10,9 @@
10 10 use WP_User;
11 11 use WPDeveloper\BetterDocs\Admin\Builder\GlobalFields;
12 12 use WPDeveloper\BetterDocs\Admin\Builder\Rules;
13 13 use WPDeveloper\BetterDocs\AI\ModelRegistry;
14 +use WPDeveloper\BetterDocs\AI\ProviderFactory;
14 15 use WPDeveloper\BetterDocs\REST\AIEdit;
15 16 use WPDeveloper\BetterDocs\Utils\AIHelper;
16 17 use WPDeveloper\BetterDocs\Utils\Base;
17 18 use WPDeveloper\BetterDocs\Utils\Database;
@@ -53,9 +54,8 @@
53 54
54 55 add_action( 'wp_ajax_betterdocs_dark_mode', array( $this, 'dark_mode' ) );
55 56 add_filter( 'betterdocs_settings_tab_advance', array( $this, 'hide_roles_management' ), 11, 1 );
56 57 add_action( 'betterdocs::settings::saved', array( $this, 'fallback_slugs' ), 99, 3 );
57 - add_action( 'admin_init', array( $this, 'maybe_migrate_ai_platform_settings' ) );
58 58 }
59 59
60 60 public function fallback_slugs( $_saved, $_settings, $_old_settings = array() ) {
61 61 $_default = $this->get_default();
@@ -82,72 +82,28 @@
82 82 /**
83 83 * Settings keys holding secret API keys. These are masked before reaching
84 84 * the browser, stripped for non-admins, and never persisted as their mask.
85 85 *
86 - * Includes the legacy single-key fields plus the per-platform content-suite
87 - * keys introduced with multi-platform support.
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.
88 89 *
89 90 * @return array<int,string>
90 91 */
91 92 public static function sensitive_api_key_fields() {
92 93 $fields = array(
93 - 'ai_autowrite_api_key',
94 + ProviderFactory::OPENAI_KEY_FIELD,
94 95 'ai_chatbot_api_key',
95 96 );
96 97 foreach ( array_keys( ModelRegistry::platforms() ) as $platform ) {
97 - $fields[] = 'ai_api_key_' . $platform;
98 + $fields[] = ProviderFactory::key_field_for( $platform );
98 99 }
99 100 // Add-ons (e.g. the AI Chatbot) register their own per-platform keys here
100 101 // so they are masked in the browser and stripped for non-admins.
101 - return apply_filters( 'betterdocs_sensitive_api_key_fields', $fields );
102 + return apply_filters( 'betterdocs_sensitive_api_key_fields', array_values( array_unique( $fields ) ) );
102 103 }
103 104
104 105 /**
105 - * One-time copy of pre-multi-platform AI settings onto the new keys so the
106 - * settings UI shows the user's existing OpenAI key and model under the new
107 - * platform-aware fields. Runtime already falls back via ProviderFactory, so
108 - * this only affects what the admin sees. Guarded by an option flag.
109 - *
110 - * Runs on `admin_init`, which fires for every logged-in user who loads
111 - * /wp-admin/ — including a Subscriber. Since this writes settings (and copies
112 - * the legacy OpenAI key onto `ai_api_key_openai`), it is gated on the same
113 - * `edit_docs_settings` capability used everywhere else for settings writes.
114 - * The one-shot flag is deliberately NOT set when the capability check fails,
115 - * otherwise the first low-privilege page load would permanently skip the
116 - * migration for administrators too.
117 - *
118 - * @return void
119 - */
120 - public function maybe_migrate_ai_platform_settings() {
121 - if ( get_option( 'betterdocs_ai_platform_migrated' ) ) {
122 - return;
123 - }
124 -
125 - if ( ! current_user_can( 'edit_docs_settings' ) ) {
126 - return;
127 - }
128 -
129 - if ( '' === (string) $this->get_raw_field( 'ai_platform', '' ) ) {
130 - $this->save( 'ai_platform', 'openai' );
131 - }
132 -
133 - $legacy_key = (string) $this->get( 'ai_autowrite_api_key', '' );
134 - if ( '' !== $legacy_key && '' === (string) $this->get_raw_field( 'ai_api_key_openai', '' ) ) {
135 - $this->save( 'ai_api_key_openai', $legacy_key );
136 - }
137 -
138 - if ( '' === (string) $this->get_raw_field( 'ai_model', '' ) ) {
139 - $legacy_model = (string) $this->get( 'write_with_ai_model', '' );
140 - if ( '' === $legacy_model ) {
141 - $legacy_model = (string) $this->get( 'article_summary_model', 'gpt-4o-mini' );
142 - }
143 - $this->save( 'ai_model', $legacy_model !== '' ? $legacy_model : 'gpt-4o-mini' );
144 - }
145 -
146 - update_option( 'betterdocs_ai_platform_migrated', 1 );
147 - }
148 -
149 - /**
150 106 * This method is responsible for enqueueing scripts in settings panel
151 107 *
152 108 * @param string $hook
153 109 *
@@ -327,8 +283,12 @@
327 283 'enable_breadcrumb_category' => true,
328 284 'enable_breadcrumb_title' => true,
329 285 'enable_sidebar_cat_list' => true,
330 286 'enable_print_icon' => true,
287 + 'print_enable_logo' => false,
288 + 'print_logo' => array(),
289 + 'print_enable_footer' => false,
290 + 'print_footer_text' => '',
331 291 'enable_tags' => true,
332 292 'email_feedback' => true,
333 293 'feedback_link_text' => __( 'Still stuck? How can we help?', 'betterdocs' ),
334 294 'reaction_feedback_text' => __( 'Thanks for your feedback', 'betterdocs' ),
@@ -367,13 +327,13 @@
367 327 'article_summary_model' => 'gpt-4o-mini',
368 328 'article_summary_max_token' => 1500,
369 329 // Multi-platform AI (content suite). `ai_platform` selects the active
370 330 // provider; `ai_model` is the single global model; keys are stored
371 - // per platform so switching never loses a saved key. Legacy keys
372 - // above are kept for back-compat and migrated on upgrade.
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.
373 334 'ai_platform' => 'openai',
374 335 'ai_model' => 'gpt-4o-mini',
375 - 'ai_api_key_openai' => '',
376 336 'ai_api_key_gemini' => '',
377 337 'ai_api_key_claude' => '',
378 338 'ai_api_key_deepseek' => '',
379 339 'ai_api_key_openrouter' => '',
@@ -387,9 +347,21 @@
387 347 'singular_estimated_reading_time_text' => __( 'min read', 'betterdocs' ),
388 348 'betterdocs_access_control_repeater' => array(),
389 349 'internal_knowledge_base_type' => 'basic',
390 350 'betterdocs_access_control_repeater_kb' => array(),
391 - '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' => ''
392 364 );
393 365
394 366 $_default = apply_filters( 'betterdocs_default_settings', $_default );
395 367 // $_default = apply_filters_deprecated(
@@ -761,21 +733,38 @@
761 733 betterdocs()->kbmigration->migrate();
762 734 }
763 735 $_settings = wp_parse_args( $_normalized_settings, $_old_settings );
764 736
765 - // Check if there are actual changes before saving.
766 - // update_option returns false when values serialize identically, which can happen
767 - // due to object caching or type normalization even when user made changes.
768 - $_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;
769 759
770 760 $_saved = $this->database->save( $this->base_key, $_settings );
771 761
772 762 do_action_ref_array( 'betterdocs::settings::saved', array( $_saved, $_settings, $_old_settings, &$this ) );
773 763
774 - // Return true if save succeeded OR if there were changes to attempt saving.
775 - // This handles cases where update_option returns false due to identical serialization
776 - // (e.g., object caching, type coercion during serialization).
777 - 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;
778 767 }
779 768
780 769 public function views( $hook ) {
781 770 return betterdocs()->views->get( 'admin/settings' );
@@ -1428,8 +1417,44 @@
1428 1417 'enable_disable_text_active' => true,
1429 1418 'default' => 1,
1430 1419 'priority' => 3
1431 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 + ),
1432 1457 'enable_tags' => array(
1433 1458 'name' => 'enable_tags',
1434 1459 'type' => 'toggle',
1435 1460 'label' => __( 'Tags', 'betterdocs' ),
@@ -1434,9 +1459,9 @@
1434 1459 'type' => 'toggle',
1435 1460 'label' => __( 'Tags', 'betterdocs' ),
1436 1461 'enable_disable_text_active' => true,
1437 1462 'default' => 1,
1438 - 'priority' => 4
1463 + 'priority' => 8
1439 1464 ),
1440 1465 'show_last_update_time' => array(
1441 1466 'name' => 'show_last_update_time',
1442 1467 'type' => 'toggle',
@@ -1442,9 +1467,9 @@
1442 1467 'type' => 'toggle',
1443 1468 'label' => __( 'Last Update Time', 'betterdocs' ),
1444 1469 'enable_disable_text_active' => true,
1445 1470 'default' => 1,
1446 - 'priority' => 5
1471 + 'priority' => 9
1447 1472 ),
1448 1473 'enable_navigation' => array(
1449 1474 'name' => 'enable_navigation',
1450 1475 'type' => 'toggle',
@@ -1450,9 +1475,9 @@
1450 1475 'type' => 'toggle',
1451 1476 'label' => __( 'Navigation', 'betterdocs' ),
1452 1477 'enable_disable_text_active' => true,
1453 1478 'default' => 1,
1454 - 'priority' => 6
1479 + 'priority' => 10
1455 1480 ),
1456 1481 'enable_comment' => array(
1457 1482 'name' => 'enable_comment',
1458 1483 'type' => 'toggle',
@@ -1458,9 +1483,9 @@
1458 1483 'type' => 'toggle',
1459 1484 'label' => __( 'Comment', 'betterdocs' ),
1460 1485 'enable_disable_text_active' => true,
1461 1486 'default' => '',
1462 - 'priority' => 7
1487 + 'priority' => 11
1463 1488 ),
1464 1489 'enable_credit' => array(
1465 1490 'name' => 'enable_credit',
1466 1491 'type' => 'toggle',
@@ -1466,9 +1491,9 @@
1466 1491 'type' => 'toggle',
1467 1492 'label' => __( 'Show Powered by BetterDocs', 'betterdocs' ),
1468 1493 'enable_disable_text_active' => true,
1469 1494 'default' => '',
1470 - 'priority' => 8
1495 + 'priority' => 12
1471 1496 ),
1472 1497 'reaction_feedback_text' => array(
1473 1498 'name' => 'reaction_feedback_text',
1474 1499 'type' => 'text',
@@ -1473,9 +1498,9 @@
1473 1498 'name' => 'reaction_feedback_text',
1474 1499 'type' => 'text',
1475 1500 'label' => __( 'Reaction Feedback Text', 'betterdocs' ),
1476 1501 'default' => __( 'Thanks for your feedback.', 'betterdocs' ),
1477 - 'priority' => 9
1502 + 'priority' => 13
1478 1503 ),
1479 1504 'enable_estimated_reading_time' => array(
1480 1505 'name' => 'enable_estimated_reading_time',
1481 1506 'type' => 'toggle',
@@ -1481,9 +1506,9 @@
1481 1506 'type' => 'toggle',
1482 1507 'label' => __( 'Estimated Reading Time', 'betterdocs' ),
1483 1508 'enable_disable_text_active' => true,
1484 1509 'default' => 0,
1485 - 'priority' => 10
1510 + 'priority' => 14
1486 1511 ),
1487 1512 'estimated_reading_time_title' => array(
1488 1513 'name' => 'estimated_reading_time_title',
1489 1514 'type' => 'text',
@@ -1488,9 +1513,9 @@
1488 1513 'name' => 'estimated_reading_time_title',
1489 1514 'type' => 'text',
1490 1515 'label' => __( 'Estimated Reading Time Title', 'betterdocs' ),
1491 1516 'default' => '',
1492 - 'priority' => 11,
1517 + 'priority' => 15,
1493 1518 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1494 1519 ),
1495 1520 'estimated_reading_time_text' => array(
1496 1521 'name' => 'estimated_reading_time_text',
@@ -1496,9 +1521,9 @@
1496 1521 'name' => 'estimated_reading_time_text',
1497 1522 'type' => 'text',
1498 1523 'label' => __( 'Estimated Reading Time Text', 'betterdocs' ),
1499 1524 'default' => __( 'min read', 'betterdocs' ),
1500 - 'priority' => 12,
1525 + 'priority' => 16,
1501 1526 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1502 1527 ),
1503 1528 'singular_estimated_reading_time_text' => array(
1504 1529 'name' => 'singular_estimated_reading_time_text',
@@ -1504,9 +1529,9 @@
1504 1529 'name' => 'singular_estimated_reading_time_text',
1505 1530 'type' => 'text',
1506 1531 'label' => __( 'Estimated Reading Time Text Singular', 'betterdocs' ),
1507 1532 'default' => __( 'min read', 'betterdocs' ),
1508 - 'priority' => 13,
1533 + 'priority' => 17,
1509 1534 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1510 1535 )
1511 1536 )
1512 1537 ),
@@ -2400,16 +2425,18 @@
2400 2425 'default' => 'openai',
2401 2426 // DeepSeek and OpenRouter are temporarily hidden from the
2402 2427 // dropdown. Filter the options here, NOT ModelRegistry::platforms() —
2403 2428 // sensitive_api_key_fields() loops the registry to mask each
2404 - // ai_api_key_{platform}, so trimming the registry would silently
2429 + // platform's key field, so trimming the registry would silently
2405 2430 // un-mask those keys. Re-enable later by dropping the array_diff_key.
2406 2431 'options' => GlobalFields::normalize_fields(
2407 2432 array_diff_key( ModelRegistry::platforms(), array_flip( array( 'deepseek', 'openrouter' ) ) )
2408 2433 )
2409 2434 ),
2410 - 'ai_api_key_openai' => array(
2411 - 'name' => 'ai_api_key_openai',
2435 + // OpenAI's key field keeps its original name so installs
2436 + // that already saved a Write with AI key keep it.
2437 + 'ai_autowrite_api_key' => array(
2438 + 'name' => 'ai_autowrite_api_key',
2412 2439 'type' => 'text',
2413 2440 'label' => __( 'OpenAI API Key', 'betterdocs' ),
2414 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/' ) ),
2415 2442 'default' => '',
@@ -2526,9 +2553,10 @@
2526 2553 'priority' => 6,
2527 2554 'label' => __( 'Write Glossaries with AI', 'betterdocs' ),
2528 2555 'label_subtitle' => __( 'Generate AI based Glossary definitions from the Glossaries admin page', 'betterdocs' ),
2529 2556 'enable_disable_text_active' => true,
2530 - 'default' => true
2557 + 'default' => true,
2558 + 'is_pro' => true
2531 2559 ),
2532 2560 'enable_docs_ai_suite' => array(
2533 2561 'name' => 'enable_docs_ai_suite',
2534 2562 'type' => 'toggle',