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
betterdocs / includes / Core / Admin.php

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

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