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

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

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