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 +318 -77 4.8.0 → 4.9.4 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,40 @@
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 + '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' => ''
392 383 );
393 384
394 385 $_default = apply_filters( 'betterdocs_default_settings', $_default );
395 386 // $_default = apply_filters_deprecated(
@@ -761,21 +752,38 @@
761 752 betterdocs()->kbmigration->migrate();
762 753 }
763 754 $_settings = wp_parse_args( $_normalized_settings, $_old_settings );
764 755
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;
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;
769 778
770 779 $_saved = $this->database->save( $this->base_key, $_settings );
771 780
772 781 do_action_ref_array( 'betterdocs::settings::saved', array( $_saved, $_settings, $_old_settings, &$this ) );
773 782
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;
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;
778 786 }
779 787
780 788 public function views( $hook ) {
781 789 return betterdocs()->views->get( 'admin/settings' );
@@ -1428,8 +1436,44 @@
1428 1436 'enable_disable_text_active' => true,
1429 1437 'default' => 1,
1430 1438 'priority' => 3
1431 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 + ),
1432 1476 'enable_tags' => array(
1433 1477 'name' => 'enable_tags',
1434 1478 'type' => 'toggle',
1435 1479 'label' => __( 'Tags', 'betterdocs' ),
@@ -1434,9 +1478,9 @@
1434 1478 'type' => 'toggle',
1435 1479 'label' => __( 'Tags', 'betterdocs' ),
1436 1480 'enable_disable_text_active' => true,
1437 1481 'default' => 1,
1438 - 'priority' => 4
1482 + 'priority' => 8
1439 1483 ),
1440 1484 'show_last_update_time' => array(
1441 1485 'name' => 'show_last_update_time',
1442 1486 'type' => 'toggle',
@@ -1442,9 +1486,9 @@
1442 1486 'type' => 'toggle',
1443 1487 'label' => __( 'Last Update Time', 'betterdocs' ),
1444 1488 'enable_disable_text_active' => true,
1445 1489 'default' => 1,
1446 - 'priority' => 5
1490 + 'priority' => 9
1447 1491 ),
1448 1492 'enable_navigation' => array(
1449 1493 'name' => 'enable_navigation',
1450 1494 'type' => 'toggle',
@@ -1450,9 +1494,9 @@
1450 1494 'type' => 'toggle',
1451 1495 'label' => __( 'Navigation', 'betterdocs' ),
1452 1496 'enable_disable_text_active' => true,
1453 1497 'default' => 1,
1454 - 'priority' => 6
1498 + 'priority' => 10
1455 1499 ),
1456 1500 'enable_comment' => array(
1457 1501 'name' => 'enable_comment',
1458 1502 'type' => 'toggle',
@@ -1458,9 +1502,9 @@
1458 1502 'type' => 'toggle',
1459 1503 'label' => __( 'Comment', 'betterdocs' ),
1460 1504 'enable_disable_text_active' => true,
1461 1505 'default' => '',
1462 - 'priority' => 7
1506 + 'priority' => 11
1463 1507 ),
1464 1508 'enable_credit' => array(
1465 1509 'name' => 'enable_credit',
1466 1510 'type' => 'toggle',
@@ -1466,9 +1510,9 @@
1466 1510 'type' => 'toggle',
1467 1511 'label' => __( 'Show Powered by BetterDocs', 'betterdocs' ),
1468 1512 'enable_disable_text_active' => true,
1469 1513 'default' => '',
1470 - 'priority' => 8
1514 + 'priority' => 12
1471 1515 ),
1472 1516 'reaction_feedback_text' => array(
1473 1517 'name' => 'reaction_feedback_text',
1474 1518 'type' => 'text',
@@ -1473,9 +1517,9 @@
1473 1517 'name' => 'reaction_feedback_text',
1474 1518 'type' => 'text',
1475 1519 'label' => __( 'Reaction Feedback Text', 'betterdocs' ),
1476 1520 'default' => __( 'Thanks for your feedback.', 'betterdocs' ),
1477 - 'priority' => 9
1521 + 'priority' => 13
1478 1522 ),
1479 1523 'enable_estimated_reading_time' => array(
1480 1524 'name' => 'enable_estimated_reading_time',
1481 1525 'type' => 'toggle',
@@ -1481,9 +1525,9 @@
1481 1525 'type' => 'toggle',
1482 1526 'label' => __( 'Estimated Reading Time', 'betterdocs' ),
1483 1527 'enable_disable_text_active' => true,
1484 1528 'default' => 0,
1485 - 'priority' => 10
1529 + 'priority' => 14
1486 1530 ),
1487 1531 'estimated_reading_time_title' => array(
1488 1532 'name' => 'estimated_reading_time_title',
1489 1533 'type' => 'text',
@@ -1488,9 +1532,9 @@
1488 1532 'name' => 'estimated_reading_time_title',
1489 1533 'type' => 'text',
1490 1534 'label' => __( 'Estimated Reading Time Title', 'betterdocs' ),
1491 1535 'default' => '',
1492 - 'priority' => 11,
1536 + 'priority' => 15,
1493 1537 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1494 1538 ),
1495 1539 'estimated_reading_time_text' => array(
1496 1540 'name' => 'estimated_reading_time_text',
@@ -1496,9 +1540,9 @@
1496 1540 'name' => 'estimated_reading_time_text',
1497 1541 'type' => 'text',
1498 1542 'label' => __( 'Estimated Reading Time Text', 'betterdocs' ),
1499 1543 'default' => __( 'min read', 'betterdocs' ),
1500 - 'priority' => 12,
1544 + 'priority' => 16,
1501 1545 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1502 1546 ),
1503 1547 'singular_estimated_reading_time_text' => array(
1504 1548 'name' => 'singular_estimated_reading_time_text',
@@ -1504,9 +1548,9 @@
1504 1548 'name' => 'singular_estimated_reading_time_text',
1505 1549 'type' => 'text',
1506 1550 'label' => __( 'Estimated Reading Time Text Singular', 'betterdocs' ),
1507 1551 'default' => __( 'min read', 'betterdocs' ),
1508 - 'priority' => 13,
1552 + 'priority' => 17,
1509 1553 'rules' => Rules::is( 'enable_estimated_reading_time', true )
1510 1554 )
1511 1555 )
1512 1556 ),
@@ -1773,8 +1817,202 @@
1773 1817 'enable_disable_text_active' => true,
1774 1818 'default' => false
1775 1819 )
1776 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 + ) )
1777 2015 )
1778 2016 )
1779 2017 )
1780 2018 )
@@ -2400,16 +2638,18 @@
2400 2638 'default' => 'openai',
2401 2639 // DeepSeek and OpenRouter are temporarily hidden from the
2402 2640 // dropdown. Filter the options here, NOT ModelRegistry::platforms() —
2403 2641 // sensitive_api_key_fields() loops the registry to mask each
2404 - // ai_api_key_{platform}, so trimming the registry would silently
2642 + // platform's key field, so trimming the registry would silently
2405 2643 // un-mask those keys. Re-enable later by dropping the array_diff_key.
2406 2644 'options' => GlobalFields::normalize_fields(
2407 2645 array_diff_key( ModelRegistry::platforms(), array_flip( array( 'deepseek', 'openrouter' ) ) )
2408 2646 )
2409 2647 ),
2410 - 'ai_api_key_openai' => array(
2411 - 'name' => 'ai_api_key_openai',
2648 + // OpenAI's key field keeps its original name so installs
2649 + // that already saved a Write with AI key keep it.
2650 + 'ai_autowrite_api_key' => array(
2651 + 'name' => 'ai_autowrite_api_key',
2412 2652 'type' => 'text',
2413 2653 'label' => __( 'OpenAI API Key', 'betterdocs' ),
2414 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/' ) ),
2415 2655 'default' => '',
@@ -2526,9 +2766,10 @@
2526 2766 'priority' => 6,
2527 2767 'label' => __( 'Write Glossaries with AI', 'betterdocs' ),
2528 2768 'label_subtitle' => __( 'Generate AI based Glossary definitions from the Glossaries admin page', 'betterdocs' ),
2529 2769 'enable_disable_text_active' => true,
2530 - 'default' => true
2770 + 'default' => true,
2771 + 'is_pro' => true
2531 2772 ),
2532 2773 'enable_docs_ai_suite' => array(
2533 2774 'name' => 'enable_docs_ai_suite',
2534 2775 'type' => 'toggle',