PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / includes / Core / Admin.php

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

1,805 lines 63.3 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 // BetterDocs' own admin screens. Promotional notices are limited to these
488 // (WP.org guideline 11), never the whole of wp-admin.
489 $betterdocs_screens = array(
490 'toplevel_page_betterdocs-dashboard',
491 'admin_page_betterdocs-admin',
492 'betterdocs_page_betterdocs-admin',
493 'betterdocs_page_betterdocs-settings',
494 'betterdocs_page_betterdocs-faq',
495 'betterdocs_page_betterdocs-analytics',
496 'betterdocs_page_betterdocs-glossaries',
497 'betterdocs_page_betterdocs-ai-chatbot',
498 'betterdocs_page_betterdocs-api-docs',
499 'betterdocs_page_betterdocs-doc-categories',
500 'betterdocs_page_betterdocs-doc-tags',
501 'edit-doc_category',
502 'edit-doc_tag',
503 );
504
505 $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>';
506 $_summer_campaign_notice = array(
507 'thumbnail' => $this->assets->icon( 'betterdocs-logo.svg', true ),
508 'html' => $summer_campaign_message,
509 'links' => array(
510 'support' => array(
511 'link' => 'https://betterdocs.co/summer2026-admin-notice',
512 'attributes' => array(
513 'target' => '_blank',
514 'class' => 'offer-button',
515 ),
516 'label' => __( 'Upgrade To PRO Now', 'betterdocs' ),
517 ),
518 'maybe_later' => array(
519 'label' => __( 'I’ll Grab It Later', 'betterdocs' ),
520 'attributes' => array(
521 'target' => '_blank',
522 // A promo dismissal is permanent: no 15-day re-show.
523 'data-dismiss' => true,
524 'class' => 'dismiss-btn',
525 ),
526 ),
527 ),
528 );
529
530 $notices->add(
531 'summer-campaign-26',
532 $_summer_campaign_notice,
533 array(
534 'start' => $notices->time(),
535 'recurrence' => false,
536 'dismissible' => true,
537 'expire' => strtotime( '11:59:59pm June 25, 2026' ),
538 'display_if' => ! is_plugin_active( 'betterdocs-pro/betterdocs-pro.php' ),
539 'screens' => $betterdocs_screens,
540 )
541 );
542
543 self::$cache_bank->create_account( $notices );
544 self::$cache_bank->calculate_deposits( $notices );
545 if ( method_exists( self::$cache_bank, 'clear_notices_in_' ) ) {
546 self::$cache_bank->clear_notices_in_(
547 $betterdocs_screens,
548 $notices,
549 true
550 );
551 }
552 }
553
554 /**
555 * Resolve the admin dark-mode preference.
556 *
557 * The mode switcher stores the choice in a client cookie (no DB write, shared
558 * across every admin screen). Fall back to the legacy
559 * `betterdocs_settings['dark_mode']` value for installs that set it before this
560 * change and haven't toggled since.
561 *
562 * @return bool
563 */
564 public function is_dark_mode() {
565 if ( isset( $_COOKIE['betterdocs_admin_dark_mode'] ) ) {
566 return '1' === $_COOKIE['betterdocs_admin_dark_mode']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
567 }
568
569 $saved = get_option( 'betterdocs_settings', array() );
570 return ! empty( $saved['dark_mode'] );
571 }
572
573 /**
574 * Whether the knowledge base is genuinely empty (no doc categories and no
575 * non-trash docs). Localized to the admin so the All Docs panel can render a
576 * skeleton shaped like the "No Category Found" empty card on first paint —
577 * instead of a category/docs skeleton it would immediately replace — without
578 * waiting for the REST fetch to reveal the count.
579 *
580 * @return bool
581 */
582 public function kb_is_empty() {
583 $cats = wp_count_terms( array( 'taxonomy' => 'doc_category', 'hide_empty' => false ) );
584 $cats = is_wp_error( $cats ) ? 0 : (int) $cats;
585 if ( $cats > 0 ) {
586 return false;
587 }
588
589 $counts = (array) wp_count_posts( 'docs' );
590 $total = 0;
591 foreach ( array( 'publish', 'future', 'draft', 'pending', 'private' ) as $status ) {
592 $total += isset( $counts[ $status ] ) ? (int) $counts[ $status ] : 0;
593 }
594
595 return 0 === $total;
596 }
597
598 public function body_classes( $classes ) {
599 $dark_mode = $this->is_dark_mode();
600 $current_screen_id = get_current_screen() != null ? str_replace( 'betterdocs_page_', '', str_replace( 'toplevel_page_', '', str_replace( 'admin_page_', '', get_current_screen()->id ) ) ) : '';
601 /**
602 * Filter the list of (prefix-stripped) screen ids that receive the
603 * `betterdocs-admin` body class (and dark-mode class). Pro/add-ons can
604 * register their own React admin pages, e.g. the Knowledge Base page.
605 *
606 * @param string[] $registered_screens Screen ids with the page prefix removed.
607 */
608 $registered_screens = apply_filters( 'betterdocs_admin_screen_slugs', array(
609 'betterdocs-settings',
610 'betterdocs-admin',
611 'betterdocs-dashboard',
612 'betterdocs-analytics',
613 'betterdocs-content-iq',
614 'betterdocs-glossaries',
615 'betterdocs-faq',
616 'betterdocs-doc-categories',
617 'betterdocs-doc-tags',
618 'edit-doc_category',
619 'edit-doc_tag',
620 'edit-knowledge_base',
621 'betterdocs-ai-chatbot',
622 'betterdocs-api-docs',
623 // Without this the MCP screen never receives `betterdocs-admin`, and
624 // the design tokens' dark-mode overrides — which are declared on
625 // `.betterdocs-admin.betterdocs-dark-mode` — can never apply there:
626 // the switcher in the header flips the cookie and the page stays
627 // light. @since 4.9.0
628 'betterdocs-mcp',
629 ) );
630
631 if ( in_array( $current_screen_id, $registered_screens ) ) {
632 $classes .= ' betterdocs-admin ';
633 }
634
635 // Dark mode also applies on the Quick Setup wizard, whose self-scoped chrome
636 // keys off `.betterdocs_page_betterdocs-setup.betterdocs-dark-mode`.
637 $dark_screens = array_merge( $registered_screens, array( 'betterdocs-setup' ) );
638 if ( $dark_mode && in_array( $current_screen_id, $dark_screens, true ) ) {
639 $classes .= ' betterdocs-dark-mode ';
640 }
641
642 return $classes;
643 }
644
645 /**
646 * Remove Comments Column From List Table
647 *
648 * @param array $columns
649 *
650 * @return array
651 * @since 1.0.0
652 */
653 public function set_custom_edit_action_columns( $columns ) {
654 unset( $columns['comments'] );
655 $new_columns = array();
656 foreach ( $columns as $key => $value ) {
657 if ( 'date' == $key ) {
658 $new_columns['betterdocs_word_count'] = __( 'Word Count', 'betterdocs' ); // put the tags column before it
659 $new_columns['betterdocs_reaction'] = __( 'Reactions', 'betterdocs' );
660 }
661 $new_columns[ $key ] = $value;
662 }
663
664 return $new_columns;
665 }
666
667 public function manage_custom_columns( $column, $post_id ) {
668 global $wpdb;
669 switch ( $column ) {
670 case 'betterdocs_word_count':
671 $content_without_html_tags = trim( wp_strip_all_tags( get_post_field( 'post_content', $post_id ) ) );
672 preg_match_all( '/<[^>]*>|[\p{L}\p{M}]+/u', $content_without_html_tags, $matches );
673 $total_words = ! empty( $matches[0] ) ? count( $matches[0] ) : count( array() );
674 $word_count = $total_words;
675 echo '<span>' . esc_html( intval( $word_count ) ) . '</span>';
676 break;
677 case 'betterdocs_reaction':
678 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- per-post analytics aggregation rendered in admin list table; cache would mask live reactions.
679 $analytics = $wpdb->get_results(
680 $wpdb->prepare(
681 "SELECT
682 sum(impressions) as totalViews,
683 sum(unique_visit) as totalUniqueViews,
684 sum(happy + sad + normal) as totalReactions,
685 sum(happy) as totalHappy,
686 sum(normal) as totalNormal,
687 sum(sad) as totalSad
688 FROM {$wpdb->prefix}betterdocs_analytics
689 WHERE post_id = %d",
690 $post_id
691 )
692 );
693
694 echo '<ul class="reactions-count">
695 <li>
696 <a title="happy" class="betterdocs-feelings happy" data-feelings="happy" href="#">
697 <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">
698 <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
699 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
700 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
701 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" />
702 <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
703 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
704 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
705 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" />
706 </svg>
707 <span>' . esc_html( ( intval( $analytics[0]->totalHappy ) !== null ? intval( $analytics[0]->totalHappy ) : 0 ) ) . '</span>
708 </a>
709 </li>
710 <li>
711 <a title="normal" class="betterdocs-feelings normal" data-feelings="normal" href="#">
712 <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">
713 <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
714 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
715 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
716 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" />
717 </svg>
718 <span>' . esc_html( ( intval( $analytics[0]->totalNormal ) !== null ? intval( $analytics[0]->totalNormal ) : 0 ) ) . '</span>
719 </a>
720 </li>
721 <li>
722 <a title="sad" class="betterdocs-feelings sad" data-feelings="sad" href="#">
723 <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">
724 <circle class="st0" cx="27.5" cy="0.6" r="1.9" />
725 <circle class="st0" cx="36" cy="0.6" r="1.9" />
726 <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
727 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
728 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
729 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
730 c1.8,0,3.5,0.8,4.6,2.1C15,14.3,14.7,15,14.1,15z" />
731 </svg>
732 <span>' . esc_html( ( intval( $analytics[0]->totalNormal ) !== null ? intval( $analytics[0]->totalNormal ) : 0 ) ) . '</span>
733 </a>
734 </li>
735 </ul>';
736 break;
737 }
738 }
739
740 /**
741 * Enqueue Assets for Admin ( Styles )
742 *
743 * @param string $hook
744 *
745 * @return void
746 * @since 1.0.0
747 */
748 public function styles( $hook ) {
749 $this->assets->enqueue( 'betterdocs-global', 'admin/css/global.css', array(), 'all' );
750
751 if ( ! betterdocs()->is_betterdocs_screen( $hook ) ) {
752 return;
753 }
754
755 $this->assets->enqueue( 'betterdocs-select2', 'vendor/css/select2.min.css', array(), 'all' );
756 $this->assets->enqueue( 'betterdocs-daterangepicker', 'vendor/css/daterangepicker.css', array(), 'all' );
757 $this->assets->enqueue( 'betterdocs-old', 'admin/css/betterdocs.css', array(), 'all' );
758
759 /**
760 * This scripts enqueued for Dashboard App.
761 */
762 $this->assets->enqueue( 'betterdocs', 'admin/css/dashboard.css', array( 'betterdocs-old' ), '', BETTERDOCS_VERSION );
763 $this->assets->enqueue( 'betterdocs-icons', 'admin/btd-icon/style.css' );
764 }
765
766 /**
767 * Enqueue Assets for Admin ( Scripts )
768 *
769 * @param string $hook
770 *
771 * @return void
772 * @since 1.0.0
773 */
774 public function scripts( $hook ) {
775 // Classic-UI screens that should offer a "Switch to BetterDocs UI"
776 // button: All Docs, FAQ list, FAQ groups, Product FAQ groups,
777 // Doc Categories, Doc Tags. Maps each to the React admin page to
778 // return to; $switch_args carries extra query args (e.g. the FAQ
779 // Builder tab) appended to the React page URL.
780 $switch_page = '';
781 $switch_args = array();
782 if ( 'edit.php' === $hook && 'docs' === get_post_type() ) {
783 $switch_page = 'betterdocs-admin';
784 } elseif ( 'edit.php' === $hook && 'betterdocs_faq' === get_post_type() ) {
785 $switch_page = 'betterdocs-faq';
786 } elseif ( 'edit-tags.php' === $hook ) {
787 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
788 $taxonomy = $screen && ! empty( $screen->taxonomy )
789 ? $screen->taxonomy
790 : ( isset( $_GET['taxonomy'] ) ? sanitize_key( wp_unslash( $_GET['taxonomy'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
791 if ( 'betterdocs_faq_category' === $taxonomy ) {
792 $switch_page = 'betterdocs-faq';
793 } elseif ( 'betterdocs_product_faq_category' === $taxonomy ) {
794 // Product FAQ groups live on the FAQ Builder's WooCommerce tab.
795 $switch_page = 'betterdocs-faq';
796 $switch_args = array( 'faq_tab' => 'woocommerce' );
797 } elseif ( 'doc_category' === $taxonomy ) {
798 $switch_page = 'betterdocs-doc-categories';
799 } elseif ( 'doc_tag' === $taxonomy ) {
800 $switch_page = 'betterdocs-doc-tags';
801 }
802 }
803
804 /**
805 * Allow Pro/add-ons to map their own classic-UI taxonomy screens to a
806 * React admin page for the "Switch to BetterDocs UI" button — e.g. the
807 * Knowledge Base taxonomy, which only exists when Pro is active.
808 *
809 * @param string $switch_page React page slug, or '' for no switcher.
810 * @param string $hook Current admin page hook.
811 */
812 $switch_page = apply_filters( 'betterdocs_classic_switch_page', $switch_page, $hook );
813
814 if ( $switch_page ) {
815 $this->assets->enqueue(
816 'betterdocs-switcher',
817 'admin/js/switcher.js',
818 array(
819 'jquery',
820 )
821 );
822
823 $this->assets->localize(
824 'betterdocs-switcher',
825 'betterdocsSwitcher',
826 array(
827 'menu_title' => __( 'Switch to BetterDocs UI', 'betterdocs' ),
828 'page' => $switch_page,
829 'url' => add_query_arg(
830 array_merge( array( 'page' => $switch_page ), $switch_args ),
831 admin_url( 'admin.php' )
832 ),
833 'site_address' => get_bloginfo( 'url' ),
834 'betterdocs_pro_plugin' => betterdocs()->is_pro_active(),
835 'betterdocs_pro_version' => betterdocs()->pro_version(),
836 )
837 );
838
839 return;
840 }
841
842 wp_enqueue_script( 'wp-editor' ); // enqueue this for yoast related issue
843
844 if ( ! betterdocs()->is_betterdocs_screen( $hook ) ) {
845 return;
846 }
847
848 wp_enqueue_media(); // load early to fix problems with media upload issues on settings for WordPress 6.0.9
849 $this->assets->register( 'betterdocs-admin', 'admin/js/dashboard.js' );
850
851 $saved_settings = get_option( 'betterdocs_settings', false );
852 $dark_mode = $this->is_dark_mode();
853 $this->assets->localize(
854 'betterdocs-admin',
855 'betterdocs_admin',
856 array(
857 'ajaxurl' => admin_url( 'admin-ajax.php' ),
858 'doc_cat_order_nonce' => wp_create_nonce( 'doc_cat_order_nonce' ),
859 'knowledge_base_order_nonce' => wp_create_nonce( 'knowledge_base_order_nonce' ),
860 'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- pagination read from URL.
861 'per_page_id' => 'edit_doc_category_per_page',
862 'menu_title' => __( 'Switch to BetterDocs UI', 'betterdocs' ),
863 'dark_mode' => $dark_mode,
864 'kb_is_empty' => $this->kb_is_empty(),
865 'text' => __( 'Copied!', 'betterdocs' ),
866 'test_report' => __( 'Test Report!', 'betterdocs' ),
867 'sending' => __( 'Sending...', 'betterdocs' ),
868 'dir_url' => BETTERDOCS_ABSURL,
869 'rest_url' => esc_url_raw( rest_url() ),
870 'free_version' => betterdocs()->version,
871 'generate_data_url' => get_rest_url( null, '/betterdocs/v1/create-sample-docs' ),
872 'ai_sample_docs' => array(
873 'enabled' => (bool) betterdocs()->settings->get( 'enable_ai_sample_docs', true ),
874 'rest_base' => esc_url_raw( get_rest_url( null, '/betterdocs/v1/sample-docs' ) ),
875 ),
876 'nonce' => wp_create_nonce( 'wp_rest' ),
877 'sync_nonce' => wp_create_nonce( 'ai_chatbot_embed' ),
878 'count_all_docs' => array_sum( (array) wp_count_posts( 'docs' ) ),
879 'count_all_faq' => array_sum( (array) wp_count_posts( 'betterdocs_faq' ) ),
880 'faq_order' => get_option( 'betterdocs_faq_order', 'default' ),
881 'count_new_docs' => $this->get_not_synced_docs_count(),
882 'admin_url' => admin_url(),
883 'ia_preview' => betterdocs()->settings->get( 'ia_enable_preview', false ),
884 'multiple_kb' => betterdocs()->settings->get( 'multiple_kb' ),
885 'previewMode' => betterdocs()->settings->get( 'ia_enable_preview', false ),
886 'dashboard_mode' => get_option( 'dashboard_mode' ),
887 'betterdocs_pro_plugin' => betterdocs()->is_pro_active(),
888 'betterdocs_pro_version' => betterdocs()->pro_version(),
889 'analytics_older' => version_compare( betterdocs()->pro_version(), '3.3.4', '<=' ),
890 'betterdocs_ChatBot_plugin' => is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' ),
891 'api_docs_teaser' => betterdocs()->show_api_docs_teaser(),
892 'glossaries_teaser' => betterdocs()->show_glossary_teaser(),
893 'glossaries_needs_pro_update' => betterdocs()->glossaries_needs_pro_update(),
894 'glossaries_min_pro_version' => betterdocs()->glossaries_min_pro_version(),
895 'glossaries_pro_update_url' => self_admin_url( 'plugins.php' ),
896 'content_intelligence_teaser' => betterdocs()->show_content_intelligence_teaser(),
897 'is_woocommerce_active' => class_exists( 'WooCommerce' ),
898 'total_doc_category_terms' => wp_count_terms( 'doc_category' ),
899 'current_admin_language' => Helper::get_current_admin_language(),
900 'is_multilingual' => Helper::is_multilingual_active(),
901 'languages' => Helper::get_admin_languages(),
902 /**
903 * MCP page bootstrap. `abilities_api_available` decides whether the
904 * page offers a connection at all: without the Abilities API there
905 * is no tool catalog, so an AI client would connect and find
906 * nothing. `enabled` is only the initial paint — the toggle owns
907 * the value from then on.
908 *
909 * @since 4.9.0
910 */
911 'mcp' => array(
912 'abilities_api_available' => function_exists( 'wp_register_ability' ),
913 'enabled' => (bool) betterdocs()->settings->get( 'enable_mcp', false ),
914 'rest' => 'betterdocs/v1',
915 ),
916 )
917 );
918
919 // If wp-date (which includes moment.js) is not registered, enqueue your custom moment.js
920 if ( ! wp_script_is( 'wp-date', 'registered' ) ) {
921 $this->assets->enqueue( 'moment', 'vendor/js/moment.min.js', array() );
922 }
923 wp_enqueue_script( 'betterdocs-admin' );
924
925 /**
926 * Duplicate Codes Need to Be Removed From Here Onwards
927 */
928
929 // FAQ Builder Related Localization
930 betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/css/faq.css' );
931 betterdocs()->assets->enqueue( 'betterdocs-admin-faq', 'admin/js/faq.js' );
932
933 // Load the classic editor (TinyMCE + QuickTags) so the FAQ rich-text editor can mount via wp.editor.initialize().
934 if ( function_exists( 'wp_enqueue_editor' ) ) {
935 wp_enqueue_editor();
936 }
937 if ( function_exists( 'wp_enqueue_media' ) ) {
938 wp_enqueue_media();
939 }
940
941 // removing emoji support
942 remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
943 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
944
945 // Get settings and remove unnecessary keys
946 $betterdocs_settings = get_option( 'betterdocs_settings', false );
947 if ( is_array( $betterdocs_settings ) && ! current_user_can( 'edit_docs_settings' ) ) {
948 foreach ( Settings::sensitive_api_key_fields() as $sensitive_key ) {
949 unset( $betterdocs_settings[ $sensitive_key ] );
950 }
951 }
952
953 betterdocs()->assets->localize(
954 'betterdocs-admin-faq',
955 'betterdocsFaq',
956 array(
957 'dir_url' => BETTERDOCS_ABSURL,
958 'rest_url' => esc_url_raw( rest_url() ),
959 'free_version' => betterdocs()->version,
960 'nonce' => wp_create_nonce( 'wp_rest' ),
961 'betterdocs_settings' => $betterdocs_settings,
962 )
963 );
964
965 // Glossaries is Pro — Pro's Core\Glossaries::enqueue() owns that bundle
966 // and both of its localized objects (`betterdocs`, `betterdocsGlossary`).
967 }
968
969 /**
970 * All admin pages header
971 *
972 * @return void
973 * @since 1.0.0
974 */
975 public function header( $admin_tab_name ) {
976 $quick_links = array(
977 'switch_view' => sprintf(
978 '<a href="%s" class="betterdocs-button betterdocs-button-secondary">%s</a>',
979 add_query_arg(
980 array(
981 'post_type' => 'docs',
982 'bdocs_view' => 'classic',
983 ),
984 'edit.php'
985 ),
986 __( 'Switch to Classic UI', 'betterdocs' )
987 ),
988 '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' ) ),
989 );
990
991 $quick_links = apply_filters( 'betterdocs_quick_links', $quick_links );
992
993 betterdocs()->views->get(
994 'admin/header',
995 array(
996 'quick_links' => $quick_links,
997 'active_tab' => $admin_tab_name,
998 )
999 );
1000 }
1001
1002 /**
1003 * Register all the menus for BetterDocs
1004 *
1005 * @return void
1006 * @since 1.0.0
1007 */
1008 public function menus() {
1009 $default_args = array(
1010 'page_title' => 'BetterDocs',
1011 'menu_title' => 'BetterDocs',
1012 'capability' => 'edit_docs', // Unified capability
1013 'menu_slug' => $this->slug,
1014 'callback' => array( $this, 'output' ),
1015 'icon_url' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg', true ),
1016 'position' => 5,
1017 );
1018
1019 $_menu_position = 5;
1020 global $submenu;
1021
1022 // Always register both UI endpoints
1023 $this->register_modern_ui_fallback();
1024
1025 // The one-time MCP discovery badge (ADR-063). Decided once, here, and
1026 // remembered for `mcp_badge_styles()`: the pill's markup and the pill's
1027 // stylesheet have to be printed on the same requests as each other.
1028 $this->mcp_badge = self::should_flag_mcp();
1029
1030 foreach ( $this->menu_list() as $key => $value ) {
1031 if ( 'betterdocs' === $key ) {
1032 $callable = 'add_menu_page';
1033 $value = wp_parse_args( $value, $default_args );
1034 call_user_func_array( $callable, $value );
1035 } else {
1036 $is_core_page = strpos( $value['menu_slug'], '?' ) !== false;
1037
1038 if ( $is_core_page ) {
1039 // Add classic UI directly
1040 $submenu[ $this->slug ][] = array(
1041 $value['menu_title'],
1042 $value['capability'],
1043 $value['menu_slug'],
1044 $value['page_title'],
1045 );
1046 } else {
1047 // Add modern UI through WordPress API
1048 add_submenu_page(
1049 $this->slug,
1050 $value['page_title'],
1051 $value['menu_title'],
1052 $value['capability'],
1053 $value['menu_slug'],
1054 $value['callback']
1055 );
1056 }
1057 ++$_menu_position;
1058 }
1059 }
1060
1061 $this->paint_mcp_badge();
1062 }
1063
1064 /**
1065 * Append the discovery badge to the registered menu titles.
1066 *
1067 * **After** registration, editing `$menu` / `$submenu` in place — the same
1068 * shape `add_custom_classes_to_menu_items()` uses — and never by passing a
1069 * decorated title to `add_menu_page()`. That distinction is not cosmetic:
1070 * core stores `sanitize_title( $menu_title )` as `$admin_page_hooks[ $slug ]`
1071 * (`wp-admin/includes/plugin.php:1397`) and builds every child page's hook
1072 * suffix from it (`get_plugin_page_hookname()`), so markup in the parent's
1073 * title renames `betterdocs_page_betterdocs-mcp` — and the MCP screen, whose
1074 * asset enqueue is keyed on that exact suffix, silently loads no React
1075 * bundle at all. Measured: the first visit came back 102,513 bytes with no
1076 * `dashboard.js`, against 489,272 bytes once the badge had cleared.
1077 *
1078 * Titles are only ever appended to, never rebuilt: the menu list is filtered
1079 * (`betterdocs_admin_menu`), so whatever a filter put in a title survives.
1080 *
1081 * @return void
1082 * @since 4.9.0
1083 */
1084 private function paint_mcp_badge() {
1085 if ( ! $this->mcp_badge ) {
1086 return;
1087 }
1088
1089 global $menu, $submenu;
1090
1091 if ( is_array( $menu ) ) {
1092 foreach ( $menu as &$item ) {
1093 if ( isset( $item[2] ) && $this->slug === $item[2] ) {
1094 $item[0] .= self::mcp_parent_bubble();
1095 break;
1096 }
1097 }
1098 unset( $item );
1099 }
1100
1101 if ( isset( $submenu[ $this->slug ] ) && is_array( $submenu[ $this->slug ] ) ) {
1102 foreach ( $submenu[ $this->slug ] as &$sub_item ) {
1103 if ( isset( $sub_item[2] ) && 'betterdocs-content-iq' === $sub_item[2] ) {
1104 $sub_item[0] .= self::mcp_submenu_pill();
1105 break;
1106 }
1107 }
1108 unset( $sub_item );
1109 }
1110 }
1111
1112 /**
1113 * Whether the current user should see the one-time MCP discovery badge.
1114 *
1115 * True only for a user who can actually reach the screen and has never
1116 * opened it. The capability is the one the MCP menu item is already
1117 * registered with (`manage_options`) rather than a second, re-derived rule —
1118 * so the badge can never advertise a page its reader cannot open.
1119 *
1120 * @return bool
1121 * @since 4.9.0
1122 */
1123 public static function should_flag_mcp() {
1124 if ( ! current_user_can( 'manage_options' ) ) {
1125 return false;
1126 }
1127
1128 $user_id = get_current_user_id();
1129
1130 if ( ! $user_id ) {
1131 return false;
1132 }
1133
1134 // `get_user_meta( …, true )` answers '' for a key that is not there, and
1135 // the value written is always `time()` — so an empty string is the only
1136 // shape "never opened" takes.
1137 return '' === get_user_meta( $user_id, self::MCP_SEEN_META, true );
1138 }
1139
1140 /**
1141 * WordPress' own update-count bubble, for the BetterDocs parent menu item.
1142 *
1143 * Core's markup on purpose: the red bubble, its position and its dark-mode
1144 * colours are already in `wp-admin`'s stylesheet, so this needs no CSS of
1145 * ours and cannot drift from the Plugins/Updates bubbles beside it.
1146 *
1147 * @return string
1148 * @since 4.9.0
1149 */
1150 private static function mcp_parent_bubble() {
1151 return ' <span class="update-plugins count-1"><span class="update-count">1</span></span>';
1152 }
1153
1154 /**
1155 * The green "New" pill for the MCP submenu item.
1156 *
1157 * Core has no submenu-badge markup, so this one is ours — styled by
1158 * `mcp_badge_styles()`.
1159 *
1160 * @return string
1161 * @since 4.9.0
1162 */
1163 private static function mcp_submenu_pill() {
1164 return ' <span class="bd-menu-pill">' . esc_html__( 'New', 'betterdocs' ) . '</span>';
1165 }
1166
1167 /**
1168 * Whether this request is the MCP screen being opened by someone who can
1169 * open it.
1170 *
1171 * The whole of the clearing decision, in one static so it can be pinned by
1172 * a test. It reads the **page slug** rather than an admin hook suffix
1173 * because the suffix is derived state: core builds it from
1174 * `sanitize_title()` of the *parent* menu title (`get_plugin_page_hookname()`),
1175 * that title is filtered (`betterdocs_admin_menu`), and the parent slug is
1176 * spelled two ways in this class already. `?page=betterdocs-mcp` is the one
1177 * thing that identifies this screen on every install (ADR-065).
1178 *
1179 * The capability is `manage_options`, the same one the MCP menu item is
1180 * registered with and the same one {@see self::should_flag_mcp()} gates on:
1181 * nothing may be written for a user who cannot reach the page.
1182 *
1183 * @return bool
1184 * @since 4.9.0
1185 */
1186 public static function is_mcp_screen_request() {
1187 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection; see mark_mcp_seen().
1188 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1189
1190 if ( 'betterdocs-content-iq' !== $page ) {
1191 return false;
1192 }
1193
1194 return current_user_can( 'manage_options' );
1195 }
1196
1197 /**
1198 * Record that this user has now seen the MCP screen.
1199 *
1200 * Bound to `admin_init` — which fires for every admin request — and gated on
1201 * the page slug, rather than to `load-{$hook_suffix}` for the one suffix
1202 * `add_submenu_page()` happened to return. `admin_menu` has already run by
1203 * the time `admin_init` fires (measured), so the badge is still painted on
1204 * *this* request and is gone from the next admin page — that is expected and
1205 * correct. Do not add JavaScript to strip it mid-request.
1206 *
1207 * **No nonce, on purpose.** A nonce protects a state change an attacker
1208 * could make a logged-in administrator perform unknowingly. The only state
1209 * here is "this administrator has now been shown the MCP screen once", it is
1210 * written for the current user alone, it holds no attacker-chosen value, and
1211 * the worst a forged request can achieve is hiding a discovery badge from
1212 * the person it was drawn for. A nonce on a plain page view would also have
1213 * to survive the menu link, which carries none.
1214 *
1215 * @return void
1216 * @since 4.9.0
1217 */
1218 public function mark_mcp_seen() {
1219 if ( ! self::is_mcp_screen_request() ) {
1220 return;
1221 }
1222
1223 $user_id = get_current_user_id();
1224
1225 if ( ! $user_id ) {
1226 return;
1227 }
1228
1229 update_user_meta( $user_id, self::MCP_SEEN_META, time() );
1230 }
1231
1232 /**
1233 * The handful of declarations the "New" pill needs, inline, and only while
1234 * it is being shown.
1235 *
1236 * The menu is read from the WordPress Dashboard, and `styles()` above
1237 * early-returns on non-BetterDocs screens — so `admin/css/dashboard.css` is
1238 * not loaded where this pill is seen. Loading the whole BetterDocs admin
1239 * stylesheet globally, or shipping a stylesheet file for nine declarations,
1240 * both cost far more than printing them here. The accent is written out
1241 * rather than taken from `--base-color-700`: that token lives in
1242 * `dashboard.css`, which is exactly the file that is not loaded here.
1243 *
1244 * @return void
1245 * @since 4.9.0
1246 */
1247 public function mcp_badge_styles() {
1248 if ( ! $this->mcp_badge ) {
1249 return;
1250 }
1251
1252 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";
1253 }
1254
1255 private function register_modern_ui_fallback() {
1256 // Add the submenu with valid parent slug
1257 add_submenu_page(
1258 'betterdocs', // Valid parent slug
1259 __( 'All Docs', 'betterdocs' ),
1260 '', // Empty menu title hides it
1261 'edit_docs',
1262 'betterdocs-admin',
1263 array( $this, 'output' )
1264 );
1265
1266 // Hide the menu item from appearing in the admin sidebar
1267 global $submenu;
1268 if ( isset( $submenu['betterdocs'] ) ) {
1269 foreach ( $submenu['betterdocs'] as $key => $item ) {
1270 if ( 'betterdocs-admin' === $item[2] ) {
1271 unset( $submenu['betterdocs'][ $key ] );
1272 break;
1273 }
1274 }
1275 }
1276 }
1277
1278 /**
1279 * BetterDocs Admin Page Output
1280 *
1281 * @return void
1282 * @since 1.0.0
1283 */
1284 public function output() {
1285 if ( betterdocs()->is_pro_active()
1286 && version_compare( betterdocs()->pro_version(), '3.3.4', '<=' )
1287 && get_current_screen()->id == 'betterdocs_page_betterdocs-analytics' ) {
1288 betterdocs_pro()->views->get( 'admin/analytics-pro' );
1289 } else {
1290 betterdocs()->views->get(
1291 'admin/main',
1292 array(
1293 'admin_ui' => 'dnd',
1294 )
1295 );
1296 }
1297 }
1298
1299 /**
1300 * Menu creator helper
1301 *
1302 * @param string $title
1303 * @param string $slug
1304 * @param string $cap
1305 * @param array $callback
1306 *
1307 * @return array
1308 * @since 2.5.0
1309 */
1310 private function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null, $optional = array() ) {
1311 return Helper::normalize_menu( $title, $slug, $cap, $callback, $optional );
1312 }
1313
1314 /**
1315 * BetterDocs Menu List
1316 *
1317 * @return array
1318 * @since 1.0.0
1319 */
1320 private function menu_list() {
1321 $parent_slug = array();
1322
1323 $betterdocs_admin_pages = array(
1324 'betterdocs' => array(
1325 'menu_slug' => $this->slug,
1326 'page_title' => 'BetterDocs',
1327 'menu_title' => 'BetterDocs',
1328 'capability' => 'edit_docs',
1329 'callback' => array( $this, 'output' ),
1330 'icon_url' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg', true ),
1331 'position' => 5,
1332 ),
1333 'dashboard' => $this->normalize_menu(
1334 __( 'Dashboard', 'betterdocs' ),
1335 'betterdocs-dashboard',
1336 'edit_docs',
1337 array(
1338 $this,
1339 'output',
1340 )
1341 ),
1342 'all_docs' => $this->normalize_menu(
1343 __( 'All Docs', 'betterdocs' ),
1344 $this->ui_slug(),
1345 'edit_docs',
1346 array( $this, 'output' ),
1347 $parent_slug
1348 ),
1349 'add_new' => $this->normalize_menu(
1350 __( 'Add New', 'betterdocs' ),
1351 'post-new.php?post_type=docs'
1352 ),
1353 'categories' => $this->normalize_menu(
1354 __( 'Categories', 'betterdocs' ),
1355 'betterdocs-doc-categories',
1356 'manage_doc_terms',
1357 array( $this, 'output' ),
1358 $parent_slug
1359 ),
1360 'tags' => $this->normalize_menu(
1361 __( 'Tags', 'betterdocs' ),
1362 'betterdocs-doc-tags',
1363 'manage_doc_terms',
1364 array( $this, 'output' ),
1365 $parent_slug
1366 ),
1367 'settings' => $this->normalize_menu(
1368 __( 'Settings', 'betterdocs' ),
1369 'betterdocs-settings',
1370 'edit_docs_settings',
1371 array(
1372 $this,
1373 'output',
1374 ),
1375 $parent_slug
1376 ),
1377 'mcp' => $this->normalize_menu(
1378 __( 'MCP', 'betterdocs' ),
1379 'betterdocs-mcp',
1380 'manage_options',
1381 array(
1382 $this,
1383 'output',
1384 ),
1385 $parent_slug
1386 ),
1387 'analytics' => $this->normalize_menu(
1388 __( 'Analytics', 'betterdocs' ),
1389 'betterdocs-analytics',
1390 'read_docs_analytics',
1391 array(
1392 $this,
1393 'output',
1394 ),
1395 $parent_slug
1396 ),
1397 'content_intelligence' => $this->normalize_menu(
1398 __( 'Content IQ', 'betterdocs' ),
1399 'betterdocs-content-iq',
1400 'read_docs_analytics',
1401 array(
1402 $this,
1403 'output',
1404 ),
1405 $parent_slug
1406 ),
1407 'faq' => $this->normalize_menu(
1408 __( 'FAQ Builder', 'betterdocs' ),
1409 'betterdocs-faq',
1410 'read_faq_builder',
1411 array(
1412 $this,
1413 'output',
1414 ),
1415 $parent_slug
1416 ),
1417 );
1418
1419 // Content Intelligence ships in Pro, which overwrites that same key in place.
1420 // Unlike API Docs it has to sit directly after Analytics, and `menus()` walks
1421 // this array in insertion order — so the slot is declared inside the literal
1422 // above and only withdrawn here. Appending it after the fact, api_docs-style,
1423 // would park it at the bottom of the menu.
1424 if ( ! ( betterdocs()->show_content_intelligence_teaser() || betterdocs()->has_content_intelligence() ) ) {
1425 unset( $betterdocs_admin_pages['content_intelligence'] );
1426 }
1427
1428 // Glossaries is Pro. Reserve this same 'glossaries' slot for Free's locked
1429 // teaser so the item keeps this position; once Pro is active the real
1430 // screen overwrites the key in place (same pattern as API Docs below).
1431 if ( betterdocs()->show_glossary_teaser() || ( betterdocs()->is_pro_active() && betterdocs()->settings->get( 'enable_glossaries' ) == true ) ) {
1432 $betterdocs_admin_pages['glossaries'] = $this->normalize_menu(
1433 __( 'Glossaries', 'betterdocs' ),
1434 'betterdocs-glossaries',
1435 betterdocs()->show_glossary_teaser() ? 'manage_options' : 'read_docs_analytics',
1436 array(
1437 $this,
1438 'output',
1439 ),
1440 $parent_slug
1441 );
1442 }
1443
1444 // API Docs ships in Pro, which overwrites this same key in place — declaring
1445 // the slot here is what keeps the item in this position. Without Pro it
1446 // holds Free's locked teaser instead.
1447 if ( betterdocs()->show_api_docs_teaser() || betterdocs()->has_api_docs() ) {
1448 $betterdocs_admin_pages['api_docs'] = $this->normalize_menu(
1449 __( 'API Docs', 'betterdocs' ),
1450 'betterdocs-api-docs',
1451 apply_filters( 'betterdocs_api_ref_capability', 'manage_options' ),
1452 array(
1453 $this,
1454 'output',
1455 ),
1456 $parent_slug
1457 );
1458 }
1459
1460 if ( ! betterdocs()->is_chatbot_active() ) {
1461 $betterdocs_admin_pages['ai_chatbot'] = $this->normalize_menu(
1462 __( 'AI Chatbot', 'betterdocs' ),
1463 'betterdocs-ai-chatbot',
1464 'edit_docs_settings',
1465 array(
1466 $this,
1467 'output',
1468 ),
1469 $parent_slug
1470 );
1471 }
1472
1473 return apply_filters( 'betterdocs_admin_menu', $betterdocs_admin_pages, array( $this, 'output' ), $parent_slug );
1474 }
1475
1476 /**
1477 * Put the BetterDocs submenu in a deliberate order.
1478 *
1479 * Order used to be an accident of *when* each item was added: Free declares
1480 * most of them inline, and reserves in-place slots for `glossaries` /
1481 * `api_docs` so Pro can overwrite the key without moving it. Anything added
1482 * purely through this filter, though, could only land at the end — which is
1483 * why Multiple KB (Pro, priority 100) and AI Chatbot Logs sat after
1484 * everything else regardless of where they belong.
1485 *
1486 * Sorting here, at priority 999, fixes that for every source at once: Free's
1487 * own entries, Pro's, and any add-on's. Knowledge Base now follows Tags (it
1488 * is the third taxonomy-ish thing, so it belongs with Categories and Tags
1489 * rather than past Analytics), and API Docs follows Knowledge Base.
1490 *
1491 * Keys not listed keep their relative order and are appended, so an add-on
1492 * that registers something unknown to this list is never dropped.
1493 *
1494 * @param array $pages Menu pages keyed by slug id.
1495 * @return array
1496 */
1497 public function order_admin_menu( $pages ) {
1498 if ( ! is_array( $pages ) ) {
1499 return $pages;
1500 }
1501
1502 $order = array(
1503 'betterdocs',
1504 'dashboard',
1505 'all_docs',
1506 'add_new',
1507 'categories',
1508 'tags',
1509 'multiple_kb',
1510 'api_docs',
1511 'settings',
1512 'mcp',
1513 'analytics',
1514 // Content IQ reads as a second Analytics screen, so it has to stay
1515 // pinned directly behind it. Without this entry it would fall into
1516 // the unknown-key bucket below and be appended to the bottom of the
1517 // menu — the exact placement the menu literal avoids by declaring
1518 // the slot inline rather than filtering it in api_docs-style.
1519 'content_intelligence',
1520 'faq',
1521 'glossaries',
1522 'ai_chatbot',
1523 'ai_chatbot_logs',
1524 );
1525
1526 $ordered = array();
1527 foreach ( $order as $key ) {
1528 if ( array_key_exists( $key, $pages ) ) {
1529 $ordered[ $key ] = $pages[ $key ];
1530 unset( $pages[ $key ] );
1531 }
1532 }
1533
1534 // `$pages` now holds only unknown keys, still in their original order.
1535 return array_merge( $ordered, $pages );
1536 }
1537
1538 public function add_custom_classes_to_menu_items() {
1539 global $menu, $submenu;
1540
1541 $menu_items = array(
1542 'betterdocs' => 'betterdocs',
1543 'betterdocs_page_all_docs' => 'betterdocs-all-docs',
1544 'betterdocs_page_add_new' => 'betterdocs-add-new',
1545 'betterdocs-doc-categories' => 'betterdocs-categories',
1546 'betterdocs-doc-tags' => 'betterdocs-tags',
1547 'betterdocs-settings' => 'betterdocs-settings',
1548 'betterdocs-mcp' => 'betterdocs-mcp',
1549 'betterdocs-analytics' => 'betterdocs-analytics',
1550 'betterdocs-content-iq' => 'betterdocs-content-iq',
1551 'betterdocs-faq' => 'betterdocs-faq',
1552 'betterdocs-glossaries' => 'betterdocs-glossaries',
1553 'betterdocs-ai-chatbot' => 'betterdocs-ai-chatbot',
1554 'betterdocs-api-docs' => 'betterdocs-api-docs',
1555 'edit-tags.php?taxonomy=knowledge_base&post_type=docs' => 'betterdocs-multiplekb',
1556 );
1557
1558 foreach ( $menu as &$item ) {
1559 if ( isset( $menu_items[ $item[2] ] ) ) {
1560 if ( ! isset( $item[4] ) ) {
1561 $item[4] = '';
1562 }
1563 $item[4] .= '' . $menu_items[ $item[2] ];
1564 }
1565 }
1566
1567 foreach ( $submenu as &$submenu_items ) {
1568 foreach ( $submenu_items as &$sub_item ) {
1569 if ( isset( $menu_items[ $sub_item[2] ] ) ) {
1570 if ( ! isset( $sub_item[4] ) ) {
1571 $sub_item[4] = '';
1572 }
1573 $sub_item[4] .= '' . $menu_items[ $sub_item[2] ];
1574 }
1575 }
1576 }
1577 }
1578
1579 public function quick_setup_menu( $menus ) {
1580 $betterdocs_settings = get_option( 'betterdocs_settings' );
1581 if ( $betterdocs_settings ) {
1582 return $menus;
1583 } else {
1584 $menus['quick_setup'] = $this->normalize_menu(
1585 __( 'Quick Setup', 'betterdocs' ),
1586 'betterdocs-setup',
1587 'delete_users',
1588 array(
1589 $this->container->get( SetupWizard::class ),
1590 'views',
1591 )
1592 );
1593 }
1594
1595 return $menus;
1596 }
1597
1598 public function insert_plugin_links( $links ) {
1599 $links[] = '<a href="admin.php?page=betterdocs-settings">' . __( 'Settings', 'betterdocs' ) . '</a>';
1600
1601 if ( ! is_plugin_active( 'betterdocs-pro/betterdocs-pro.php' ) ) {
1602 $links[] = '<a href="https://betterdocs.co/upgrade-to-pro-plugins-wp" target="_blank" style="color: #000; font-weight: bold;">' . __( 'Upgrade to Pro', 'betterdocs' ) . '</a>';
1603 }
1604
1605 return $links;
1606 }
1607
1608 public function toolbar_menu( $admin_bar ) {
1609 if ( ! is_admin() || ! is_admin_bar_showing() ) {
1610 return;
1611 }
1612
1613 // Show only when the user is a member of this site, or they're a super admin.
1614 if ( ! is_user_member_of_blog() && ! is_super_admin() ) {
1615 return;
1616 }
1617
1618 $docs_url = '';
1619 $encyclopedia_url = '';
1620
1621 if ( $this->settings->get( 'builtin_doc_page' ) ) {
1622 $docs_url = get_post_type_archive_link( 'docs' );
1623 } elseif ( intval( $docs_page = $this->settings->get( 'docs_page' ) ) ) {
1624 $docs_url = ! empty( $docs_page ) ? get_page_link( $docs_page ) : false;
1625 }
1626
1627 if ( ! $docs_url ) {
1628 return;
1629 }
1630
1631 $slug = $this->settings->get( 'encyclopedia_root_slug' );
1632
1633 global $wp_rewrite;
1634 if ( $wp_rewrite->using_index_permalinks() ) {
1635 $slug = $wp_rewrite->index . '/' . $slug;
1636 }
1637
1638 $encyclopedia_url = home_url( $slug );
1639
1640 $admin_bar->add_node(
1641 array(
1642 'parent' => 'site-name',
1643 'id' => 'view-docs',
1644 'title' => __( 'Visit Documentation', 'betterdocs' ),
1645 'href' => $docs_url,
1646 )
1647 );
1648
1649 $is_enable_encyclopedia = betterdocs()->settings->get( 'enable_encyclopedia' );
1650
1651 if ( $is_enable_encyclopedia && betterdocs()->is_pro_active() ) {
1652 $admin_bar->add_node(
1653 array(
1654 'parent' => 'site-name',
1655 'id' => 'view-encyclopedia',
1656 'title' => __( 'Visit Encyclopedia', 'betterdocs' ),
1657 'href' => $encyclopedia_url,
1658 )
1659 );
1660 }
1661 }
1662
1663 /**
1664 * Save last visited admin ui
1665 *
1666 * @since 3.0.1
1667 */
1668 public function save_admin_page() {
1669 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1670 $post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : '';
1671 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1672 $bdocs_view = isset( $_GET['bdocs_view'] ) ? sanitize_text_field( wp_unslash( $_GET['bdocs_view'] ) ) : '';
1673 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
1674 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
1675
1676 if ( 'docs' === $post_type && 'classic' === $bdocs_view ) {
1677 update_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', 'classic_ui' );
1678 } elseif ( 'betterdocs-admin' === $page ) {
1679 update_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', 'modern_ui' );
1680 }
1681 }
1682
1683 /**
1684 * Return last visited admin ui slug
1685 *
1686 * @return string
1687 * @since 3.0.1
1688 */
1689 public function ui_slug() {
1690 $last_visited = get_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', true );
1691 $docs_exist = get_posts(
1692 array(
1693 'post_type' => 'docs',
1694 'post_status' => 'any',
1695 'numberposts' => 1,
1696 )
1697 );
1698
1699 return ( 'modern_ui' === $last_visited || empty( $docs_exist ) )
1700 ? 'betterdocs-admin'
1701 : 'edit.php?post_type=docs&bdocs_view=classic';
1702 }
1703
1704 /**
1705 * Resets a duplicate submenu in WordPress if the parent main menu and the first submenu permalink are not the same.
1706 *
1707 * @return string
1708 * @since 3.0.1
1709 */
1710 public function reset_submenu() {
1711 global $submenu;
1712
1713 $docs = get_posts( array( 'post_type' => 'docs' ) );
1714 if ( count( $docs ) == 0 ) {
1715 return;
1716 }
1717
1718 $last_visited = get_user_meta( get_current_user_id(), 'last_visited_docs_admin_page', true );
1719
1720 if ( 'classic_ui' === $last_visited && isset( $submenu['betterdocs-admin'] ) && in_array( 'betterdocs-admin', $submenu['betterdocs-admin'][0] ) ) {
1721 unset( $submenu['betterdocs-admin'][0] );
1722 $submenu['betterdocs-admin'] = array_values( $submenu['betterdocs-admin'] );
1723 }
1724 }
1725
1726 /**
1727 * Initialize Black Friday Pointer
1728 *
1729 * @return void
1730 * @since 3.7.0
1731 */
1732 private function init_black_friday_pointer() {
1733 // Only initialize if conditions are met
1734 if ( NoticePointers::should_display_notice() ) {
1735 new NoticePointers();
1736 }
1737 }
1738
1739 /**
1740 * AJAX handler for dismissing Black Friday pointer
1741 *
1742 * @return void
1743 * @since 3.7.0
1744 */
1745 public function ajax_dismiss_black_friday_pointer() {
1746 // Verify nonce
1747 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
1748 if ( ! wp_verify_nonce( $nonce, 'betterdocs_dismiss_pointer' ) ) {
1749 wp_send_json_error( array( 'message' => __( 'Invalid nonce', 'betterdocs' ) ) );
1750 }
1751
1752 // Check if user has permission
1753 if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'edit_docs' ) ) {
1754 wp_send_json_error( array( 'message' => __( 'Permission denied', 'betterdocs' ) ) );
1755 }
1756
1757 // Get the introduction key
1758 $introduction_key = isset( $_POST['introduction_key'] ) ? sanitize_text_field( wp_unslash( $_POST['introduction_key'] ) ) : '';
1759
1760 if ( empty( $introduction_key ) ) {
1761 wp_send_json_error( array( 'message' => __( 'Invalid introduction key', 'betterdocs' ) ) );
1762 }
1763
1764 // Set the introduction as viewed
1765 NoticePointers::set_introduction_viewed( $introduction_key );
1766
1767 // Clear the priority option so other plugins can set their priority
1768 delete_option( '_wpdeveloper_plugin_pointer_priority' );
1769
1770 wp_send_json_success( array( 'message' => __( 'Pointer dismissed successfully', 'betterdocs' ) ) );
1771 }
1772
1773 /**
1774 * Get count of docs that are not yet synced
1775 * Similar to Helper::get_not_synced_post_ids() in betterdocs-ai-chatbot plugin
1776 *
1777 * @return int
1778 * @since 3.7.0
1779 */
1780 private function get_not_synced_docs_count() {
1781 $new_post_ids = get_option( 'saved_docs_post_ids', array() );
1782 $error_posts_data = get_option( 'betterdocs_ai_chatbot_error_posts', array() );
1783
1784 // Extract post IDs from error posts (handle both old and new formats)
1785 $error_post_ids = array();
1786 if ( is_array( $error_posts_data ) && ! empty( $error_posts_data ) ) {
1787 foreach ( $error_posts_data as $key => $value ) {
1788 if ( is_numeric( $key ) && is_numeric( $value ) ) {
1789 // Old format: numeric key with post ID as value
1790 $error_post_ids[] = $value;
1791 } elseif ( is_numeric( $key ) && is_array( $value ) && isset( $value['post_id'] ) ) {
1792 // New format: post ID as key with structured data
1793 $error_post_ids[] = $key;
1794 } elseif ( is_numeric( $key ) ) {
1795 // New format: post ID as key
1796 $error_post_ids[] = $key;
1797 }
1798 }
1799 }
1800
1801 // Merge both arrays and remove duplicates, then count
1802 return count( array_unique( array_merge( $new_post_ids, $error_post_ids ) ) );
1803 }
1804 }
1805