PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
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 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / Admin.php

Admin.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Core/Admin.php

1,716 lines 59.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Core;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8
9 use Exception;
10 use PriyoMukul\WPNotice\Notices;
11 use WPDeveloper\BetterDocs\Admin\NoticePointers;
12 use WPDeveloper\BetterDocs\Utils\Base;
13 use PriyoMukul\WPNotice\Utils\CacheBank;
14 use WPDeveloper\BetterDocs\Utils\Helper;
15 use WPDeveloper\BetterDocs\Utils\Enqueue;
16 use WPDeveloper\BetterDocs\Insights\Insights;
17 use PriyoMukul\WPNotice\Utils\NoticeRemover;
18 use WPDeveloper\BetterDocs\Core\PluginInstaller;
19 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
20
21 class Admin extends Base {
22 /**
23 * Per-user flag recording that this administrator has opened the MCP screen.
24 *
25 * Stores the timestamp of the first visit, but only its **presence** is read:
26 * absent means "this user has not seen MCP yet", which is what puts the
27 * one-time discovery badge on the menu (ADR-063). Per user on purpose — two
28 * administrators each get their own first look, and neither clears the other's.
29 *
30 * @var string
31 * @since 4.9.0
32 */
33 const MCP_SEEN_META = 'betterdocs_mcp_seen';
34
35 /**
36 * Whether this request painted the MCP discovery badge onto the menu.
37 *
38 * Decided once in `menus()` (on `admin_menu`) and read again in
39 * `mcp_badge_styles()` (on `admin_head`), rather than re-deciding, because the
40 * two must agree: on the request that opens the MCP screen the badge is still
41 * painted while the meta is already written, and re-deciding at `admin_head`
42 * would leave that one painted pill unstyled.
43 *
44 * @var bool
45 * @since 4.9.0
46 */
47 private $mcp_badge = false;
48
49 /**
50 * @var CacheBank
51 */
52 private static $cache_bank;
53 /**
54 * Admin Root Menu Slug
55 *
56 * @var string
57 */
58 private $slug = 'betterdocs-dashboard';
59 /**
60 * Insights
61 *
62 * @var Insights
63 */
64 private $insights = null;
65
66 /**
67 * DI\Container
68 *
69 * @var Container
70 */
71 private $container;
72
73 /**
74 * Database Wrapper
75 *
76 * @var Settings
77 */
78 private $settings;
79
80 /**
81 * KBMigration
82 *
83 * @var KBMigration
84 */
85 private $kbmigration;
86
87 /**
88 * Enqueue
89 *
90 * @var Enqueue
91 */
92 private $assets;
93
94 // modules
95 protected $installer;
96
97 /**
98 * FAQBuilder
99 *
100 * @var FAQBuilder
101 */
102 private $faq_builder;
103 private $glossaries;
104
105 public function __construct( Container $container, PostType $type, Enqueue $assets, Settings $settings, KBMigration $kbmigration ) {
106 $this->container = $container;
107 $this->assets = $assets;
108 $this->settings = $settings;
109 $this->kbmigration = $kbmigration;
110 $this->slug = 'betterdocs-dashboard';
111
112 add_action( 'init', array( $type, 'register' ), 9 );
113 add_action( 'rest_api_init', array( $this, 'order_terms_in_wp_terms_admin_table' ) );
114
115 $type->init();
116 $type->admin_init();
117
118 $this->faq_builder = $this->container->get( FAQBuilder::class );
119 $this->glossaries = $this->container->get( Glossaries::class );
120
121 /**
122 * Register usage tracking (including the daily `put_do_weekly_action` cron
123 * handler) on every request — WP-Cron runs with is_admin() === false, so
124 * this MUST sit above the admin guard or the cron send never fires. The
125 * admin-only UI hooks inside Insights::init() (deactivation form, footer
126 * scripts, plugin_action_links) are context-specific and simply never run
127 * outside wp-admin.
128 */
129 $this->plugin_insights();
130
131 if ( ! is_admin() ) {
132 return;
133 }
134
135 $this->installer = new PluginInstaller();
136
137 add_action( 'admin_notices', array( $this, 'compatibility_notices' ) );
138 // The WPNotice CacheBank wipes all admin_notices at priority 10 on BetterDocs
139 // screens, so the hook above never renders inside the BetterDocs panels.
140 // Re-add the compatibility notice after that wipe (in_admin_header, priority
141 // 999) so it shows on the panels like the review / license notices.
142 add_action( 'in_admin_header', function () {
143 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
144 if ( $screen && betterdocs()->is_betterdocs_screen( $screen->id ) ) {
145 add_action( 'admin_notices', array( $this, 'compatibility_notices' ) );
146 }
147 }, 999 );
148 // add_action( 'admin_init', [$this, 'notices'], 9 );
149 add_filter( 'admin_init', array( $this, 'save_admin_page' ), 99 );
150
151 add_action( 'admin_menu', array( $this, 'menus' ) );
152 // The badge's clear runs on `admin_init` — a hook that fires for every
153 // admin request — and identifies the screen by its page slug, rather
154 // than on `load-{$hook_suffix}` (ADR-065). `admin_init` fires *after*
155 // `admin_menu`, measured on the rig, so the badge is still painted on
156 // the request that opens the screen exactly as before.
157 add_action( 'admin_init', array( $this, 'mark_mcp_seen' ) );
158 add_action( 'admin_menu', array( $this, 'reset_submenu' ) );
159 add_action( 'admin_head', array( $this, 'add_custom_classes_to_menu_items' ) );
160 add_action( 'admin_head', array( $this, 'mcp_badge_styles' ) );
161 add_filter( 'plugin_action_links_' . BETTERDOCS_PLUGIN_BASENAME, array( $this, 'insert_plugin_links' ) );
162
163 // $this->container->get( SetupWizard::class )->init();
164
165 add_action( 'admin_enqueue_scripts', array( $this, 'styles' ) );
166 add_action( 'admin_enqueue_scripts', array( $this, 'scripts' ) );
167 // add_action( 'betterdocs_listing_header', [ $this, 'header' ], 10, 1 );
168 add_action( 'admin_bar_menu', array( $this, 'toolbar_menu' ), 32 );
169
170 add_filter( 'admin_body_class', array( $this, 'body_classes' ) );
171 add_filter( 'parent_file', array( $type, 'highlight_admin_menu' ) );
172 add_filter( 'submenu_file', array( $type, 'highlight_admin_submenu' ), 10, 2 );
173 add_filter( 'betterdocs_admin_menu', array( $this, 'quick_setup_menu' ), 10, 1 );
174
175 /**
176 * Remove Comments Column from List Table.
177 */
178 add_filter( 'manage_docs_posts_columns', array( $this, 'set_custom_edit_action_columns' ) );
179 add_filter( 'manage_docs_posts_custom_column', array( $this, 'manage_custom_columns' ), 10, 2 );
180
181 /**
182 * Add New Column
183 */
184 add_filter( 'manage_users_columns', array( $this, 'add_users_total_docs_column' ), 10, 1 );
185 add_filter( 'manage_users_custom_column', array( $this, 'popular_users_docs_data' ), 10, 3 );
186 if ( is_plugin_active( 'betterdocs-pro/betterdocs-pro.php' ) ) {
187 add_action( 'admin_footer-plugins.php', array( $this, 'disable_deactivation' ) );
188 }
189
190 if ( $this->settings->get( 'enable_estimated_reading_time' ) ) {
191 // Hook into unified metabox instead of creating separate metabox
192 add_action( 'betterdocs_reading_time_tab_content', array( $this, 'render_estimated_time_markup' ) );
193 }
194
195 self::$cache_bank = CacheBank::get_instance();
196
197 // Remove OLD notice from 1.0.0 (if other WPDeveloper plugin has notice)
198 NoticeRemover::get_instance( '1.0.0' );
199
200 try {
201 $this->notices();
202 } catch ( Exception $e ) {
203 unset( $e );
204 }
205
206 // Initialize Black Friday Pointer
207 $this->init_black_friday_pointer();
208
209 // Register AJAX handler for pointer dismissal
210 add_action( 'wp_ajax_betterdocs_dismiss_black_friday_pointer', array( $this, 'ajax_dismiss_black_friday_pointer' ) );
211 }
212
213 public function order_terms_in_wp_terms_admin_table() {
214 // order the terms correctly to be shown on the admin panel categories menu with betterdocs order
215 add_action(
216 'rest_insert_doc_category',
217 function ( $term, $request, $bool ) {
218 $max_order = Helper::get_max_doc_category_order_from_term_meta() ?? 0;
219 $next_order = $max_order + 1;
220 update_term_meta( $term->term_id, 'doc_category_order', $next_order );
221 },
222 10,
223 3
224 );
225 }
226
227 public function disable_deactivation() {
228 $tooltip_text = esc_html__( 'Deactivate BetterDocs Pro First', 'betterdocs' );
229 ?>
230 <style type="text/css">
231 #deactivate-betterdocs {
232 color: #cccccc;
233 position: relative;
234 }
235
236 /* Tooltip styling */
237 #deactivate-betterdocs[title]:hover::after {
238 content: attr(title);
239 position: absolute;
240 bottom: 100%;
241 left: 50%;
242 transform: translateX(-50%);
243 background-color: #333;
244 color: #fff;
245 padding: 5px 10px;
246 border-radius: 4px;
247 font-size: 12px;
248 white-space: nowrap;
249 box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
250 z-index: 10;
251 }
252 #deactivate-betterdocs:focus {
253 box-shadow: none;
254 outline: none;
255 }
256 </style>
257 <script type="text/javascript">
258 jQuery(document).ready(function($) {
259 // Disable the default action and add class with tooltip by default
260 const tooltipText = "<?php echo esc_attr( $tooltip_text ); ?>";
261 $("#deactivate-betterdocs")
262 .addClass("disabled-tooltip")
263 .attr("title", tooltipText)
264 .on("click", function(e) {
265 e.preventDefault(); // Prevent any action on click
266 });
267 });
268 </script>
269 <?php
270 }
271
272 public function add_users_total_docs_column( $columns ) {
273 $new_column = array(
274 'docs' => __( 'Docs', 'betterdocs' ),
275 );
276 $columns = array_merge( $columns, $new_column );
277 return $columns;
278 }
279
280 public function popular_users_docs_data( $output, $column_name, $user_id ) {
281 if ( 'docs' == $column_name ) {
282 $total_count = count_user_posts( $user_id, 'docs', true );
283 return '<a href="edit.php?post_type=docs&author=' . $user_id . '" class="edit"><span aria-hidden="true">' . $total_count . '</span></a>';
284 }
285 return $output;
286 }
287
288 public function render_estimated_time_markup() {
289 betterdocs()->views->get( 'admin/metabox/estimated-reading-box' );
290 }
291
292 public function compatibility_notices() {
293 if ( betterdocs()->is_pro_active() ) {
294 $plugins = Helper::get_plugins();
295 $plugin_data = $plugins['betterdocs-pro/betterdocs-pro.php'];
296
297 // Require the paired Pro release: the Analytics UI is version-coupled to
298 // Pro's advanced modules, so an older Pro renders a broken/partial panel.
299 if ( isset( $plugin_data['Version'] ) && version_compare( $plugin_data['Version'], '4.0.0', '>=' ) ) {
300 return;
301 }
302
303 betterdocs()->views->get( 'admin/notices/compatibility', array( 'version' => $plugin_data['Version'] ) );
304 }
305 }
306
307 public function plugin_insights( $prevent_init = false ) {
308 $this->insights = Insights::get_instance(
309 BETTERDOCS_PLUGIN_FILE,
310 array(
311 'opt_in' => true,
312 'goodbye_form' => true,
313 'item_id' => 'c7b16777b4f1b83f6083',
314 )
315 );
316
317 $this->insights->set_notice_options(
318 array(
319 'notice' => __( 'Want to help make <strong>BetterDocs</strong> even more awesome? You can get a <strong>10% discount coupon</strong> for Premium extensions if you allow us to track the usage.', 'betterdocs' ),
320 'extra_notice' => __( 'We collect non-sensitive diagnostic data and plugin usage information. Your site URL, WordPress & PHP version, plugins & themes and email address to send you the discount coupon. This data lets us make sure this plugin always stays compatible with the most popular plugins and themes. No spam, I promise.', 'betterdocs' ),
321 )
322 );
323
324 if ( ! $prevent_init ) {
325 $this->insights->init();
326 }
327
328 return $this->insights;
329 }
330
331 /**
332 * Admin notices for Review and others.
333 *
334 * @return void
335 * @throws Exception
336 */
337 public function notices() {
338 $notices = new Notices(
339 array(
340 'id' => 'betterdocs',
341 'storage_key' => 'notices',
342 'lifetime' => 3,
343 'stylesheet_url' => $this->assets->asset_url( 'admin/css/notices.css' ),
344 'styles' => $this->assets->asset_url( 'admin/css/notices.css' ),
345 'priority' => 4,
346 )
347 );
348
349 /**
350 * Review Notice
351 *
352 * @var mixed $message
353 */
354
355 $message = __( 'We hope you\'re enjoying BetterDocs! Could you please do us a BIG favor and give it a 5-star rating on WordPress to help us spread the word and boost our motivation?', 'betterdocs' );
356
357 $_review_notice = array(
358 'thumbnail' => $this->assets->icon( 'betterdocs-logo.svg', true ),
359 'html' => '<p>' . $message . '</p>',
360 'links' => array(
361 'later' => array(
362 'link' => 'https://wordpress.org/plugins/betterdocs/#reviews',
363 'target' => '_blank',
364 'label' => __( 'Sure, you deserve it!', 'betterdocs' ),
365 'icon_class' => 'dashicons dashicons-external',
366 ),
367 'allready' => array(
368 'label' => __( 'I already did', 'betterdocs' ),
369 'icon_class' => 'dashicons dashicons-smiley',
370 'attributes' => array(
371 'data-dismiss' => true,
372 ),
373 ),
374 'maybe_later' => array(
375 'label' => __( 'Maybe Later', 'betterdocs' ),
376 'icon_class' => 'dashicons dashicons-calendar-alt',
377 'attributes' => array(
378 'data-later' => true,
379 'class' => 'dismiss-btn',
380 ),
381 ),
382 'support' => array(
383 'link' => 'https://wpdeveloper.com/support',
384 'attributes' => array(
385 'target' => '_blank',
386 ),
387 'label' => __( 'I need help', 'betterdocs' ),
388 'icon_class' => 'dashicons dashicons-sos',
389 ),
390 'never_show_again' => array(
391 'label' => __( 'Never show again', 'betterdocs' ),
392 'icon_class' => 'dashicons dashicons-dismiss',
393 'attributes' => array(
394 'data-dismiss' => true,
395 ),
396 ),
397 ),
398 );
399
400 $notices->add(
401 'review',
402 $_review_notice,
403 array(
404 'start' => $notices->strtotime( '+10 days' ),
405 'recurrence' => 30,
406 'dismissible' => true,
407 )
408 );
409
410 if ( $this->kbmigration->existing_plugins && ! in_array( $this->kbmigration->existing_plugins[0][0], $this->kbmigration->migrated_plugins ) ) {
411 $plugin_name = '<strong>' . esc_html( $this->kbmigration->existing_plugins[0][1] ) . '</strong>';
412
413 $message = sprintf(
414 /* translators: %s is the name of the existing knowledge base plugin. */
415 __( 'Already using %s? Power up your Knowledge Base by migrating all your docs and settings to BetterDocs with just 1 click.', 'betterdocs' ),
416 esc_html( $plugin_name )
417 );
418
419 $migration_message = sprintf(
420 '<p class="migration-message">%s</p><a class="button button-primary betterdocs-migration-notice" href="%s">%s</a>',
421 $message,
422 esc_url( admin_url( 'admin.php?page=betterdocs-settings&tab=tab-migration' ) ),
423 esc_html__( 'Start Migration', 'betterdocs' )
424 );
425
426 $_migration_notice = array(
427 'thumbnail' => '',
428 'html' => $migration_message,
429 'links' => array(
430 'maybe_later' => array(
431 'label' => __( 'Maybe Later', 'betterdocs' ),
432 'icon_class' => 'dashicons dashicons-calendar-alt',
433 'attributes' => array(
434 'data-later' => true,
435 'class' => 'dismiss-btn',
436 ),
437 ),
438 'never_show_again' => array(
439 'label' => __( 'Never show again', 'betterdocs' ),
440 'icon_class' => 'dashicons dashicons-dismiss',
441 'attributes' => array(
442 'data-dismiss' => true,
443 ),
444 ),
445 ),
446 );
447
448 $notices->add(
449 'migration',
450 $_migration_notice,
451 array(
452 'start' => $notices->time(),
453 'recurrence' => false,
454 'dismissible' => true,
455 )
456 );
457 }
458
459 /**
460 *
461 * Opt-In Notice
462 */
463 $allow_tracking = get_option( 'wpins_allow_tracking' );
464 if ( null != $this->insights && ! isset( $allow_tracking['betterdocs'] ) ) {
465 $notices->add(
466 'opt_in',
467 array( $this->insights, 'notice' ),
468 array(
469 'classes' => 'updated put-dismiss-notice',
470 'start' => $notices->time(),
471 'refresh' => BETTERDOCS_VERSION,
472 'dismissible' => true,
473 'do_action' => 'wpdeveloper_notice_clicked_for_betterdocs',
474 'display_if' => ! function_exists( 'betterdocs_pro' ),
475 'screens' => array( 'dashboard' ),
476 )
477 );
478 }
479
480 $summer_campaign_message = '<div class="betterdocs-summer-notice-body"><p style="margin-top: 0; margin-bottom: 0;">🏖️ <strong>Summer Savings:</strong> Build AI-powered Knowledge Bases & FAQs to cut support tickets and improve user experience – now <strong>up to $100 OFF!</strong></p></div>';
481 $_summer_campaign_notice = array(
482 'thumbnail' => $this->assets->icon( 'betterdocs-logo.svg', true ),
483 'html' => $summer_campaign_message,
484 'links' => array(
485 'support' => array(
486 'link' => 'https://betterdocs.co/summer2026-admin-notice',
487 'attributes' => array(
488 'target' => '_blank',
489 'class' => 'offer-button',
490 ),
491 'label' => __( 'Upgrade To PRO Now', 'betterdocs' ),
492 ),
493 'maybe_later' => array(
494 'label' => __( 'I’ll Grab It Later', 'betterdocs' ),
495 'attributes' => array(
496 'target' => '_blank',
497 'data-later' => true,
498 'class' => 'dismiss-btn',
499 ),
500 ),
501 ),
502 );
503
504 $notices->add(
505 'summer-campaign-26',
506 $_summer_campaign_notice,
507 array(
508 'start' => $notices->time(),
509 'recurrence' => false,
510 'dismissible' => true,
511 'refresh' => BETTERDOCS_VERSION,
512 'expire' => strtotime( '11:59:59pm June 25, 2026' ),
513 'display_if' => ! is_plugin_active( 'betterdocs-pro/betterdocs-pro.php' ),
514 )
515 );
516
517 self::$cache_bank->create_account( $notices );
518 self::$cache_bank->calculate_deposits( $notices );
519 if ( method_exists( self::$cache_bank, 'clear_notices_in_' ) ) {
520 self::$cache_bank->clear_notices_in_(
521 array(
522 'toplevel_page_betterdocs-dashboard',
523 'admin_page_betterdocs-admin',
524 'betterdocs_page_betterdocs-admin',
525 'betterdocs_page_betterdocs-settings',
526 'betterdocs_page_betterdocs-faq',
527 'betterdocs_page_betterdocs-analytics',
528 'betterdocs_page_betterdocs-glossaries',
529 'betterdocs_page_betterdocs-ai-chatbot',
530 'betterdocs_page_betterdocs-api-docs',
531 'betterdocs_page_betterdocs-doc-categories',
532 'betterdocs_page_betterdocs-doc-tags',
533 'edit-doc_category',
534 'edit-doc_tag',
535 ),
536 $notices,
537 true
538 );
539 }
540 }
541
542 /**
543 * Resolve the admin dark-mode preference.
544 *
545 * The mode switcher stores the choice in a client cookie (no DB write, shared
546 * across every admin screen). Fall back to the legacy
547 * `betterdocs_settings['dark_mode']` value for installs that set it before this
548 * change and haven't toggled since.
549 *
550 * @return bool
551 */
552 public function is_dark_mode() {
553 if ( isset( $_COOKIE['betterdocs_admin_dark_mode'] ) ) {
554 return '1' === $_COOKIE['betterdocs_admin_dark_mode']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
555 }
556
557 $saved = get_option( 'betterdocs_settings', array() );
558 return ! empty( $saved['dark_mode'] );
559 }
560
561 /**
562 * Whether the knowledge base is genuinely empty (no doc categories and no
563 * non-trash docs). Localized to the admin so the All Docs panel can render a
564 * skeleton shaped like the "No Category Found" empty card on first paint —
565 * instead of a category/docs skeleton it would immediately replace — without
566 * waiting for the REST fetch to reveal the count.
567 *
568 * @return bool
569 */
570 public function kb_is_empty() {
571 $cats = wp_count_terms( array( 'taxonomy' => 'doc_category', 'hide_empty' => false ) );
572 $cats = is_wp_error( $cats ) ? 0 : (int) $cats;
573 if ( $cats > 0 ) {
574 return false;
575 }
576
577 $counts = (array) wp_count_posts( 'docs' );
578 $total = 0;
579 foreach ( array( 'publish', 'future', 'draft', 'pending', 'private' ) as $status ) {
580 $total += isset( $counts[ $status ] ) ? (int) $counts[ $status ] : 0;
581 }
582
583 return 0 === $total;
584 }
585
586 public function body_classes( $classes ) {
587 $dark_mode = $this->is_dark_mode();
588 $current_screen_id = get_current_screen() != null ? str_replace( 'betterdocs_page_', '', str_replace( 'toplevel_page_', '', str_replace( 'admin_page_', '', get_current_screen()->id ) ) ) : '';
589 /**
590 * Filter the list of (prefix-stripped) screen ids that receive the
591 * `betterdocs-admin` body class (and dark-mode class). Pro/add-ons can
592 * register their own React admin pages, e.g. the Knowledge Base page.
593 *
594 * @param string[] $registered_screens Screen ids with the page prefix removed.
595 */
596 $registered_screens = apply_filters( 'betterdocs_admin_screen_slugs', array(
597 'betterdocs-settings',
598 'betterdocs-admin',
599 'betterdocs-dashboard',
600 'betterdocs-analytics',
601 'betterdocs-glossaries',
602 'betterdocs-faq',
603 'betterdocs-doc-categories',
604 'betterdocs-doc-tags',
605 'edit-doc_category',
606 'edit-doc_tag',
607 'edit-knowledge_base',
608 'betterdocs-ai-chatbot',
609 'betterdocs-api-docs',
610 // Without this the MCP screen never receives `betterdocs-admin`, and
611 // the design tokens' dark-mode overrides — which are declared on
612 // `.betterdocs-admin.betterdocs-dark-mode` — can never apply there:
613 // the switcher in the header flips the cookie and the page stays
614 // light. @since 4.9.0
615 'betterdocs-mcp',
616 ) );
617
618 if ( in_array( $current_screen_id, $registered_screens ) ) {
619 $classes .= ' betterdocs-admin ';
620 }
621
622 // Dark mode also applies on the Quick Setup wizard, whose self-scoped chrome
623 // keys off `.betterdocs_page_betterdocs-setup.betterdocs-dark-mode`.
624 $dark_screens = array_merge( $registered_screens, array( 'betterdocs-setup' ) );
625 if ( $dark_mode && in_array( $current_screen_id, $dark_screens, true ) ) {
626 $classes .= ' betterdocs-dark-mode ';
627 }
628
629 return $classes;
630 }
631
632 /**
633 * Remove Comments Column From List Table
634 *
635 * @param array $columns
636 *
637 * @return array
638 * @since 1.0.0
639 */
640 public function set_custom_edit_action_columns( $columns ) {
641 unset( $columns['comments'] );
642 $new_columns = array();
643 foreach ( $columns as $key => $value ) {
644 if ( 'date' == $key ) {
645 $new_columns['betterdocs_word_count'] = __( 'Word Count', 'betterdocs' ); // put the tags column before it
646 $new_columns['betterdocs_reaction'] = __( 'Reactions', 'betterdocs' );
647 }
648 $new_columns[ $key ] = $value;
649 }
650
651 return $new_columns;
652 }
653
654 public function manage_custom_columns( $column, $post_id ) {
655 global $wpdb;
656 switch ( $column ) {
657 case 'betterdocs_word_count':
658 $content_without_html_tags = trim( wp_strip_all_tags( get_post_field( 'post_content', $post_id ) ) );
659 preg_match_all( '/<[^>]*>|[\p{L}\p{M}]+/u', $content_without_html_tags, $matches );
660 $total_words = ! empty( $matches[0] ) ? count( $matches[0] ) : count( array() );
661 $word_count = $total_words;
662 echo '<span>' . esc_html( intval( $word_count ) ) . '</span>';
663 break;
664 case 'betterdocs_reaction':
665 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- per-post analytics aggregation rendered in admin list table; cache would mask live reactions.
666 $analytics = $wpdb->get_results(
667 $wpdb->prepare(
668 "SELECT
669 sum(impressions) as totalViews,
670 sum(unique_visit) as totalUniqueViews,
671 sum(happy + sad + normal) as totalReactions,
672 sum(happy) as totalHappy,
673 sum(normal) as totalNormal,
674 sum(sad) as totalSad
675 FROM {$wpdb->prefix}betterdocs_analytics
676 WHERE post_id = %d",
677 $post_id
678 )
679 );
680
681 echo '<ul class="reactions-count">
682 <li>
683 <a title="happy" class="betterdocs-feelings happy" data-feelings="happy" href="#">
684 <svg width="15" height="15" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
685 <path class="st0" d="M10,0.1c-5.4,0-9.9,4.4-9.9,9.8c0,5.4,4.4,9.9,9.8,9.9c5.4,0,9.9-4.4,9.9-9.8C19.9,4.5,15.4,0.1,10,0.1z
686 M13.3,6.4c0.8,0,1.5,0.7,1.5,1.5c0,0.8-0.7,1.5-1.5,1.5c-0.8,0-1.5-0.7-1.5-1.5C11.8,7.1,12.5,6.4,13.3,6.4z M6.7,6.4
687 c0.8,0,1.5,0.7,1.5,1.5c0,0.8-0.7,1.5-1.5,1.5c-0.8,0-1.5-0.7-1.5-1.5C5.2,7.1,5.9,6.4,6.7,6.4z M10,16.1c-2.6,0-4.9-1.6-5.8-4
688 l1.2-0.4c0.7,1.9,2.5,3.2,4.6,3.2s3.9-1.3,4.6-3.2l1.2,0.4C14.9,14.5,12.6,16.1,10,16.1z" />
689 <path class="st1" d="M-6.6-119.7c-7.1,0-12.9,5.8-12.9,12.9s5.8,12.9,12.9,12.9s12.9-5.8,12.9-12.9S0.6-119.7-6.6-119.7z
690 M-2.3-111.4c1.1,0,2,0.9,2,2c0,1.1-0.9,2-2,2c-1.1,0-2-0.9-2-2C-4.3-110.5-3.4-111.4-2.3-111.4z M-10.9-111.4c1.1,0,2,0.9,2,2
691 c0,1.1-0.9,2-2,2c-1.1,0-2-0.9-2-2C-12.9-110.5-12-111.4-10.9-111.4z M-6.6-98.7c-3.4,0-6.4-2.1-7.6-5.3l1.6-0.6
692 c0.9,2.5,3.3,4.2,6,4.2s5.1-1.7,6-4.2L1-104C-0.1-100.8-3.2-98.7-6.6-98.7z" />
693 </svg>
694 <span>' . esc_html( ( intval( $analytics[0]->totalHappy ) !== null ? intval( $analytics[0]->totalHappy ) : 0 ) ) . '</span>
695 </a>
696 </li>
697 <li>
698 <a title="normal" class="betterdocs-feelings normal" data-feelings="normal" href="#">
699 <svg width="15" height="15" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
700 <path class="st0" d="M10,0.2c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S15.4,0.2,10,0.2z M6.7,6.5
701 c0.8,0,1.5,0.7,1.5,1.5c0,0.8-0.7,1.5-1.5,1.5C5.9,9.5,5.2,8.9,5.2,8C5.2,7.2,5.9,6.5,6.7,6.5z M14.2,14.3H5.9
702 c-0.3,0-0.6-0.3-0.6-0.6c0-0.3,0.3-0.6,0.6-0.6h8.3c0.3,0,0.6,0.3,0.6,0.6C14.8,14,14.5,14.3,14.2,14.3z M13.3,9.5
703 c-0.8,0-1.5-0.7-1.5-1.5c0-0.8,0.7-1.5,1.5-1.5c0.8,0,1.5,0.7,1.5,1.5C14.8,8.9,14.1,9.5,13.3,9.5z" />
704 </svg>
705 <span>' . esc_html( ( intval( $analytics[0]->totalNormal ) !== null ? intval( $analytics[0]->totalNormal ) : 0 ) ) . '</span>
706 </a>
707 </li>
708 <li>
709 <a title="sad" class="betterdocs-feelings sad" data-feelings="sad" href="#">
710 <svg width="15" height="15" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
711 <circle class="st0" cx="27.5" cy="0.6" r="1.9" />
712 <circle class="st0" cx="36" cy="0.6" r="1.9" />
713 <path class="st1" d="M10,0.3c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S15.4,0.3,10,0.3z M13.3,6.6
714 c0.8,0,1.5,0.7,1.5,1.5c0,0.8-0.7,1.5-1.5,1.5c-0.8,0-1.5-0.7-1.5-1.5C11.8,7.3,12.4,6.6,13.3,6.6z M6.7,6.6c0.8,0,1.5,0.7,1.5,1.5
715 c0,0.8-0.7,1.5-1.5,1.5C5.9,9.6,5.2,9,5.2,8.1C5.2,7.3,5.9,6.6,6.7,6.6z M14.1,15L14.1,15c-0.2,0-0.4-0.1-0.5-0.2
716 c-0.9-1-2.2-1.7-3.7-1.7s-2.8,0.6-3.7,1.7C6.2,14.9,6,15,5.9,15h0c-0.6,0-0.8-0.6-0.5-1.1c1.1-1.3,2.8-2.1,4.6-2.1
717 c1.8,0,3.5,0.8,4.6,2.1C15,14.3,14.7,15,14.1,15z" />
718 </svg>
719 <span>' . esc_html( ( intval( $analytics[0]->totalNormal ) !== null ? intval( $analytics[0]->totalNormal ) : 0 ) ) . '</span>
720 </a>
721 </li>
722 </ul>';
723 break;
724 }
725 }
726
727 /**
728 * Enqueue Assets for Admin ( Styles )
729 *
730 * @param string $hook
731 *
732 * @return void
733 * @since 1.0.0
734 */
735 public function styles( $hook ) {
736 $this->assets->enqueue( 'betterdocs-global', 'admin/css/global.css', array(), 'all' );
737
738 if ( ! betterdocs()->is_betterdocs_screen( $hook ) ) {
739 return;
740 }
741
742 $this->assets->enqueue( 'betterdocs-select2', 'vendor/css/select2.min.css', array(), 'all' );
743 $this->assets->enqueue( 'betterdocs-daterangepicker', 'vendor/css/daterangepicker.css', array(), 'all' );
744 $this->assets->enqueue( 'betterdocs-old', 'admin/css/betterdocs.css', array(), 'all' );
745
746 /**
747 * This scripts enqueued for Dashboard App.
748 */
749 $this->assets->enqueue( 'betterdocs', 'admin/css/dashboard.css', array( 'betterdocs-old' ), '', BETTERDOCS_VERSION );
750 $this->assets->enqueue( 'betterdocs-icons', 'admin/btd-icon/style.css' );
751 }
752
753 /**
754 * Enqueue Assets for Admin ( Scripts )
755 *
756 * @param string $hook
757 *
758 * @return void
759 * @since 1.0.0
760 */
761 public function scripts( $hook ) {
762 // Classic-UI screens that should offer a "Switch to BetterDocs UI"
763 // button: All Docs, FAQ list, FAQ groups, Product FAQ groups,
764 // Doc Categories, Doc Tags. Maps each to the React admin page to
765 // return to; $switch_args carries extra query args (e.g. the FAQ
766 // Builder tab) appended to the React page URL.
767 $switch_page = '';
768 $switch_args = array();
769 if ( 'edit.php' === $hook && 'docs' === get_post_type() ) {
770 $switch_page = 'betterdocs-admin';
771 } elseif ( 'edit.php' === $hook && 'betterdocs_faq' === get_post_type() ) {
772 $switch_page = 'betterdocs-faq';
773 } elseif ( 'edit-tags.php' === $hook ) {
774 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
775 $taxonomy = $screen && ! empty( $screen->taxonomy )
776 ? $screen->taxonomy
777 : ( isset( $_GET['taxonomy'] ) ? sanitize_key( wp_unslash( $_GET['taxonomy'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
778 if ( 'betterdocs_faq_category' === $taxonomy ) {
779 $switch_page = 'betterdocs-faq';
780 } elseif ( 'betterdocs_product_faq_category' === $taxonomy ) {
781 // Product FAQ groups live on the FAQ Builder's WooCommerce tab.
782 $switch_page = 'betterdocs-faq';
783 $switch_args = array( 'faq_tab' => 'woocommerce' );
784 } elseif ( 'doc_category' === $taxonomy ) {
785 $switch_page = 'betterdocs-doc-categories';
786 } elseif ( 'doc_tag' === $taxonomy ) {
787 $switch_page = 'betterdocs-doc-tags';
788 }
789 }
790
791 /**
792 * Allow Pro/add-ons to map their own classic-UI taxonomy screens to a
793 * React admin page for the "Switch to BetterDocs UI" button — e.g. the
794 * Knowledge Base taxonomy, which only exists when Pro is active.
795 *
796 * @param string $switch_page React page slug, or '' for no switcher.
797 * @param string $hook Current admin page hook.
798 */
799 $switch_page = apply_filters( 'betterdocs_classic_switch_page', $switch_page, $hook );
800
801 if ( $switch_page ) {
802 $this->assets->enqueue(
803 'betterdocs-switcher',
804 'admin/js/switcher.js',
805 array(
806 'jquery',
807 )
808 );
809
810 $this->assets->localize(
811 'betterdocs-switcher',
812 'betterdocsSwitcher',
813 array(
814 'menu_title' => __( 'Switch to BetterDocs UI', 'betterdocs' ),
815 'page' => $switch_page,
816 'url' => add_query_arg(
817 array_merge( array( 'page' => $switch_page ), $switch_args ),
818 admin_url( 'admin.php' )
819 ),
820 'site_address' => get_bloginfo( 'url' ),
821 'betterdocs_pro_plugin' => betterdocs()->is_pro_active(),
822 'betterdocs_pro_version' => betterdocs()->pro_version(),
823 )
824 );
825
826 return;
827 }
828
829 wp_enqueue_script( 'wp-editor' ); // enqueue this for yoast related issue
830
831 if ( ! betterdocs()->is_betterdocs_screen( $hook ) ) {
832 return;
833 }
834
835 wp_enqueue_media(); // load early to fix problems with media upload issues on settings for WordPress 6.0.9
836 $this->assets->register( 'betterdocs-admin', 'admin/js/dashboard.js' );
837
838 $saved_settings = get_option( 'betterdocs_settings', false );
839 $dark_mode = $this->is_dark_mode();
840 $this->assets->localize(
841 'betterdocs-admin',
842 'betterdocs_admin',
843 array(
844 'ajaxurl' => admin_url( 'admin-ajax.php' ),
845 'doc_cat_order_nonce' => wp_create_nonce( 'doc_cat_order_nonce' ),
846 'knowledge_base_order_nonce' => wp_create_nonce( 'knowledge_base_order_nonce' ),
847 'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- pagination read from URL.
848 'per_page_id' => 'edit_doc_category_per_page',
849 'menu_title' => __( 'Switch to BetterDocs UI', 'betterdocs' ),
850 'dark_mode' => $dark_mode,
851 'kb_is_empty' => $this->kb_is_empty(),
852 'text' => __( 'Copied!', 'betterdocs' ),
853 'test_report' => __( 'Test Report!', 'betterdocs' ),
854 'sending' => __( 'Sending...', 'betterdocs' ),
855 'dir_url' => BETTERDOCS_ABSURL,
856 'rest_url' => esc_url_raw( rest_url() ),
857 'free_version' => betterdocs()->version,
858 'generate_data_url' => get_rest_url( null, '/betterdocs/v1/create-sample-docs' ),
859 'ai_sample_docs' => array(
860 'enabled' => (bool) betterdocs()->settings->get( 'enable_ai_sample_docs', true ),
861 'rest_base' => esc_url_raw( get_rest_url( null, '/betterdocs/v1/sample-docs' ) ),
862 ),
863 'nonce' => wp_create_nonce( 'wp_rest' ),
864 'sync_nonce' => wp_create_nonce( 'ai_chatbot_embed' ),
865 'count_all_docs' => array_sum( (array) wp_count_posts( 'docs' ) ),
866 'count_all_faq' => array_sum( (array) wp_count_posts( 'betterdocs_faq' ) ),
867 'faq_order' => get_option( 'betterdocs_faq_order', 'default' ),
868 'count_new_docs' => $this->get_not_synced_docs_count(),
869 'admin_url' => admin_url(),
870 'ia_preview' => betterdocs()->settings->get( 'ia_enable_preview', false ),
871 'multiple_kb' => betterdocs()->settings->get( 'multiple_kb' ),
872 'previewMode' => betterdocs()->settings->get( 'ia_enable_preview', false ),
873 'dashboard_mode' => get_option( 'dashboard_mode' ),
874 'betterdocs_pro_plugin' => betterdocs()->is_pro_active(),
875 'betterdocs_pro_version' => betterdocs()->pro_version(),
876 'analytics_older' => version_compare( betterdocs()->pro_version(), '3.3.4', '<=' ),
877 'betterdocs_ChatBot_plugin' => is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' ),
878 'api_docs_teaser' => betterdocs()->show_api_docs_teaser(),
879 'is_woocommerce_active' => class_exists( 'WooCommerce' ),
880 'total_doc_category_terms' => wp_count_terms( 'doc_category' ),
881 'current_admin_language' => Helper::get_current_admin_language(),
882 'is_multilingual' => Helper::is_multilingual_active(),
883 'languages' => Helper::get_admin_languages(),
884 /**
885 * MCP page bootstrap. `abilities_api_available` decides whether the
886 * page offers a connection at all: without the Abilities API there
887 * is no tool catalog, so an AI client would connect and find
888 * nothing. `enabled` is only the initial paint — the toggle owns
889 * the value from then on.
890 *
891 * @since 4.9.0
892 */
893 'mcp' => array(
894 'abilities_api_available' => function_exists( 'wp_register_ability' ),
895 'enabled' => (bool) betterdocs()->settings->get( 'enable_mcp', false ),
896 'rest' => 'betterdocs/v1',
897 ),
898 )
899 );
900
901 // If wp-date (which includes moment.js) is not registered, enqueue your custom moment.js
902 if ( ! wp_script_is( 'wp-date', 'registered' ) ) {
903 $this->assets->enqueue( 'moment', 'vendor/js/moment.min.js', array() );
904 }
905 wp_enqueue_script( 'betterdocs-admin' );
906
907 /**
908 * Duplicate Codes Need to Be Removed From Here Onwards
909 */
910
911 // FAQ Builder Related Localization
912 betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/css/faq.css' );
913 betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/js/faq.js' );
914
915 // Load the classic editor (TinyMCE + QuickTags) so the FAQ rich-text editor can mount via wp.editor.initialize().
916 if ( function_exists( 'wp_enqueue_editor' ) ) {
917 wp_enqueue_editor();
918 }
919 if ( function_exists( 'wp_enqueue_media' ) ) {
920 wp_enqueue_media();
921 }
922
923 // removing emoji support
924 remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
925 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
926
927 // Get settings and remove unnecessary keys
928 $betterdocs_settings = get_option( 'betterdocs_settings', false );
929 if ( is_array( $betterdocs_settings ) && ! current_user_can( 'edit_docs_settings' ) ) {
930 foreach ( Settings::sensitive_api_key_fields() as $sensitive_key ) {
931 unset( $betterdocs_settings[ $sensitive_key ] );
932 }
933 }
934
935 betterdocs()->assets->localize(
936 'betterdocs-admin-faq',
937 'betterdocsFaq',
938 array(
939 'dir_url' => BETTERDOCS_ABSURL,
940 'rest_url' => esc_url_raw( rest_url() ),
941 'free_version' => betterdocs()->version,
942 'nonce' => wp_create_nonce( 'wp_rest' ),
943 'betterdocs_settings' => $betterdocs_settings,
944 )
945 );
946
947 // Glossaries Related Localization
948 betterdocs()->assets->enqueue( 'betterdocs-admin-glossaries', 'admin/css/faq.css' );
949
950 betterdocs()->assets->enqueue( 'betterdocs-admin-glossaries', 'admin/js/glossaries.js' );
951
952 betterdocs()->assets->localize(
953 'betterdocs-admin-glossaries',
954 'betterdocsGlossary',
955 array(
956 'dir_url' => BETTERDOCS_ABSURL,
957 'rest_url' => esc_url_raw( rest_url() ),
958 'free_version' => betterdocs()->version,
959 'nonce' => wp_create_nonce( 'wp_rest' ),
960 'betterdocs_settings' => $betterdocs_settings,
961 )
962 );
963 }
964
965 /**
966 * All admin pages header
967 *
968 * @return void
969 * @since 1.0.0
970 */
971 public function header( $admin_tab_name ) {
972 $quick_links = array(
973 'switch_view' => sprintf(
974 '<a href="%s" class="betterdocs-button betterdocs-button-secondary">%s</a>',
975 add_query_arg(
976 array(
977 'post_type' => 'docs',
978 'bdocs_view' => 'classic',
979 ),
980 'edit.php'
981 ),
982 __( 'Switch to Classic UI', 'betterdocs' )
983 ),
984 'add_new_doc' => sprintf( '<a href="%s" class="betterdocs-button betterdocs-button-primary">%s</a>', add_query_arg( array( 'post_type' => 'docs' ), 'post-new.php' ), __( 'Add New Doc', 'betterdocs' ) ),
985 );
986
987 $quick_links = apply_filters( 'betterdocs_quick_links', $quick_links );
988
989 betterdocs()->views->get(
990 'admin/header',
991 array(
992 'quick_links' => $quick_links,
993 'active_tab' => $admin_tab_name,
994 )
995 );
996 }
997
998 /**
999 * Register all the menus for BetterDocs
1000 *
1001 * @return void
1002 * @since 1.0.0
1003 */
1004 public function menus() {
1005 $default_args = array(
1006 'page_title' => 'BetterDocs',
1007 'menu_title' => 'BetterDocs',
1008 'capability' => 'edit_docs', // Unified capability
1009 'menu_slug' => $this->slug,
1010 'callback' => array( $this, 'output' ),
1011 'icon_url' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg', true ),
1012 'position' => 5,
1013 );
1014
1015 $_menu_position = 5;
1016 global $submenu;
1017
1018 // Always register both UI endpoints
1019 $this->register_modern_ui_fallback();
1020
1021 // The one-time MCP discovery badge (ADR-063). Decided once, here, and
1022 // remembered for `mcp_badge_styles()`: the pill's markup and the pill's
1023 // stylesheet have to be printed on the same requests as each other.
1024 $this->mcp_badge = self::should_flag_mcp();
1025
1026 foreach ( $this->menu_list() as $key => $value ) {
1027 if ( 'betterdocs' === $key ) {
1028 $callable = 'add_menu_page';
1029 $value = wp_parse_args( $value, $default_args );
1030 call_user_func_array( $callable, $value );
1031 } else {
1032 $is_core_page = strpos( $value['menu_slug'], '?' ) !== false;
1033
1034 if ( $is_core_page ) {
1035 // Add classic UI directly
1036 $submenu[ $this->slug ][] = array(
1037 $value['menu_title'],
1038 $value['capability'],
1039 $value['menu_slug'],
1040 $value['page_title'],
1041 );
1042 } else {
1043 // Add modern UI through WordPress API
1044 add_submenu_page(
1045 $this->slug,
1046 $value['page_title'],
1047 $value['menu_title'],
1048 $value['capability'],
1049 $value['menu_slug'],
1050 $value['callback']
1051 );
1052 }
1053 ++$_menu_position;
1054 }
1055 }
1056
1057 $this->paint_mcp_badge();
1058 }
1059
1060 /**
1061 * Append the discovery badge to the registered menu titles.
1062 *
1063 * **After** registration, editing `$menu` / `$submenu` in place — the same
1064 * shape `add_custom_classes_to_menu_items()` uses — and never by passing a
1065 * decorated title to `add_menu_page()`. That distinction is not cosmetic:
1066 * core stores `sanitize_title( $menu_title )` as `$admin_page_hooks[ $slug ]`
1067 * (`wp-admin/includes/plugin.php:1397`) and builds every child page's hook
1068 * suffix from it (`get_plugin_page_hookname()`), so markup in the parent's
1069 * title renames `betterdocs_page_betterdocs-mcp` — and the MCP screen, whose
1070 * asset enqueue is keyed on that exact suffix, silently loads no React
1071 * bundle at all. Measured: the first visit came back 102,513 bytes with no
1072 * `dashboard.js`, against 489,272 bytes once the badge had cleared.
1073 *
1074 * Titles are only ever appended to, never rebuilt: the menu list is filtered
1075 * (`betterdocs_admin_menu`), so whatever a filter put in a title survives.
1076 *
1077 * @return void
1078 * @since 4.9.0
1079 */
1080 private function paint_mcp_badge() {
1081 if ( ! $this->mcp_badge ) {
1082 return;
1083 }
1084
1085 global $menu, $submenu;
1086
1087 if ( is_array( $menu ) ) {
1088 foreach ( $menu as &$item ) {
1089 if ( isset( $item[2] ) && $this->slug === $item[2] ) {
1090 $item[0] .= self::mcp_parent_bubble();
1091 break;
1092 }
1093 }
1094 unset( $item );
1095 }
1096
1097 if ( isset( $submenu[ $this->slug ] ) && is_array( $submenu[ $this->slug ] ) ) {
1098 foreach ( $submenu[ $this->slug ] as &$sub_item ) {
1099 if ( isset( $sub_item[2] ) && 'betterdocs-mcp' === $sub_item[2] ) {
1100 $sub_item[0] .= self::mcp_submenu_pill();
1101 break;
1102 }
1103 }
1104 unset( $sub_item );
1105 }
1106 }
1107
1108 /**
1109 * Whether the current user should see the one-time MCP discovery badge.
1110 *
1111 * True only for a user who can actually reach the screen and has never
1112 * opened it. The capability is the one the MCP menu item is already
1113 * registered with (`manage_options`) rather than a second, re-derived rule —
1114 * so the badge can never advertise a page its reader cannot open.
1115 *
1116 * @return bool
1117 * @since 4.9.0
1118 */
1119 public static function should_flag_mcp() {
1120 if ( ! current_user_can( 'manage_options' ) ) {
1121 return false;
1122 }
1123
1124 $user_id = get_current_user_id();
1125
1126 if ( ! $user_id ) {
1127 return false;
1128 }
1129
1130 // `get_user_meta( …, true )` answers '' for a key that is not there, and
1131 // the value written is always `time()` — so an empty string is the only
1132 // shape "never opened" takes.
1133 return '' === get_user_meta( $user_id, self::MCP_SEEN_META, true );
1134 }
1135
1136 /**
1137 * WordPress' own update-count bubble, for the BetterDocs parent menu item.
1138 *
1139 * Core's markup on purpose: the red bubble, its position and its dark-mode
1140 * colours are already in `wp-admin`'s stylesheet, so this needs no CSS of
1141 * ours and cannot drift from the Plugins/Updates bubbles beside it.
1142 *
1143 * @return string
1144 * @since 4.9.0
1145 */
1146 private static function mcp_parent_bubble() {
1147 return ' <span class="update-plugins count-1"><span class="update-count">1</span></span>';
1148 }
1149
1150 /**
1151 * The green "New" pill for the MCP submenu item.
1152 *
1153 * Core has no submenu-badge markup, so this one is ours — styled by
1154 * `mcp_badge_styles()`.
1155 *
1156 * @return string
1157 * @since 4.9.0
1158 */
1159 private static function mcp_submenu_pill() {
1160 return ' <span class="bd-menu-pill">' . esc_html__( 'New', 'betterdocs' ) . '</span>';
1161 }
1162
1163 /**
1164 * Whether this request is the MCP screen being opened by someone who can
1165 * open it.
1166 *
1167 * The whole of the clearing decision, in one static so it can be pinned by
1168 * a test. It reads the **page slug** rather than an admin hook suffix
1169 * because the suffix is derived state: core builds it from
1170 * `sanitize_title()` of the *parent* menu title (`get_plugin_page_hookname()`),
1171 * that title is filtered (`betterdocs_admin_menu`), and the parent slug is
1172 * spelled two ways in this class already. `?page=betterdocs-mcp` is the one
1173 * thing that identifies this screen on every install (ADR-065).
1174 *
1175 * The capability is `manage_options`, the same one the MCP menu item is
1176 * registered with and the same one {@see self::should_flag_mcp()} gates on:
1177 * nothing may be written for a user who cannot reach the page.
1178 *
1179 * @return bool
1180 * @since 4.9.0
1181 */
1182 public static function is_mcp_screen_request() {
1183 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection; see mark_mcp_seen().
1184 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1185
1186 if ( 'betterdocs-mcp' !== $page ) {
1187 return false;
1188 }
1189
1190 return current_user_can( 'manage_options' );
1191 }
1192
1193 /**
1194 * Record that this user has now seen the MCP screen.
1195 *
1196 * Bound to `admin_init` — which fires for every admin request — and gated on
1197 * the page slug, rather than to `load-{$hook_suffix}` for the one suffix
1198 * `add_submenu_page()` happened to return. `admin_menu` has already run by
1199 * the time `admin_init` fires (measured), so the badge is still painted on
1200 * *this* request and is gone from the next admin page — that is expected and
1201 * correct. Do not add JavaScript to strip it mid-request.
1202 *
1203 * **No nonce, on purpose.** A nonce protects a state change an attacker
1204 * could make a logged-in administrator perform unknowingly. The only state
1205 * here is "this administrator has now been shown the MCP screen once", it is
1206 * written for the current user alone, it holds no attacker-chosen value, and
1207 * the worst a forged request can achieve is hiding a discovery badge from
1208 * the person it was drawn for. A nonce on a plain page view would also have
1209 * to survive the menu link, which carries none.
1210 *
1211 * @return void
1212 * @since 4.9.0
1213 */
1214 public function mark_mcp_seen() {
1215 if ( ! self::is_mcp_screen_request() ) {
1216 return;
1217 }
1218
1219 $user_id = get_current_user_id();
1220
1221 if ( ! $user_id ) {
1222 return;
1223 }
1224
1225 update_user_meta( $user_id, self::MCP_SEEN_META, time() );
1226 }
1227
1228 /**
1229 * The handful of declarations the "New" pill needs, inline, and only while
1230 * it is being shown.
1231 *
1232 * The menu is read from the WordPress Dashboard, and `styles()` above
1233 * early-returns on non-BetterDocs screens — so `admin/css/dashboard.css` is
1234 * not loaded where this pill is seen. Loading the whole BetterDocs admin
1235 * stylesheet globally, or shipping a stylesheet file for nine declarations,
1236 * both cost far more than printing them here. The accent is written out
1237 * rather than taken from `--base-color-700`: that token lives in
1238 * `dashboard.css`, which is exactly the file that is not loaded here.
1239 *
1240 * @return void
1241 * @since 4.9.0
1242 */
1243 public function mcp_badge_styles() {
1244 if ( ! $this->mcp_badge ) {
1245 return;
1246 }
1247
1248 echo '<style id="betterdocs-menu-pill">#adminmenu .bd-menu-pill{display:inline-block;background:#00b884;color:#fff;font-size:10px;text-transform:uppercase;line-height:1.6;padding:1px 6px;margin-left:6px;border-radius:9px;}</style>' . "\n";
1249 }
1250
1251 private function register_modern_ui_fallback() {
1252 // Add the submenu with valid parent slug
1253 add_submenu_page(
1254 'betterdocs', // Valid parent slug
1255 __( 'All Docs', 'betterdocs' ),
1256 '', // Empty menu title hides it
1257 'edit_docs',
1258 'betterdocs-admin',
1259 array( $this, 'output' )
1260 );
1261
1262 // Hide the menu item from appearing in the admin sidebar
1263 global $submenu;
1264 if ( isset( $submenu['betterdocs'] ) ) {
1265 foreach ( $submenu['betterdocs'] as $key => $item ) {
1266 if ( 'betterdocs-admin' === $item[2] ) {
1267 unset( $submenu['betterdocs'][ $key ] );
1268 break;
1269 }
1270 }
1271 }
1272 }
1273
1274 /**
1275 * BetterDocs Admin Page Output
1276 *
1277 * @return void
1278 * @since 1.0.0
1279 */
1280 public function output() {
1281 if ( betterdocs()->is_pro_active()
1282 && version_compare( betterdocs()->pro_version(), '3.3.4', '<=' )
1283 && get_current_screen()->id == 'betterdocs_page_betterdocs-analytics' ) {
1284 betterdocs_pro()->views->get( 'admin/analytics-pro' );
1285 } else {
1286 betterdocs()->views->get(
1287 'admin/main',
1288 array(
1289 'admin_ui' => 'dnd',
1290 )
1291 );
1292 }
1293 }
1294
1295 /**
1296 * Menu creator helper
1297 *
1298 * @param string $title
1299 * @param string $slug
1300 * @param string $cap
1301 * @param array $callback
1302 *
1303 * @return array
1304 * @since 2.5.0
1305 */
1306 private function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null, $optional = array() ) {
1307 return Helper::normalize_menu( $title, $slug, $cap, $callback, $optional );
1308 }
1309
1310 /**
1311 * BetterDocs Menu List
1312 *
1313 * @return array
1314 * @since 1.0.0
1315 */
1316 private function menu_list() {
1317 $parent_slug = array();
1318
1319 $betterdocs_admin_pages = array(
1320 'betterdocs' => array(
1321 'menu_slug' => $this->slug,
1322 'page_title' => 'BetterDocs',
1323 'menu_title' => 'BetterDocs',
1324 'capability' => 'edit_docs',
1325 'callback' => array( $this, 'output' ),
1326 'icon_url' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg', true ),
1327 'position' => 5,
1328 ),
1329 'dashboard' => $this->normalize_menu(
1330 __( 'Dashboard', 'betterdocs' ),
1331 'betterdocs-dashboard',
1332 'edit_docs',
1333 array(
1334 $this,
1335 'output',
1336 )
1337 ),
1338 'all_docs' => $this->normalize_menu(
1339 __( 'All Docs', 'betterdocs' ),
1340 $this->ui_slug(),
1341 'edit_docs',
1342 array( $this, 'output' ),
1343 $parent_slug
1344 ),
1345 'add_new' => $this->normalize_menu(
1346 __( 'Add New', 'betterdocs' ),
1347 'post-new.php?post_type=docs'
1348 ),
1349 'categories' => $this->normalize_menu(
1350 __( 'Categories', 'betterdocs' ),
1351 'betterdocs-doc-categories',
1352 'manage_doc_terms',
1353 array( $this, 'output' ),
1354 $parent_slug
1355 ),
1356 'tags' => $this->normalize_menu(
1357 __( 'Tags', 'betterdocs' ),
1358 'betterdocs-doc-tags',
1359 'manage_doc_terms',
1360 array( $this, 'output' ),
1361 $parent_slug
1362 ),
1363 'settings' => $this->normalize_menu(
1364 __( 'Settings', 'betterdocs' ),
1365 'betterdocs-settings',
1366 'edit_docs_settings',
1367 array(
1368 $this,
1369 'output',
1370 ),
1371 $parent_slug
1372 ),
1373 'mcp' => $this->normalize_menu(
1374 __( 'MCP', 'betterdocs' ),
1375 'betterdocs-mcp',
1376 'manage_options',
1377 array(
1378 $this,
1379 'output',
1380 ),
1381 $parent_slug
1382 ),
1383 'analytics' => $this->normalize_menu(
1384 __( 'Analytics', 'betterdocs' ),
1385 'betterdocs-analytics',
1386 'read_docs_analytics',
1387 array(
1388 $this,
1389 'output',
1390 ),
1391 $parent_slug
1392 ),
1393 'faq' => $this->normalize_menu(
1394 __( 'FAQ Builder', 'betterdocs' ),
1395 'betterdocs-faq',
1396 'read_faq_builder',
1397 array(
1398 $this,
1399 'output',
1400 ),
1401 $parent_slug
1402 ),
1403 );
1404
1405 if ( betterdocs()->is_pro_active() && betterdocs()->settings->get( 'enable_glossaries' ) == true ) {
1406 $betterdocs_admin_pages['glossaries'] = $this->normalize_menu(
1407 __( 'Glossaries', 'betterdocs' ),
1408 'betterdocs-glossaries',
1409 'read_docs_analytics',
1410 array(
1411 $this,
1412 'output',
1413 ),
1414 $parent_slug
1415 );
1416 }
1417
1418 // API Docs ships in Pro, which overwrites this same key in place — declaring
1419 // the slot here is what keeps the item in this position. Without Pro it
1420 // holds Free's locked teaser instead.
1421 if ( betterdocs()->show_api_docs_teaser() || betterdocs()->has_api_docs() ) {
1422 $betterdocs_admin_pages['api_docs'] = $this->normalize_menu(
1423 __( 'API Docs', 'betterdocs' ),
1424 'betterdocs-api-docs',
1425 apply_filters( 'betterdocs_api_ref_capability', 'manage_options' ),
1426 array(
1427 $this,
1428 'output',
1429 ),
1430 $parent_slug
1431 );
1432 }
1433
1434 if ( ! betterdocs()->is_chatbot_active() ) {
1435 $betterdocs_admin_pages['ai_chatbot'] = $this->normalize_menu(
1436 __( 'AI Chatbot', 'betterdocs' ),
1437 'betterdocs-ai-chatbot',
1438 'edit_docs_settings',
1439 array(
1440 $this,
1441 'output',
1442 ),
1443 $parent_slug
1444 );
1445 }
1446
1447 return apply_filters( 'betterdocs_admin_menu', $betterdocs_admin_pages, array( $this, 'output' ), $parent_slug );
1448 }
1449
1450 public function add_custom_classes_to_menu_items() {
1451 global $menu, $submenu;
1452
1453 $menu_items = array(
1454 'betterdocs' => 'betterdocs',
1455 'betterdocs_page_all_docs' => 'betterdocs-all-docs',
1456 'betterdocs_page_add_new' => 'betterdocs-add-new',
1457 'betterdocs-doc-categories' => 'betterdocs-categories',
1458 'betterdocs-doc-tags' => 'betterdocs-tags',
1459 'betterdocs-settings' => 'betterdocs-settings',
1460 'betterdocs-mcp' => 'betterdocs-mcp',
1461 'betterdocs-analytics' => 'betterdocs-analytics',
1462 'betterdocs-faq' => 'betterdocs-faq',
1463 'betterdocs-glossaries' => 'betterdocs-glossaries',
1464 'betterdocs-ai-chatbot' => 'betterdocs-ai-chatbot',
1465 'betterdocs-api-docs' => 'betterdocs-api-docs',
1466 'edit-tags.php?taxonomy=knowledge_base&post_type=docs' => 'betterdocs-multiplekb',
1467 );
1468
1469 foreach ( $menu as &$item ) {
1470 if ( isset( $menu_items[ $item[2] ] ) ) {
1471 if ( ! isset( $item[4] ) ) {
1472 $item[4] = '';
1473 }
1474 $item[4] .= '' . $menu_items[ $item[2] ];
1475 }
1476 }
1477
1478 foreach ( $submenu as &$submenu_items ) {
1479 foreach ( $submenu_items as &$sub_item ) {
1480 if ( isset( $menu_items[ $sub_item[2] ] ) ) {
1481 if ( ! isset( $sub_item[4] ) ) {
1482 $sub_item[4] = '';
1483 }
1484 $sub_item[4] .= '' . $menu_items[ $sub_item[2] ];
1485 }
1486 }
1487 }
1488 }
1489
1490 public function quick_setup_menu( $menus ) {
1491 $betterdocs_settings = get_option( 'betterdocs_settings' );
1492 if ( $betterdocs_settings ) {
1493 return $menus;
1494 } else {
1495 $menus['quick_setup'] = $this->normalize_menu(
1496 __( 'Quick Setup', 'betterdocs' ),
1497 'betterdocs-setup',
1498 'delete_users',
1499 array(
1500 $this->container->get( SetupWizard::class ),
1501 'views',
1502 )
1503 );
1504 }
1505
1506 return $menus;
1507 }
1508
1509 public function insert_plugin_links( $links ) {
1510 $links[] = '<a href="admin.php?page=betterdocs-settings">' . __( 'Settings', 'betterdocs' ) . '</a>';
1511
1512 if ( ! is_plugin_active( 'betterdocs-pro/betterdocs-pro.php' ) ) {
1513 $links[] = '<a href="https://betterdocs.co/upgrade-to-pro-plugins-wp" target="_blank" style="color: #000; font-weight: bold;">' . __( 'Upgrade to Pro', 'betterdocs' ) . '</a>';
1514 }
1515
1516 return $links;
1517 }
1518
1519 public function toolbar_menu( $admin_bar ) {
1520 if ( ! is_admin() || ! is_admin_bar_showing() ) {
1521 return;
1522 }
1523
1524 // Show only when the user is a member of this site, or they're a super admin.
1525 if ( ! is_user_member_of_blog() && ! is_super_admin() ) {
1526 return;
1527 }
1528
1529 $docs_url = '';
1530 $encyclopedia_url = '';
1531
1532 if ( $this->settings->get( 'builtin_doc_page' ) ) {
1533 $docs_url = get_post_type_archive_link( 'docs' );
1534 } elseif ( intval( $docs_page = $this->settings->get( 'docs_page' ) ) ) {
1535 $docs_url = ! empty( $docs_page ) ? get_page_link( $docs_page ) : false;
1536 }
1537
1538 if ( ! $docs_url ) {
1539 return;
1540 }
1541
1542 $slug = $this->settings->get( 'encyclopedia_root_slug' );
1543
1544 global $wp_rewrite;
1545 if ( $wp_rewrite->using_index_permalinks() ) {
1546 $slug = $wp_rewrite->index . '/' . $slug;
1547 }
1548
1549 $encyclopedia_url = home_url( $slug );
1550
1551 $admin_bar->add_node(
1552 array(
1553 'parent' => 'site-name',
1554 'id' => 'view-docs',
1555 'title' => __( 'Visit Documentation', 'betterdocs' ),
1556 'href' => $docs_url,
1557 )
1558 );
1559
1560 $is_enable_encyclopedia = betterdocs()->settings->get( 'enable_encyclopedia' );
1561
1562 if ( $is_enable_encyclopedia && betterdocs()->is_pro_active() ) {
1563 $admin_bar->add_node(
1564 array(
1565 'parent' => 'site-name',
1566 'id' => 'view-encyclopedia',
1567 'title' => __( 'Visit Encyclopedia', 'betterdocs' ),
1568 'href' => $encyclopedia_url,
1569 )
1570 );
1571 }
1572 }
1573
1574 /**
1575 * Save last visited admin ui
1576 *
1577 * @since 3.0.1
1578 */
1579 public function save_admin_page() {
1580 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1581 $post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : '';
1582 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1583 $bdocs_view = isset( $_GET['bdocs_view'] ) ? sanitize_text_field( wp_unslash( $_GET['bdocs_view'] ) ) : '';
1584 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1585 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
1586
1587 if ( 'docs' === $post_type && 'classic' === $bdocs_view ) {
1588 update_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', 'classic_ui' );
1589 } elseif ( 'betterdocs-admin' === $page ) {
1590 update_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', 'modern_ui' );
1591 }
1592 }
1593
1594 /**
1595 * Return last visited admin ui slug
1596 *
1597 * @return string
1598 * @since 3.0.1
1599 */
1600 public function ui_slug() {
1601 $last_visited = get_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', true );
1602 $docs_exist = get_posts(
1603 array(
1604 'post_type' => 'docs',
1605 'post_status' => 'any',
1606 'numberposts' => 1,
1607 )
1608 );
1609
1610 return ( 'modern_ui' === $last_visited || empty( $docs_exist ) )
1611 ? 'betterdocs-admin'
1612 : 'edit.php?post_type=docs&bdocs_view=classic';
1613 }
1614
1615 /**
1616 * Resets a duplicate submenu in WordPress if the parent main menu and the first submenu permalink are not the same.
1617 *
1618 * @return string
1619 * @since 3.0.1
1620 */
1621 public function reset_submenu() {
1622 global $submenu;
1623
1624 $docs = get_posts( array( 'post_type' => 'docs' ) );
1625 if ( count( $docs ) == 0 ) {
1626 return;
1627 }
1628
1629 $last_visited = get_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', true );
1630
1631 if ( 'classic_ui' === $last_visited && isset( $submenu['betterdocs-admin'] ) && in_array( 'betterdocs-admin', $submenu['betterdocs-admin'][0] ) ) {
1632 unset( $submenu['betterdocs-admin'][0] );
1633 $submenu['betterdocs-admin'] = array_values( $submenu['betterdocs-admin'] );
1634 }
1635 }
1636
1637 /**
1638 * Initialize Black Friday Pointer
1639 *
1640 * @return void
1641 * @since 3.7.0
1642 */
1643 private function init_black_friday_pointer() {
1644 // Only initialize if conditions are met
1645 if ( NoticePointers::should_display_notice() ) {
1646 new NoticePointers();
1647 }
1648 }
1649
1650 /**
1651 * AJAX handler for dismissing Black Friday pointer
1652 *
1653 * @return void
1654 * @since 3.7.0
1655 */
1656 public function ajax_dismiss_black_friday_pointer() {
1657 // Verify nonce
1658 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
1659 if ( ! wp_verify_nonce( $nonce, 'betterdocs_dismiss_pointer' ) ) {
1660 wp_send_json_error( array( 'message' => __( 'Invalid nonce', 'betterdocs' ) ) );
1661 }
1662
1663 // Check if user has permission
1664 if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'edit_docs' ) ) {
1665 wp_send_json_error( array( 'message' => __( 'Permission denied', 'betterdocs' ) ) );
1666 }
1667
1668 // Get the introduction key
1669 $introduction_key = isset( $_POST['introduction_key'] ) ? sanitize_text_field( wp_unslash( $_POST['introduction_key'] ) ) : '';
1670
1671 if ( empty( $introduction_key ) ) {
1672 wp_send_json_error( array( 'message' => __( 'Invalid introduction key', 'betterdocs' ) ) );
1673 }
1674
1675 // Set the introduction as viewed
1676 NoticePointers::set_introduction_viewed( $introduction_key );
1677
1678 // Clear the priority option so other plugins can set their priority
1679 delete_option( '_wpdeveloper_plugin_pointer_priority' );
1680
1681 wp_send_json_success( array( 'message' => __( 'Pointer dismissed successfully', 'betterdocs' ) ) );
1682 }
1683
1684 /**
1685 * Get count of docs that are not yet synced
1686 * Similar to Helper::get_not_synced_post_ids() in betterdocs-ai-chatbot plugin
1687 *
1688 * @return int
1689 * @since 3.7.0
1690 */
1691 private function get_not_synced_docs_count() {
1692 $new_post_ids = get_option( 'saved_docs_post_ids', array() );
1693 $error_posts_data = get_option( 'betterdocs_ai_chatbot_error_posts', array() );
1694
1695 // Extract post IDs from error posts (handle both old and new formats)
1696 $error_post_ids = array();
1697 if ( is_array( $error_posts_data ) && ! empty( $error_posts_data ) ) {
1698 foreach ( $error_posts_data as $key => $value ) {
1699 if ( is_numeric( $key ) && is_numeric( $value ) ) {
1700 // Old format: numeric key with post ID as value
1701 $error_post_ids[] = $value;
1702 } elseif ( is_numeric( $key ) && is_array( $value ) && isset( $value['post_id'] ) ) {
1703 // New format: post ID as key with structured data
1704 $error_post_ids[] = $key;
1705 } elseif ( is_numeric( $key ) ) {
1706 // New format: post ID as key
1707 $error_post_ids[] = $key;
1708 }
1709 }
1710 }
1711
1712 // Merge both arrays and remove duplicates, then count
1713 return count( array_unique( array_merge( $new_post_ids, $error_post_ids ) ) );
1714 }
1715 }
1716