PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.49
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.49
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Docs_KB / Docs_KB.php

Docs_KB.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.49, at includes/extensions/Docs_KB/Docs_KB.php

1,276 lines 43.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Docs & Knowledge Base Extension.
4 *
5 * Full-featured documentation system for WordPress.
6 *
7 * @package King_Addons
8 */
9
10 namespace King_Addons;
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Main Docs & Knowledge Base class.
18 */
19 final class Docs_KB
20 {
21 /**
22 * Option name for settings.
23 */
24 private const OPTION_NAME = 'king_addons_docs_kb_options';
25
26 /**
27 * Post type name.
28 */
29 public const POST_TYPE = 'kng_doc';
30
31 /**
32 * Taxonomy name.
33 */
34 public const TAXONOMY = 'kng_doc_category';
35
36 /**
37 * REST API namespace.
38 */
39 public const API_NAMESPACE = 'king-addons/v1';
40
41 /**
42 * Singleton instance.
43 *
44 * @var Docs_KB|null
45 */
46 private static ?Docs_KB $instance = null;
47
48 /**
49 * Cached options.
50 *
51 * @var array<string, mixed>
52 */
53 private array $options = [];
54
55 /**
56 * Gets singleton instance.
57 *
58 * @return Docs_KB
59 */
60 public static function instance(): Docs_KB
61 {
62 if (is_null(self::$instance)) {
63 self::$instance = new self();
64 }
65 return self::$instance;
66 }
67
68 /**
69 * Constructor.
70 */
71 public function __construct()
72 {
73 $this->options = $this->get_options();
74
75 // Activation
76 register_activation_hook(KING_ADDONS_PATH . 'king-addons.php', [$this, 'handle_activation']);
77
78 // Init
79 add_action('init', [$this, 'register_post_type']);
80 add_action('init', [$this, 'register_taxonomy']);
81 add_action('init', [$this, 'add_rewrite_rules']);
82
83 // Admin
84 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
85 add_action('admin_post_king_addons_docs_kb_save', [$this, 'handle_save_settings']);
86 add_filter('manage_' . self::POST_TYPE . '_posts_columns', [$this, 'add_admin_columns']);
87 add_action('manage_' . self::POST_TYPE . '_posts_custom_column', [$this, 'render_admin_columns'], 10, 2);
88
89 // Frontend
90 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
91 add_filter('template_include', [$this, 'template_loader']);
92 add_filter('the_content', [$this, 'maybe_add_toc']);
93
94 // REST API
95 add_action('rest_api_init', [$this, 'register_rest_routes']);
96
97 // AJAX feedback (Pro)
98 add_action('wp_ajax_king_docs_feedback', [$this, 'ajax_save_feedback']);
99 add_action('wp_ajax_nopriv_king_docs_feedback', [$this, 'ajax_save_feedback']);
100
101 // Track views (Pro)
102 add_action('wp', [$this, 'track_view']);
103 }
104
105 /**
106 * Handles plugin activation.
107 *
108 * @return void
109 */
110 public function handle_activation(): void
111 {
112 if (!get_option(self::OPTION_NAME)) {
113 add_option(self::OPTION_NAME, $this->get_default_options());
114 }
115
116 $this->register_post_type();
117 $this->register_taxonomy();
118 $this->add_rewrite_rules();
119 flush_rewrite_rules();
120 }
121
122 /**
123 * Gets default options.
124 *
125 * @return array<string, mixed>
126 */
127 private function get_default_options(): array
128 {
129 return [
130 // General
131 'enabled' => false,
132 'docs_slug' => 'docs',
133 'main_page_id' => 0,
134 'docs_per_page' => 10,
135
136 // Layout
137 'layout' => 'card', // box, card, modern
138 'columns' => 3,
139 'show_article_count' => true,
140 'show_category_icon' => true,
141
142 // Single Article
143 'toc_enabled' => true,
144 'toc_sticky' => true,
145 'toc_headings' => 'h2,h3',
146 'sidebar_enabled' => true,
147 'navigation_enabled' => true,
148 'print_button' => true,
149
150 // Search
151 'search_enabled' => true,
152 'search_placeholder' => __('Search documentation...', 'king-addons'),
153 'search_min_chars' => 2,
154
155 // Pro: Multiple KBs
156 'multiple_kb_enabled' => false,
157
158 // Pro: Internal docs
159 'internal_docs_enabled' => false,
160 'internal_docs_roles' => ['administrator'],
161
162 // Pro: Feedback
163 'feedback_enabled' => false,
164 'feedback_question' => __('Was this article helpful?', 'king-addons'),
165
166 // Pro: Related
167 'related_enabled' => false,
168 'related_count' => 3,
169
170 // Pro: Analytics
171 'analytics_enabled' => false,
172 'analytics_email_report' => false,
173 'analytics_email' => get_option('admin_email'),
174
175 // Colors
176 'primary_color' => '#0066ff',
177 'category_icon_color' => '#0066ff',
178 'link_color' => '#0066ff',
179 ];
180 }
181
182 /**
183 * Gets options.
184 *
185 * @return array<string, mixed>
186 */
187 public function get_options(): array
188 {
189 $saved = get_option(self::OPTION_NAME, []);
190 return wp_parse_args($saved, $this->get_default_options());
191 }
192
193 /**
194 * Checks if premium.
195 *
196 * @return bool
197 */
198 public function is_premium(): bool
199 {
200 return function_exists('king_addons_freemius')
201 && king_addons_freemius()->can_use_premium_code__premium_only();
202 }
203
204 /**
205 * Registers the Doc post type.
206 *
207 * @return void
208 */
209 public function register_post_type(): void
210 {
211 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
212
213 $labels = [
214 'name' => __('Docs', 'king-addons'),
215 'singular_name' => __('Doc', 'king-addons'),
216 'add_new' => __('Add New Doc', 'king-addons'),
217 'add_new_item' => __('Add New Doc', 'king-addons'),
218 'edit_item' => __('Edit Doc', 'king-addons'),
219 'new_item' => __('New Doc', 'king-addons'),
220 'view_item' => __('View Doc', 'king-addons'),
221 'search_items' => __('Search Docs', 'king-addons'),
222 'not_found' => __('No docs found', 'king-addons'),
223 'not_found_in_trash' => __('No docs found in Trash', 'king-addons'),
224 'menu_name' => __('Docs', 'king-addons'),
225 ];
226
227 $args = [
228 'labels' => $labels,
229 'public' => true,
230 'publicly_queryable' => true,
231 'show_ui' => true,
232 'show_in_menu' => false,
233 'show_in_rest' => true,
234 'query_var' => true,
235 'rewrite' => [
236 'slug' => $slug,
237 'with_front' => false,
238 ],
239 'capability_type' => 'post',
240 'has_archive' => true,
241 'hierarchical' => false,
242 'menu_position' => 25,
243 'menu_icon' => 'dashicons-book-alt',
244 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions', 'custom-fields'],
245 ];
246
247 register_post_type(self::POST_TYPE, $args);
248 }
249
250 /**
251 * Registers the Doc Category taxonomy.
252 *
253 * @return void
254 */
255 public function register_taxonomy(): void
256 {
257 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
258
259 $labels = [
260 'name' => __('Doc Categories', 'king-addons'),
261 'singular_name' => __('Doc Category', 'king-addons'),
262 'search_items' => __('Search Categories', 'king-addons'),
263 'all_items' => __('All Categories', 'king-addons'),
264 'parent_item' => __('Parent Category', 'king-addons'),
265 'parent_item_colon' => __('Parent Category:', 'king-addons'),
266 'edit_item' => __('Edit Category', 'king-addons'),
267 'update_item' => __('Update Category', 'king-addons'),
268 'add_new_item' => __('Add New Category', 'king-addons'),
269 'new_item_name' => __('New Category Name', 'king-addons'),
270 'menu_name' => __('Categories', 'king-addons'),
271 ];
272
273 $args = [
274 'labels' => $labels,
275 'hierarchical' => true,
276 'public' => true,
277 'show_ui' => true,
278 'show_admin_column' => true,
279 'show_in_rest' => true,
280 'query_var' => true,
281 'rewrite' => [
282 'slug' => $slug . '/category',
283 'with_front' => false,
284 'hierarchical' => true,
285 ],
286 ];
287
288 register_taxonomy(self::TAXONOMY, self::POST_TYPE, $args);
289
290 // Add custom meta fields to taxonomy
291 add_action(self::TAXONOMY . '_add_form_fields', [$this, 'add_category_fields']);
292 add_action(self::TAXONOMY . '_edit_form_fields', [$this, 'edit_category_fields'], 10, 2);
293 add_action('created_' . self::TAXONOMY, [$this, 'save_category_fields']);
294 add_action('edited_' . self::TAXONOMY, [$this, 'save_category_fields']);
295 }
296
297 /**
298 * Adds category custom fields on add form.
299 *
300 * @return void
301 */
302 public function add_category_fields(): void
303 {
304 ?>
305 <div class="form-field">
306 <label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label>
307 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
308 <option value="book"><?php esc_html_e('Book', 'king-addons'); ?></option>
309 <option value="lightbulb"><?php esc_html_e('Lightbulb', 'king-addons'); ?></option>
310 <option value="gear"><?php esc_html_e('Gear', 'king-addons'); ?></option>
311 <option value="rocket"><?php esc_html_e('Rocket', 'king-addons'); ?></option>
312 <option value="star"><?php esc_html_e('Star', 'king-addons'); ?></option>
313 <option value="code"><?php esc_html_e('Code', 'king-addons'); ?></option>
314 <option value="help"><?php esc_html_e('Help', 'king-addons'); ?></option>
315 <option value="video"><?php esc_html_e('Video', 'king-addons'); ?></option>
316 </select>
317 <p class="description"><?php esc_html_e('Select an icon for this category.', 'king-addons'); ?></p>
318 </div>
319 <div class="form-field">
320 <label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label>
321 <input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="0" min="0">
322 <p class="description"><?php esc_html_e('Custom order for sorting categories.', 'king-addons'); ?></p>
323 </div>
324 <?php
325 }
326
327 /**
328 * Adds category custom fields on edit form.
329 *
330 * @param \WP_Term $term Term object.
331 * @return void
332 */
333 public function edit_category_fields(\WP_Term $term): void
334 {
335 $icon = get_term_meta($term->term_id, 'kng_doc_cat_icon', true) ?: 'book';
336 $order = get_term_meta($term->term_id, 'kng_doc_cat_order', true) ?: 0;
337 ?>
338 <tr class="form-field">
339 <th scope="row"><label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label></th>
340 <td>
341 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
342 <option value="book" <?php selected($icon, 'book'); ?>><?php esc_html_e('Book', 'king-addons'); ?></option>
343 <option value="lightbulb" <?php selected($icon, 'lightbulb'); ?>><?php esc_html_e('Lightbulb', 'king-addons'); ?></option>
344 <option value="gear" <?php selected($icon, 'gear'); ?>><?php esc_html_e('Gear', 'king-addons'); ?></option>
345 <option value="rocket" <?php selected($icon, 'rocket'); ?>><?php esc_html_e('Rocket', 'king-addons'); ?></option>
346 <option value="star" <?php selected($icon, 'star'); ?>><?php esc_html_e('Star', 'king-addons'); ?></option>
347 <option value="code" <?php selected($icon, 'code'); ?>><?php esc_html_e('Code', 'king-addons'); ?></option>
348 <option value="help" <?php selected($icon, 'help'); ?>><?php esc_html_e('Help', 'king-addons'); ?></option>
349 <option value="video" <?php selected($icon, 'video'); ?>><?php esc_html_e('Video', 'king-addons'); ?></option>
350 </select>
351 </td>
352 </tr>
353 <tr class="form-field">
354 <th scope="row"><label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label></th>
355 <td>
356 <input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="<?php echo esc_attr($order); ?>" min="0">
357 </td>
358 </tr>
359 <?php
360 }
361
362 /**
363 * Saves category custom fields.
364 *
365 * @param int $term_id Term ID.
366 * @return void
367 */
368 public function save_category_fields(int $term_id): void
369 {
370 if (isset($_POST['kng_doc_cat_icon'])) {
371 update_term_meta($term_id, 'kng_doc_cat_icon', sanitize_text_field($_POST['kng_doc_cat_icon']));
372 }
373 if (isset($_POST['kng_doc_cat_order'])) {
374 update_term_meta($term_id, 'kng_doc_cat_order', intval($_POST['kng_doc_cat_order']));
375 }
376 }
377
378 /**
379 * Adds rewrite rules.
380 *
381 * @return void
382 */
383 public function add_rewrite_rules(): void
384 {
385 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
386
387 // Archive page
388 add_rewrite_rule(
389 '^' . $slug . '/?$',
390 'index.php?post_type=' . self::POST_TYPE,
391 'top'
392 );
393
394 // Category page
395 add_rewrite_rule(
396 '^' . $slug . '/category/([^/]+)/?$',
397 'index.php?' . self::TAXONOMY . '=$matches[1]',
398 'top'
399 );
400
401 // Single doc
402 add_rewrite_rule(
403 '^' . $slug . '/([^/]+)/?$',
404 'index.php?' . self::POST_TYPE . '=$matches[1]',
405 'top'
406 );
407
408 // Flush rules only once after settings change
409 if (get_option('king_addons_docs_kb_rewrite_flushed') !== $slug) {
410 flush_rewrite_rules();
411 update_option('king_addons_docs_kb_rewrite_flushed', $slug);
412 }
413 }
414
415 /**
416 * Adds admin columns.
417 *
418 * @param array $columns Existing columns.
419 * @return array
420 */
421 public function add_admin_columns(array $columns): array
422 {
423 $new_columns = [];
424 foreach ($columns as $key => $value) {
425 $new_columns[$key] = $value;
426 if ($key === 'title') {
427 $new_columns['doc_category'] = __('Category', 'king-addons');
428 }
429 }
430 $new_columns['doc_views'] = __('Views', 'king-addons');
431 $new_columns['doc_feedback'] = __('Feedback', 'king-addons');
432 return $new_columns;
433 }
434
435 /**
436 * Renders admin columns.
437 *
438 * @param string $column Column name.
439 * @param int $post_id Post ID.
440 * @return void
441 */
442 public function render_admin_columns(string $column, int $post_id): void
443 {
444 switch ($column) {
445 case 'doc_category':
446 $terms = get_the_terms($post_id, self::TAXONOMY);
447 if ($terms && !is_wp_error($terms)) {
448 $term_names = wp_list_pluck($terms, 'name');
449 echo esc_html(implode(', ', $term_names));
450 } else {
451 echo '';
452 }
453 break;
454
455 case 'doc_views':
456 $views = get_post_meta($post_id, '_kng_doc_views', true) ?: 0;
457 echo esc_html(number_format_i18n($views));
458 break;
459
460 case 'doc_feedback':
461 $helpful = get_post_meta($post_id, '_kng_doc_helpful', true) ?: 0;
462 $not_helpful = get_post_meta($post_id, '_kng_doc_not_helpful', true) ?: 0;
463 echo '<span style="color:#34c759">👍 ' . esc_html($helpful) . '</span> / ';
464 echo '<span style="color:#ff3b30">👎 ' . esc_html($not_helpful) . '</span>';
465 break;
466 }
467 }
468
469 /**
470 * Enqueues admin assets.
471 *
472 * @param string $hook Current admin page.
473 * @return void
474 */
475 public function enqueue_admin_assets(string $hook): void
476 {
477 if ($hook !== 'king-addons_page_king-addons-docs-kb') {
478 return;
479 }
480
481 wp_enqueue_style('wp-color-picker');
482 wp_enqueue_script('wp-color-picker');
483
484 wp_enqueue_style(
485 'king-addons-v3-styles',
486 KING_ADDONS_URL . 'includes/admin/layouts/shared/admin-v3-styles.css',
487 [],
488 KING_ADDONS_VERSION
489 );
490
491 wp_enqueue_style(
492 'king-addons-docs-kb-admin',
493 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/admin.css',
494 ['king-addons-v3-styles'],
495 KING_ADDONS_VERSION
496 );
497
498 wp_enqueue_script(
499 'king-addons-docs-kb-admin',
500 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/admin.js',
501 ['jquery', 'wp-color-picker'],
502 KING_ADDONS_VERSION,
503 true
504 );
505 }
506
507 /**
508 * Enqueues frontend assets.
509 *
510 * @return void
511 */
512 public function enqueue_frontend_assets(): void
513 {
514 if (!$this->options['enabled']) {
515 return;
516 }
517
518 // Only on docs pages
519 if (!is_post_type_archive(self::POST_TYPE)
520 && !is_singular(self::POST_TYPE)
521 && !is_tax(self::TAXONOMY)
522 && !$this->is_docs_main_page()
523 ) {
524 return;
525 }
526
527 wp_enqueue_style(
528 'king-addons-docs-kb',
529 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/frontend.css',
530 [],
531 KING_ADDONS_VERSION
532 );
533
534 wp_enqueue_script(
535 'king-addons-docs-kb',
536 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/frontend.js',
537 [],
538 KING_ADDONS_VERSION,
539 true
540 );
541
542 $options = $this->options;
543
544 wp_localize_script('king-addons-docs-kb', 'kingDocsKB', [
545 'restUrl' => rest_url(self::API_NAMESPACE . '/docs'),
546 'ajaxUrl' => admin_url('admin-ajax.php'),
547 'nonce' => wp_create_nonce('wp_rest'),
548 'feedbackNonce' => wp_create_nonce('king_docs_feedback'),
549 'searchEnabled' => !empty($options['search_enabled']),
550 'searchMinChars' => intval($options['search_min_chars'] ?? 2),
551 'tocEnabled' => !empty($options['toc_enabled']),
552 'tocSticky' => !empty($options['toc_sticky']),
553 'tocHeadings' => $options['toc_headings'] ?? 'h2,h3',
554 'feedbackEnabled' => !empty($options['feedback_enabled']) && $this->is_premium(),
555 'strings' => [
556 'searchPlaceholder' => $options['search_placeholder'] ?? __('Search documentation...', 'king-addons'),
557 'noResults' => __('No results found', 'king-addons'),
558 'searching' => __('Searching...', 'king-addons'),
559 'feedbackQuestion' => $options['feedback_question'] ?? __('Was this article helpful?', 'king-addons'),
560 'feedbackThanks' => __('Thanks for your feedback!', 'king-addons'),
561 'tocTitle' => __('On this page', 'king-addons'),
562 ],
563 ]);
564
565 // Add inline CSS for custom colors
566 $custom_css = $this->get_custom_css();
567 wp_add_inline_style('king-addons-docs-kb', $custom_css);
568 }
569
570 /**
571 * Gets custom CSS based on settings.
572 *
573 * @return string
574 */
575 private function get_custom_css(): string
576 {
577 $options = $this->options;
578 $primary = sanitize_hex_color($options['primary_color'] ?? '#0066ff');
579 $icon_color = sanitize_hex_color($options['category_icon_color'] ?? '#0066ff');
580 $link_color = sanitize_hex_color($options['link_color'] ?? '#0066ff');
581
582 return "
583 :root {
584 --kng-docs-primary: {$primary};
585 --kng-docs-icon-color: {$icon_color};
586 --kng-docs-link-color: {$link_color};
587 }
588 ";
589 }
590
591 /**
592 * Checks if current page is docs main page.
593 *
594 * @return bool
595 */
596 private function is_docs_main_page(): bool
597 {
598 $main_page_id = intval($this->options['main_page_id'] ?? 0);
599 return $main_page_id > 0 && is_page($main_page_id);
600 }
601
602 /**
603 * Loads custom templates.
604 *
605 * @param string $template Current template.
606 * @return string
607 */
608 public function template_loader(string $template): string
609 {
610 if (!$this->options['enabled']) {
611 return $template;
612 }
613
614 // Main docs page
615 if ($this->is_docs_main_page()) {
616 $custom_template = __DIR__ . '/templates/archive-docs.php';
617 if (file_exists($custom_template)) {
618 return $custom_template;
619 }
620 }
621
622 // Archive
623 if (is_post_type_archive(self::POST_TYPE)) {
624 $custom_template = __DIR__ . '/templates/archive-docs.php';
625 if (file_exists($custom_template)) {
626 return $custom_template;
627 }
628 }
629
630 // Taxonomy
631 if (is_tax(self::TAXONOMY)) {
632 $custom_template = __DIR__ . '/templates/taxonomy-docs.php';
633 if (file_exists($custom_template)) {
634 return $custom_template;
635 }
636 }
637
638 // Single doc
639 if (is_singular(self::POST_TYPE)) {
640 $custom_template = __DIR__ . '/templates/single-doc.php';
641 if (file_exists($custom_template)) {
642 return $custom_template;
643 }
644 }
645
646 return $template;
647 }
648
649 /**
650 * Maybe adds TOC to content.
651 *
652 * @param string $content Post content.
653 * @return string
654 */
655 public function maybe_add_toc(string $content): string
656 {
657 if (!is_singular(self::POST_TYPE) || !$this->options['toc_enabled']) {
658 return $content;
659 }
660
661 // TOC is added via JavaScript for better control
662 return $content;
663 }
664
665 /**
666 * Registers REST API routes.
667 *
668 * @return void
669 */
670 public function register_rest_routes(): void
671 {
672 // Search endpoint
673 register_rest_route(self::API_NAMESPACE, '/docs/search', [
674 'methods' => 'GET',
675 'callback' => [$this, 'rest_search'],
676 'permission_callback' => '__return_true',
677 'args' => [
678 'q' => [
679 'required' => true,
680 'type' => 'string',
681 'sanitize_callback' => 'sanitize_text_field',
682 ],
683 'kb' => [
684 'required' => false,
685 'type' => 'integer',
686 'default' => 0,
687 ],
688 ],
689 ]);
690
691 // Get categories endpoint
692 register_rest_route(self::API_NAMESPACE, '/docs/categories', [
693 'methods' => 'GET',
694 'callback' => [$this, 'rest_get_categories'],
695 'permission_callback' => '__return_true',
696 ]);
697
698 // Get articles by category endpoint
699 register_rest_route(self::API_NAMESPACE, '/docs/category/(?P<id>\d+)', [
700 'methods' => 'GET',
701 'callback' => [$this, 'rest_get_category_articles'],
702 'permission_callback' => '__return_true',
703 ]);
704 }
705
706 /**
707 * REST: Search docs.
708 *
709 * @param \WP_REST_Request $request Request object.
710 * @return \WP_REST_Response
711 */
712 public function rest_search(\WP_REST_Request $request): \WP_REST_Response
713 {
714 $query = $request->get_param('q');
715 $kb = $request->get_param('kb');
716
717 if (strlen($query) < ($this->options['search_min_chars'] ?? 2)) {
718 return new \WP_REST_Response(['results' => []], 200);
719 }
720
721 $args = [
722 'post_type' => self::POST_TYPE,
723 'post_status' => 'publish',
724 'posts_per_page' => 10,
725 's' => $query,
726 'orderby' => 'relevance',
727 ];
728
729 // Pro: Internal docs visibility check
730 if ($this->is_premium() && !empty($this->options['internal_docs_enabled'])) {
731 if (!is_user_logged_in()) {
732 $args['meta_query'] = [
733 [
734 'key' => '_kng_doc_visibility',
735 'value' => 'public',
736 'compare' => '=',
737 ],
738 ];
739 }
740 }
741
742 $search_query = new \WP_Query($args);
743 $results = [];
744
745 foreach ($search_query->posts as $post) {
746 $categories = get_the_terms($post->ID, self::TAXONOMY);
747 $category_name = $categories && !is_wp_error($categories)
748 ? $categories[0]->name
749 : '';
750
751 $excerpt = $post->post_excerpt ?: wp_trim_words($post->post_content, 20, '...');
752
753 // Highlight matches in title and excerpt
754 $highlighted_title = $this->highlight_matches($post->post_title, $query);
755 $highlighted_excerpt = $this->highlight_matches($excerpt, $query);
756
757 $results[] = [
758 'id' => $post->ID,
759 'title' => $post->post_title,
760 'highlighted_title' => $highlighted_title,
761 'url' => get_permalink($post->ID),
762 'excerpt' => $excerpt,
763 'highlighted_excerpt' => $highlighted_excerpt,
764 'category' => $category_name,
765 'date' => get_the_date('', $post),
766 ];
767 }
768
769 // Pro: Log search query for analytics
770 if ($this->is_premium() && !empty($this->options['analytics_enabled'])) {
771 $this->log_search_query($query, count($results));
772 }
773
774 return new \WP_REST_Response(['results' => $results], 200);
775 }
776
777 /**
778 * Highlights search matches in text.
779 *
780 * @param string $text Text to highlight.
781 * @param string $query Search query.
782 * @return string
783 */
784 private function highlight_matches(string $text, string $query): string
785 {
786 if (empty($query)) {
787 return $text;
788 }
789
790 $words = explode(' ', $query);
791 foreach ($words as $word) {
792 if (strlen($word) >= 2) {
793 $text = preg_replace(
794 '/(' . preg_quote($word, '/') . ')/iu',
795 '<mark>$1</mark>',
796 $text
797 );
798 }
799 }
800
801 return $text;
802 }
803
804 /**
805 * Logs search query for analytics.
806 *
807 * @param string $query Search query.
808 * @param int $results_count Number of results.
809 * @return void
810 */
811 private function log_search_query(string $query, int $results_count): void
812 {
813 $logs = get_option('king_addons_docs_search_logs', []);
814 $logs[] = [
815 'query' => $query,
816 'results' => $results_count,
817 'timestamp' => current_time('mysql'),
818 ];
819
820 // Keep only last 1000 entries
821 if (count($logs) > 1000) {
822 $logs = array_slice($logs, -1000);
823 }
824
825 update_option('king_addons_docs_search_logs', $logs);
826 }
827
828 /**
829 * REST: Get categories.
830 *
831 * @return \WP_REST_Response
832 */
833 public function rest_get_categories(): \WP_REST_Response
834 {
835 $categories = get_terms([
836 'taxonomy' => self::TAXONOMY,
837 'hide_empty' => true,
838 'orderby' => 'meta_value_num',
839 'meta_key' => 'kng_doc_cat_order',
840 'order' => 'ASC',
841 ]);
842
843 $results = [];
844 foreach ($categories as $cat) {
845 if (is_wp_error($cat)) {
846 continue;
847 }
848
849 $results[] = [
850 'id' => $cat->term_id,
851 'name' => $cat->name,
852 'slug' => $cat->slug,
853 'description' => $cat->description,
854 'count' => $cat->count,
855 'icon' => get_term_meta($cat->term_id, 'kng_doc_cat_icon', true) ?: 'book',
856 'url' => get_term_link($cat),
857 ];
858 }
859
860 return new \WP_REST_Response($results, 200);
861 }
862
863 /**
864 * REST: Get articles by category.
865 *
866 * @param \WP_REST_Request $request Request object.
867 * @return \WP_REST_Response
868 */
869 public function rest_get_category_articles(\WP_REST_Request $request): \WP_REST_Response
870 {
871 $category_id = $request->get_param('id');
872
873 $args = [
874 'post_type' => self::POST_TYPE,
875 'post_status' => 'publish',
876 'posts_per_page' => -1,
877 'tax_query' => [
878 [
879 'taxonomy' => self::TAXONOMY,
880 'field' => 'term_id',
881 'terms' => $category_id,
882 ],
883 ],
884 'orderby' => 'menu_order title',
885 'order' => 'ASC',
886 ];
887
888 $query = new \WP_Query($args);
889 $results = [];
890
891 foreach ($query->posts as $post) {
892 $results[] = [
893 'id' => $post->ID,
894 'title' => $post->post_title,
895 'url' => get_permalink($post->ID),
896 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 15, '...'),
897 ];
898 }
899
900 return new \WP_REST_Response($results, 200);
901 }
902
903 /**
904 * Tracks article view.
905 *
906 * @return void
907 */
908 public function track_view(): void
909 {
910 if (!is_singular(self::POST_TYPE) || !$this->options['analytics_enabled'] || !$this->is_premium()) {
911 return;
912 }
913
914 $post_id = get_the_ID();
915 $views = get_post_meta($post_id, '_kng_doc_views', true) ?: 0;
916 update_post_meta($post_id, '_kng_doc_views', $views + 1);
917
918 // Track unique views via cookie
919 $cookie_name = 'kng_doc_viewed_' . $post_id;
920 if (!isset($_COOKIE[$cookie_name])) {
921 $unique_views = get_post_meta($post_id, '_kng_doc_unique_views', true) ?: 0;
922 update_post_meta($post_id, '_kng_doc_unique_views', $unique_views + 1);
923 setcookie($cookie_name, '1', time() + DAY_IN_SECONDS, '/');
924 }
925 }
926
927 /**
928 * AJAX: Save article feedback.
929 *
930 * @return void
931 */
932 public function ajax_save_feedback(): void
933 {
934 check_ajax_referer('king_docs_feedback', 'nonce');
935
936 $post_id = intval($_POST['post_id'] ?? 0);
937 $helpful = sanitize_text_field($_POST['helpful'] ?? '');
938
939 if (!$post_id || !in_array($helpful, ['yes', 'no'])) {
940 wp_send_json_error('Invalid data');
941 }
942
943 // Check if already voted via cookie
944 $cookie_name = 'kng_doc_feedback_' . $post_id;
945 if (isset($_COOKIE[$cookie_name])) {
946 wp_send_json_error('Already voted');
947 }
948
949 $meta_key = $helpful === 'yes' ? '_kng_doc_helpful' : '_kng_doc_not_helpful';
950 $count = get_post_meta($post_id, $meta_key, true) ?: 0;
951 update_post_meta($post_id, $meta_key, $count + 1);
952
953 // Set cookie to prevent duplicate votes
954 setcookie($cookie_name, $helpful, time() + YEAR_IN_SECONDS, '/');
955
956 wp_send_json_success([
957 'helpful' => get_post_meta($post_id, '_kng_doc_helpful', true) ?: 0,
958 'not_helpful' => get_post_meta($post_id, '_kng_doc_not_helpful', true) ?: 0,
959 ]);
960 }
961
962 /**
963 * Gets docs by category for display.
964 *
965 * @param int $limit Limit per category.
966 * @return array
967 */
968 public function get_categories_with_docs(int $limit = 5): array
969 {
970 $categories = get_terms([
971 'taxonomy' => self::TAXONOMY,
972 'hide_empty' => true,
973 'parent' => 0,
974 'orderby' => 'meta_value_num',
975 'meta_key' => 'kng_doc_cat_order',
976 'order' => 'ASC',
977 ]);
978
979 $results = [];
980
981 foreach ($categories as $cat) {
982 if (is_wp_error($cat)) {
983 continue;
984 }
985
986 $docs = get_posts([
987 'post_type' => self::POST_TYPE,
988 'post_status' => 'publish',
989 'posts_per_page' => $limit,
990 'tax_query' => [
991 [
992 'taxonomy' => self::TAXONOMY,
993 'field' => 'term_id',
994 'terms' => $cat->term_id,
995 ],
996 ],
997 'orderby' => 'menu_order title',
998 'order' => 'ASC',
999 ]);
1000
1001 $doc_items = [];
1002 foreach ($docs as $doc) {
1003 $doc_items[] = [
1004 'id' => $doc->ID,
1005 'title' => $doc->post_title,
1006 'url' => get_permalink($doc->ID),
1007 ];
1008 }
1009
1010 // Get subcategories
1011 $subcats = get_terms([
1012 'taxonomy' => self::TAXONOMY,
1013 'hide_empty' => true,
1014 'parent' => $cat->term_id,
1015 'orderby' => 'meta_value_num',
1016 'meta_key' => 'kng_doc_cat_order',
1017 'order' => 'ASC',
1018 ]);
1019
1020 $subcat_items = [];
1021 foreach ($subcats as $subcat) {
1022 if (is_wp_error($subcat)) {
1023 continue;
1024 }
1025 $subcat_items[] = [
1026 'id' => $subcat->term_id,
1027 'name' => $subcat->name,
1028 'count' => $subcat->count,
1029 'url' => get_term_link($subcat),
1030 ];
1031 }
1032
1033 $results[] = [
1034 'id' => $cat->term_id,
1035 'name' => $cat->name,
1036 'slug' => $cat->slug,
1037 'description' => $cat->description,
1038 'count' => $cat->count,
1039 'icon' => get_term_meta($cat->term_id, 'kng_doc_cat_icon', true) ?: 'book',
1040 'url' => get_term_link($cat),
1041 'docs' => $doc_items,
1042 'subcategories' => $subcat_items,
1043 ];
1044 }
1045
1046 return $results;
1047 }
1048
1049 /**
1050 * Gets related docs for an article.
1051 *
1052 * @param int $post_id Post ID.
1053 * @param int $count Number of related docs.
1054 * @return array
1055 */
1056 public function get_related_docs(int $post_id, int $count = 3): array
1057 {
1058 $categories = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
1059
1060 if (empty($categories) || is_wp_error($categories)) {
1061 return [];
1062 }
1063
1064 $args = [
1065 'post_type' => self::POST_TYPE,
1066 'post_status' => 'publish',
1067 'posts_per_page' => $count,
1068 'post__not_in' => [$post_id],
1069 'tax_query' => [
1070 [
1071 'taxonomy' => self::TAXONOMY,
1072 'field' => 'term_id',
1073 'terms' => $categories,
1074 ],
1075 ],
1076 'orderby' => 'rand',
1077 ];
1078
1079 $query = new \WP_Query($args);
1080 $results = [];
1081
1082 foreach ($query->posts as $post) {
1083 $results[] = [
1084 'id' => $post->ID,
1085 'title' => $post->post_title,
1086 'url' => get_permalink($post->ID),
1087 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 15, '...'),
1088 ];
1089 }
1090
1091 return $results;
1092 }
1093
1094 /**
1095 * Gets prev/next navigation for an article.
1096 *
1097 * @param int $post_id Post ID.
1098 * @return array
1099 */
1100 public function get_article_navigation(int $post_id): array
1101 {
1102 $categories = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
1103
1104 if (empty($categories) || is_wp_error($categories)) {
1105 return ['prev' => null, 'next' => null];
1106 }
1107
1108 $all_docs = get_posts([
1109 'post_type' => self::POST_TYPE,
1110 'post_status' => 'publish',
1111 'posts_per_page' => -1,
1112 'tax_query' => [
1113 [
1114 'taxonomy' => self::TAXONOMY,
1115 'field' => 'term_id',
1116 'terms' => $categories[0],
1117 ],
1118 ],
1119 'orderby' => 'menu_order title',
1120 'order' => 'ASC',
1121 'fields' => 'ids',
1122 ]);
1123
1124 $current_index = array_search($post_id, $all_docs);
1125 $prev = null;
1126 $next = null;
1127
1128 if ($current_index !== false) {
1129 if ($current_index > 0) {
1130 $prev_id = $all_docs[$current_index - 1];
1131 $prev = [
1132 'id' => $prev_id,
1133 'title' => get_the_title($prev_id),
1134 'url' => get_permalink($prev_id),
1135 ];
1136 }
1137 if ($current_index < count($all_docs) - 1) {
1138 $next_id = $all_docs[$current_index + 1];
1139 $next = [
1140 'id' => $next_id,
1141 'title' => get_the_title($next_id),
1142 'url' => get_permalink($next_id),
1143 ];
1144 }
1145 }
1146
1147 return ['prev' => $prev, 'next' => $next];
1148 }
1149
1150 /**
1151 * Renders admin page.
1152 *
1153 * @return void
1154 */
1155 public function render_admin_page(): void
1156 {
1157 if (!current_user_can('manage_options')) {
1158 return;
1159 }
1160
1161 $this->options = $this->get_options();
1162 $options = $this->options;
1163 $is_premium = $this->is_premium();
1164
1165 include __DIR__ . '/templates/admin-page.php';
1166 }
1167
1168 /**
1169 * Handles save settings.
1170 *
1171 * @return void
1172 */
1173 public function handle_save_settings(): void
1174 {
1175 if (!current_user_can('manage_options')) {
1176 wp_die('Unauthorized');
1177 }
1178
1179 check_admin_referer('king_addons_docs_kb_save', 'king_docs_kb_nonce');
1180
1181 $old_slug = $this->options['docs_slug'] ?? 'docs';
1182 $options = [];
1183
1184 // General
1185 $options['enabled'] = !empty($_POST['enabled']);
1186 $options['docs_slug'] = sanitize_title($_POST['docs_slug'] ?? 'docs');
1187 $options['main_page_id'] = intval($_POST['main_page_id'] ?? 0);
1188 $options['docs_per_page'] = max(1, intval($_POST['docs_per_page'] ?? 10));
1189
1190 // Layout
1191 $options['layout'] = in_array($_POST['layout'] ?? 'card', ['box', 'card', 'modern'])
1192 ? sanitize_text_field($_POST['layout'])
1193 : 'card';
1194 $options['columns'] = max(1, min(4, intval($_POST['columns'] ?? 3)));
1195 $options['show_article_count'] = !empty($_POST['show_article_count']);
1196 $options['show_category_icon'] = !empty($_POST['show_category_icon']);
1197
1198 // Single Article
1199 $options['toc_enabled'] = !empty($_POST['toc_enabled']);
1200 $options['toc_sticky'] = !empty($_POST['toc_sticky']);
1201 $options['toc_headings'] = sanitize_text_field($_POST['toc_headings'] ?? 'h2,h3');
1202 $options['sidebar_enabled'] = !empty($_POST['sidebar_enabled']);
1203 $options['navigation_enabled'] = !empty($_POST['navigation_enabled']);
1204 $options['print_button'] = !empty($_POST['print_button']);
1205
1206 // Search
1207 $options['search_enabled'] = !empty($_POST['search_enabled']);
1208 $options['search_placeholder'] = sanitize_text_field($_POST['search_placeholder'] ?? '');
1209 $options['search_min_chars'] = max(1, intval($_POST['search_min_chars'] ?? 2));
1210
1211 // Pro: Multiple KBs
1212 $options['multiple_kb_enabled'] = !empty($_POST['multiple_kb_enabled']);
1213
1214 // Pro: Internal docs
1215 $options['internal_docs_enabled'] = !empty($_POST['internal_docs_enabled']);
1216 $options['internal_docs_roles'] = isset($_POST['internal_docs_roles'])
1217 ? array_map('sanitize_text_field', (array) $_POST['internal_docs_roles'])
1218 : ['administrator'];
1219
1220 // Pro: Feedback
1221 $options['feedback_enabled'] = !empty($_POST['feedback_enabled']);
1222 $options['feedback_question'] = sanitize_text_field($_POST['feedback_question'] ?? '');
1223
1224 // Pro: Related
1225 $options['related_enabled'] = !empty($_POST['related_enabled']);
1226 $options['related_count'] = max(1, intval($_POST['related_count'] ?? 3));
1227
1228 // Pro: Analytics
1229 $options['analytics_enabled'] = !empty($_POST['analytics_enabled']);
1230 $options['analytics_email_report'] = !empty($_POST['analytics_email_report']);
1231 $options['analytics_email'] = sanitize_email($_POST['analytics_email'] ?? '');
1232
1233 // Colors
1234 $options['primary_color'] = sanitize_hex_color($_POST['primary_color'] ?? '#0066ff');
1235 $options['category_icon_color'] = sanitize_hex_color($_POST['category_icon_color'] ?? '#0066ff');
1236 $options['link_color'] = sanitize_hex_color($_POST['link_color'] ?? '#0066ff');
1237
1238 update_option(self::OPTION_NAME, $options);
1239
1240 // Flush rewrite rules if slug changed
1241 if ($old_slug !== $options['docs_slug']) {
1242 delete_option('king_addons_docs_kb_rewrite_flushed');
1243 }
1244
1245 wp_redirect(admin_url('admin.php?page=king-addons-docs-kb&saved=1'));
1246 exit;
1247 }
1248
1249 /**
1250 * Gets icon SVG by name.
1251 *
1252 * @param string $name Icon name.
1253 * @return string
1254 */
1255 public static function get_icon_svg(string $name): string
1256 {
1257 $icons = [
1258 'book' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
1259 'lightbulb' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
1260 'gear' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
1261 'rocket' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
1262 'star' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
1263 'code' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
1264 'help' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
1265 'video' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
1266 'search' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
1267 'folder' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
1268 'arrow-right' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
1269 'arrow-left' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
1270 'print' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
1271 ];
1272
1273 return $icons[$name] ?? $icons['book'];
1274 }
1275 }
1276