PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.76
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.76
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.76, at includes/extensions/Docs_KB/Docs_KB.php

1,072 lines 48.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 — v2.
4 *
5 * Full-featured documentation system for WordPress with an Apple-inspired,
6 * premium Liquid-Glass design language.
7 *
8 * @package King_Addons
9 */
10
11 namespace King_Addons;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Main Docs & Knowledge Base class (singleton).
19 */
20 final class Docs_KB
21 {
22 /* ───────── Constants ───────── */
23
24 private const OPTION_NAME = 'king_addons_docs_kb_options';
25 public const POST_TYPE = 'kng_doc';
26 public const TAXONOMY = 'kng_doc_category';
27 public const TAG_TAXONOMY = 'kng_doc_tag';
28 public const API_NAMESPACE = 'king-addons/v1';
29
30 /* ───────── Singleton ───────── */
31
32 private static ?Docs_KB $instance = null;
33
34 /** @var array<string,mixed> */
35 private array $options = [];
36
37 public static function instance(): Docs_KB
38 {
39 if (is_null(self::$instance)) {
40 self::$instance = new self();
41 }
42 return self::$instance;
43 }
44
45 /* ───────── Bootstrap ───────── */
46
47 public function __construct()
48 {
49 $this->options = $this->get_options();
50
51 // Activation
52 register_activation_hook(KING_ADDONS_PATH . 'king-addons.php', [$this, 'handle_activation']);
53
54 // Init
55 add_action('init', [$this, 'register_post_type']);
56 add_action('init', [$this, 'register_taxonomy']);
57 add_action('init', [$this, 'register_tag_taxonomy']);
58 add_action('init', [$this, 'add_rewrite_rules']);
59
60 // Admin
61 add_filter('parent_file', [$this, 'fix_admin_parent_file']);
62 add_filter('submenu_file', [$this, 'fix_admin_submenu_file']);
63 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
64 add_action('admin_post_king_addons_docs_kb_save', [$this, 'handle_save_settings']);
65 add_filter('manage_' . self::POST_TYPE . '_posts_columns', [$this, 'add_admin_columns']);
66 add_action('manage_' . self::POST_TYPE . '_posts_custom_column', [$this, 'render_admin_columns'], 10, 2);
67
68 // Frontend
69 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
70 add_filter('template_include', [$this, 'template_loader']);
71
72 // REST
73 add_action('rest_api_init', [$this, 'register_rest_routes']);
74
75 // AJAX – reactions & feedback
76 add_action('wp_ajax_king_docs_reaction', [$this, 'ajax_save_reaction']);
77 add_action('wp_ajax_nopriv_king_docs_reaction', [$this, 'ajax_save_reaction']);
78
79 // Track views (Pro)
80 add_action('wp', [$this, 'track_view']);
81 }
82
83 /* ═══════════════════════════════════════════
84 ACTIVATION / OPTIONS
85 ═══════════════════════════════════════════ */
86
87 public function handle_activation(): void
88 {
89 if (!get_option(self::OPTION_NAME)) {
90 add_option(self::OPTION_NAME, $this->get_default_options());
91 }
92 $this->register_post_type();
93 $this->register_taxonomy();
94 $this->register_tag_taxonomy();
95 $this->add_rewrite_rules();
96 flush_rewrite_rules();
97 }
98
99 /** @return array<string,mixed> */
100 private function get_default_options(): array
101 {
102 return [
103 // General
104 'enabled' => true,
105 'docs_slug' => 'docs',
106 'main_page_id' => 0,
107 'docs_per_page' => 12,
108 'archive_title' => __('How can we help?', 'king-addons'),
109 'archive_subtitle' => __('Find guides, tutorials and answers to your questions.', 'king-addons'),
110
111 // Layout
112 'layout' => 'glass-card', // glass-card | glass-list | glass-grid
113 'columns' => 3,
114 'show_article_count' => true,
115 'show_category_icon' => true,
116 'dark_mode' => 'auto', // light | dark | auto
117
118 // Single article
119 'toc_enabled' => true,
120 'toc_sticky' => true,
121 'toc_headings' => 'h2,h3',
122 'sidebar_enabled' => true,
123 'navigation_enabled' => true,
124 'print_button' => true,
125 'reading_time' => true,
126 'social_share' => true,
127 'reactions_enabled' => true,
128
129 // Search
130 'search_enabled' => true,
131 'search_placeholder' => __('Search documentation…', 'king-addons'),
132 'search_min_chars' => 2,
133
134 // Pro: Multiple KBs
135 'multiple_kb_enabled' => false,
136
137 // Pro: Internal docs
138 'internal_docs_enabled' => false,
139 'internal_docs_roles' => ['administrator'],
140
141 // Pro: Feedback
142 'feedback_enabled' => false,
143 'feedback_question' => __('Was this article helpful?', 'king-addons'),
144 'feedback_thanks' => __('Thank you for your feedback!', 'king-addons'),
145 'empty_text' => __('No articles yet', 'king-addons'),
146
147 // Pro: Related
148 'related_enabled' => true,
149 'related_count' => 3,
150
151 // Pro: Analytics
152 'analytics_enabled' => false,
153 'analytics_email_report' => false,
154 'analytics_email' => get_option('admin_email'),
155
156 // Appearance
157 'primary_color' => '#0071e3',
158 'category_icon_color' => '#0071e3',
159 'link_color' => '#0071e3',
160 ];
161 }
162
163 /** @return array<string,mixed> */
164 public function get_options(): array
165 {
166 $saved = get_option(self::OPTION_NAME, []);
167 return wp_parse_args($saved, $this->get_default_options());
168 }
169
170 public function is_premium(): bool
171 {
172 return function_exists('king_addons_freemius')
173 && king_addons_freemius()->can_use_premium_code__premium_only();
174 }
175
176 /* ═══════════════════════════════════════════
177 POST TYPE / TAXONOMIES
178 ═══════════════════════════════════════════ */
179
180 public function register_post_type(): void
181 {
182 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
183
184 register_post_type(self::POST_TYPE, [
185 'labels' => [
186 'name' => __('Docs', 'king-addons'),
187 'singular_name' => __('Doc', 'king-addons'),
188 'add_new' => __('Add New Doc', 'king-addons'),
189 'add_new_item' => __('Add New Doc', 'king-addons'),
190 'edit_item' => __('Edit Doc', 'king-addons'),
191 'new_item' => __('New Doc', 'king-addons'),
192 'view_item' => __('View Doc', 'king-addons'),
193 'search_items' => __('Search Docs', 'king-addons'),
194 'not_found' => __('No docs found', 'king-addons'),
195 'not_found_in_trash' => __('No docs found in Trash', 'king-addons'),
196 'menu_name' => __('Docs', 'king-addons'),
197 ],
198 'public' => true,
199 'publicly_queryable' => true,
200 'show_ui' => true,
201 'show_in_menu' => false,
202 'show_in_rest' => true,
203 'query_var' => true,
204 'rewrite' => ['slug' => $slug, 'with_front' => false],
205 'capability_type' => 'post',
206 'has_archive' => $slug,
207 'hierarchical' => false,
208 'menu_position' => 25,
209 'menu_icon' => 'dashicons-book-alt',
210 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions', 'custom-fields', 'page-attributes'],
211 ]);
212 }
213
214 public function register_taxonomy(): void
215 {
216 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
217
218 register_taxonomy(self::TAXONOMY, self::POST_TYPE, [
219 'labels' => [
220 'name' => __('Doc Categories', 'king-addons'),
221 'singular_name' => __('Doc Category', 'king-addons'),
222 'search_items' => __('Search Categories', 'king-addons'),
223 'all_items' => __('All Categories', 'king-addons'),
224 'parent_item' => __('Parent Category', 'king-addons'),
225 'edit_item' => __('Edit Category', 'king-addons'),
226 'update_item' => __('Update Category', 'king-addons'),
227 'add_new_item' => __('Add New Category', 'king-addons'),
228 'menu_name' => __('Categories', 'king-addons'),
229 ],
230 'hierarchical' => true,
231 'public' => true,
232 'show_ui' => true,
233 'show_admin_column' => true,
234 'show_in_rest' => true,
235 'query_var' => true,
236 'rewrite' => ['slug' => $slug . '/category', 'with_front' => false, 'hierarchical' => true],
237 ]);
238
239 // Custom meta fields
240 add_action(self::TAXONOMY . '_add_form_fields', [$this, 'add_category_fields']);
241 add_action(self::TAXONOMY . '_edit_form_fields', [$this, 'edit_category_fields'], 10, 2);
242 add_action('created_' . self::TAXONOMY, [$this, 'save_category_fields']);
243 add_action('edited_' . self::TAXONOMY, [$this, 'save_category_fields']);
244 }
245
246 public function register_tag_taxonomy(): void
247 {
248 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
249
250 register_taxonomy(self::TAG_TAXONOMY, self::POST_TYPE, [
251 'labels' => [
252 'name' => __('Doc Tags', 'king-addons'),
253 'singular_name' => __('Doc Tag', 'king-addons'),
254 'search_items' => __('Search Tags', 'king-addons'),
255 'all_items' => __('All Tags', 'king-addons'),
256 'edit_item' => __('Edit Tag', 'king-addons'),
257 'update_item' => __('Update Tag', 'king-addons'),
258 'add_new_item' => __('Add New Tag', 'king-addons'),
259 'menu_name' => __('Tags', 'king-addons'),
260 ],
261 'hierarchical' => false,
262 'public' => true,
263 'show_ui' => true,
264 'show_admin_column' => true,
265 'show_in_rest' => true,
266 'rewrite' => ['slug' => $slug . '/tag', 'with_front' => false],
267 ]);
268 }
269
270 /* ── Category form fields ── */
271
272 public function add_category_fields(): void
273 {
274 $icons = self::get_available_icons();
275 ?>
276 <div class="form-field">
277 <label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label>
278 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
279 <?php foreach ($icons as $value => $label): ?>
280 <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
281 <?php endforeach; ?>
282 </select>
283 </div>
284 <div class="form-field">
285 <label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label>
286 <input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="0" min="0">
287 </div>
288 <?php
289 }
290
291 public function edit_category_fields(\WP_Term $term): void
292 {
293 $icon = get_term_meta($term->term_id, 'kng_doc_cat_icon', true) ?: 'book';
294 $order = get_term_meta($term->term_id, 'kng_doc_cat_order', true) ?: 0;
295 $icons = self::get_available_icons();
296 ?>
297 <tr class="form-field">
298 <th><label for="kng_doc_cat_icon"><?php esc_html_e('Category Icon', 'king-addons'); ?></label></th>
299 <td>
300 <select name="kng_doc_cat_icon" id="kng_doc_cat_icon">
301 <?php foreach ($icons as $value => $label): ?>
302 <option value="<?php echo esc_attr($value); ?>" <?php selected($icon, $value); ?>><?php echo esc_html($label); ?></option>
303 <?php endforeach; ?>
304 </select>
305 </td>
306 </tr>
307 <tr class="form-field">
308 <th><label for="kng_doc_cat_order"><?php esc_html_e('Order', 'king-addons'); ?></label></th>
309 <td><input type="number" name="kng_doc_cat_order" id="kng_doc_cat_order" value="<?php echo esc_attr($order); ?>" min="0"></td>
310 </tr>
311 <?php
312 }
313
314 public function save_category_fields(int $term_id): void
315 {
316 if (isset($_POST['kng_doc_cat_icon'])) {
317 update_term_meta($term_id, 'kng_doc_cat_icon', sanitize_text_field($_POST['kng_doc_cat_icon']));
318 }
319 if (isset($_POST['kng_doc_cat_order'])) {
320 update_term_meta($term_id, 'kng_doc_cat_order', intval($_POST['kng_doc_cat_order']));
321 }
322 }
323
324 /* ═══════════════════════════════════════════
325 REWRITE RULES
326 ═══════════════════════════════════════════ */
327
328 public function add_rewrite_rules(): void
329 {
330 $slug = sanitize_title($this->options['docs_slug'] ?? 'docs');
331
332 add_rewrite_rule('^' . $slug . '/?$', 'index.php?post_type=' . self::POST_TYPE, 'top');
333 add_rewrite_rule('^' . $slug . '/category/([^/]+)/?$', 'index.php?' . self::TAXONOMY . '=$matches[1]', 'top');
334 add_rewrite_rule('^' . $slug . '/([^/]+)/?$', 'index.php?' . self::POST_TYPE . '=$matches[1]', 'top');
335
336 if (get_option('king_addons_docs_kb_rewrite_flushed') !== $slug) {
337 flush_rewrite_rules();
338 update_option('king_addons_docs_kb_rewrite_flushed', $slug);
339 }
340 }
341
342 /* ═══════════════════════════════════════════
343 ADMIN COLUMNS
344 ═══════════════════════════════════════════ */
345
346 public function add_admin_columns(array $columns): array
347 {
348 $new = [];
349 foreach ($columns as $k => $v) {
350 $new[$k] = $v;
351 if ($k === 'title') {
352 $new['doc_category'] = __('Category', 'king-addons');
353 }
354 }
355 $new['doc_views'] = __('Views', 'king-addons');
356 $new['doc_reactions'] = __('Reactions', 'king-addons');
357 return $new;
358 }
359
360 public function render_admin_columns(string $column, int $post_id): void
361 {
362 switch ($column) {
363 case 'doc_category':
364 $terms = get_the_terms($post_id, self::TAXONOMY);
365 echo ($terms && !is_wp_error($terms))
366 ? esc_html(implode(', ', wp_list_pluck($terms, 'name')))
367 : '';
368 break;
369
370 case 'doc_views':
371 echo esc_html(number_format_i18n(get_post_meta($post_id, '_kng_doc_views', true) ?: 0));
372 break;
373
374 case 'doc_reactions':
375 $happy = get_post_meta($post_id, '_kng_doc_react_happy', true) ?: 0;
376 $neutral = get_post_meta($post_id, '_kng_doc_react_neutral', true) ?: 0;
377 $sad = get_post_meta($post_id, '_kng_doc_react_sad', true) ?: 0;
378 echo '<span style="color:#34c759">😊 ' . esc_html($happy) . '</span> ';
379 echo '<span style="color:#ff9f0a">😐 ' . esc_html($neutral) . '</span> ';
380 echo '<span style="color:#ff3b30">😞 ' . esc_html($sad) . '</span>';
381 break;
382 }
383 }
384
385 /* ═══════════════════════════════════════════
386 ASSETS
387 ═══════════════════════════════════════════ */
388
389 public function enqueue_admin_assets(string $hook): void
390 {
391 if ($hook !== 'king-addons_page_king-addons-docs-kb') {
392 return;
393 }
394
395 wp_enqueue_style('wp-color-picker');
396 wp_enqueue_script('wp-color-picker');
397
398 wp_enqueue_style(
399 'king-addons-v3-styles',
400 KING_ADDONS_URL . 'includes/admin/layouts/shared/admin-v3-styles.css',
401 [],
402 KING_ADDONS_VERSION
403 );
404 }
405
406 public function enqueue_frontend_assets(): void
407 {
408 if (!$this->options['enabled']) {
409 return;
410 }
411
412 if (
413 !is_post_type_archive(self::POST_TYPE)
414 && !is_singular(self::POST_TYPE)
415 && !is_tax(self::TAXONOMY)
416 && !is_tax(self::TAG_TAXONOMY)
417 && !$this->is_docs_main_page()
418 ) {
419 return;
420 }
421
422 wp_enqueue_style(
423 'king-addons-docs-kb',
424 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/frontend.css',
425 [],
426 KING_ADDONS_VERSION
427 );
428
429 wp_enqueue_script(
430 'king-addons-docs-kb',
431 KING_ADDONS_URL . 'includes/extensions/Docs_KB/assets/frontend.js',
432 [],
433 KING_ADDONS_VERSION,
434 true
435 );
436
437 wp_localize_script('king-addons-docs-kb', 'kingDocsKB', [
438 'restUrl' => rest_url(self::API_NAMESPACE . '/docs/'),
439 'ajaxUrl' => admin_url('admin-ajax.php'),
440 'nonce' => wp_create_nonce('wp_rest'),
441 'reactionNonce' => wp_create_nonce('king_docs_reaction'),
442 'searchEnabled' => !empty($this->options['search_enabled']),
443 'searchMinChars' => intval($this->options['search_min_chars'] ?? 2),
444 'tocEnabled' => !empty($this->options['toc_enabled']),
445 'tocSticky' => !empty($this->options['toc_sticky']),
446 'tocHeadings' => $this->options['toc_headings'] ?? 'h2,h3',
447 'reactionsEnabled' => !empty($this->options['reactions_enabled']),
448 'darkMode' => $this->options['dark_mode'] ?? 'auto',
449 'i18n' => [
450 'searchPlaceholder' => $this->options['search_placeholder'] ?? __('Search documentation…', 'king-addons'),
451 'noResults' => __('No results found', 'king-addons'),
452 'searching' => __('Searching…', 'king-addons'),
453 'tocTitle' => __('On this page', 'king-addons'),
454 'reactionThanks' => __('Thanks for your feedback!', 'king-addons'),
455 'copied' => __('Link copied!', 'king-addons'),
456 ],
457 ]);
458
459 // Inline custom-property overrides
460 wp_add_inline_style('king-addons-docs-kb', $this->get_custom_css());
461 }
462
463 private function get_custom_css(): string
464 {
465 $o = $this->options;
466 $primary = sanitize_hex_color($o['primary_color'] ?? '#0071e3');
467 $icon = sanitize_hex_color($o['category_icon_color'] ?? '#0071e3');
468 $link = sanitize_hex_color($o['link_color'] ?? '#0071e3');
469
470 return ":root{--kng-docs-primary:{$primary};--kng-docs-icon-color:{$icon};--kng-docs-link-color:{$link};}";
471 }
472
473 private function is_docs_main_page(): bool
474 {
475 $id = intval($this->options['main_page_id'] ?? 0);
476 return $id > 0 && is_page($id);
477 }
478
479 /* ═══════════════════════════════════════════
480 TEMPLATE LOADER
481 ═══════════════════════════════════════════ */
482
483 public function template_loader(string $template): string
484 {
485 if (!$this->options['enabled']) {
486 return $template;
487 }
488
489 $base = __DIR__ . '/templates/';
490
491 if ($this->is_docs_main_page() || is_post_type_archive(self::POST_TYPE)) {
492 $t = $base . 'archive-docs.php';
493 return file_exists($t) ? $t : $template;
494 }
495
496 if (is_tax(self::TAXONOMY) || is_tax(self::TAG_TAXONOMY)) {
497 $t = $base . 'taxonomy-docs.php';
498 return file_exists($t) ? $t : $template;
499 }
500
501 if (is_singular(self::POST_TYPE)) {
502 $t = $base . 'single-doc.php';
503 return file_exists($t) ? $t : $template;
504 }
505
506 return $template;
507 }
508
509 /* ═══════════════════════════════════════════
510 REST API
511 ═══════════════════════════════════════════ */
512
513 public function register_rest_routes(): void
514 {
515 register_rest_route(self::API_NAMESPACE, '/docs/search', [
516 'methods' => 'GET',
517 'callback' => [$this, 'rest_search'],
518 'permission_callback' => '__return_true',
519 'args' => [
520 'q' => ['required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
521 'cat' => ['required' => false, 'type' => 'integer', 'default' => 0],
522 ],
523 ]);
524
525 register_rest_route(self::API_NAMESPACE, '/docs/categories', [
526 'methods' => 'GET',
527 'callback' => [$this, 'rest_get_categories'],
528 'permission_callback' => '__return_true',
529 ]);
530
531 register_rest_route(self::API_NAMESPACE, '/docs/category/(?P<id>\d+)', [
532 'methods' => 'GET',
533 'callback' => [$this, 'rest_get_category_articles'],
534 'permission_callback' => '__return_true',
535 ]);
536 }
537
538 public function rest_search(\WP_REST_Request $request): \WP_REST_Response
539 {
540 $q = $request->get_param('q');
541 $cat = $request->get_param('cat');
542
543 if (strlen($q) < intval($this->options['search_min_chars'] ?? 2)) {
544 return new \WP_REST_Response(['results' => []], 200);
545 }
546
547 $args = [
548 'post_type' => self::POST_TYPE,
549 'post_status' => 'publish',
550 'posts_per_page' => 10,
551 's' => $q,
552 'orderby' => 'relevance',
553 ];
554
555 if ($cat) {
556 $args['tax_query'] = [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cat]];
557 }
558
559 if ($this->should_limit_to_public_docs()) {
560 $args['meta_query'] = $this->get_public_docs_meta_query();
561 }
562
563 $wp = new \WP_Query($args);
564 $results = [];
565
566 foreach ($wp->posts as $post) {
567 $cats = get_the_terms($post->ID, self::TAXONOMY);
568 $cat_name = ($cats && !is_wp_error($cats)) ? $cats[0]->name : '';
569 $excerpt = $post->post_excerpt ?: wp_trim_words($post->post_content, 20, '');
570
571 $results[] = [
572 'id' => $post->ID,
573 'title' => $post->post_title,
574 'h_title' => $this->highlight($post->post_title, $q),
575 'url' => get_permalink($post->ID),
576 'excerpt' => $excerpt,
577 'h_excerpt' => $this->highlight($excerpt, $q),
578 'category' => $cat_name,
579 'date' => get_the_date('', $post),
580 'reading_time' => $this->estimate_reading_time($post->post_content),
581 ];
582 }
583
584 // Pro: analytics log
585 if ($this->is_premium() && !empty($this->options['analytics_enabled'])) {
586 $this->log_search($q, count($results));
587 }
588
589 return new \WP_REST_Response(['results' => $results], 200);
590 }
591
592 public function rest_get_categories(): \WP_REST_Response
593 {
594 $args = [
595 'taxonomy' => self::TAXONOMY,
596 'hide_empty' => true,
597 'orderby' => 'meta_value_num',
598 'meta_key' => 'kng_doc_cat_order',
599 'order' => 'ASC',
600 ];
601
602 if ($this->should_limit_to_public_docs()) {
603 $public_doc_ids = get_posts([
604 'post_type' => self::POST_TYPE,
605 'post_status' => 'publish',
606 'fields' => 'ids',
607 'posts_per_page' => -1,
608 'meta_query' => $this->get_public_docs_meta_query(),
609 ]);
610
611 if (empty($public_doc_ids)) {
612 return new \WP_REST_Response([], 200);
613 }
614
615 $args['object_ids'] = $public_doc_ids;
616 }
617
618 $cats = get_terms($args);
619
620 if (is_wp_error($cats)) {
621 return new \WP_REST_Response([], 200);
622 }
623
624 $out = [];
625 foreach ($cats as $c) {
626 if (is_wp_error($c)) continue;
627 $out[] = [
628 'id' => $c->term_id,
629 'name' => $c->name,
630 'slug' => $c->slug,
631 'desc' => $c->description,
632 'count' => $c->count,
633 'icon' => get_term_meta($c->term_id, 'kng_doc_cat_icon', true) ?: 'book',
634 'url' => get_term_link($c),
635 ];
636 }
637
638 return new \WP_REST_Response($out, 200);
639 }
640
641 public function rest_get_category_articles(\WP_REST_Request $request): \WP_REST_Response
642 {
643 $cat_id = absint($request->get_param('id'));
644
645 $args = [
646 'post_type' => self::POST_TYPE,
647 'post_status' => 'publish',
648 'posts_per_page' => -1,
649 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cat_id]],
650 'orderby' => 'menu_order title',
651 'order' => 'ASC',
652 ];
653
654 if ($this->should_limit_to_public_docs()) {
655 $args['meta_query'] = $this->get_public_docs_meta_query();
656 }
657
658 $wp = new \WP_Query($args);
659
660 $out = [];
661 foreach ($wp->posts as $p) {
662 $out[] = [
663 'id' => $p->ID,
664 'title' => $p->post_title,
665 'url' => get_permalink($p->ID),
666 'excerpt' => $p->post_excerpt ?: wp_trim_words($p->post_content, 15, ''),
667 ];
668 }
669
670 return new \WP_REST_Response($out, 200);
671 }
672
673 private function should_limit_to_public_docs(): bool
674 {
675 if (!$this->is_premium() || empty($this->options['internal_docs_enabled'])) {
676 return false;
677 }
678
679 if (!is_user_logged_in()) {
680 return true;
681 }
682
683 $user = wp_get_current_user();
684 $allowed_roles = array_map('sanitize_key', (array)($this->options['internal_docs_roles'] ?? ['administrator']));
685
686 return empty(array_intersect($allowed_roles, (array)$user->roles));
687 }
688
689 private function get_public_docs_meta_query(): array
690 {
691 return [['key' => '_kng_doc_visibility', 'value' => 'public']];
692 }
693
694 /* ═══════════════════════════════════════════
695 REACTIONS (AJAX)
696 ═══════════════════════════════════════════ */
697
698 public function ajax_save_reaction(): void
699 {
700 check_ajax_referer('king_docs_reaction', 'nonce');
701
702 $post_id = intval($_POST['post_id'] ?? 0);
703 $reaction = sanitize_text_field($_POST['reaction'] ?? '');
704
705 if (!$post_id || !in_array($reaction, ['happy', 'neutral', 'sad'], true)) {
706 wp_send_json_error('Invalid');
707 }
708
709 $cookie = 'kng_docs_reaction_' . $post_id;
710 if (isset($_COOKIE[$cookie])) {
711 wp_send_json_error('Already voted');
712 }
713
714 $key = '_kng_doc_react_' . $reaction;
715 $count = get_post_meta($post_id, $key, true) ?: 0;
716 update_post_meta($post_id, $key, $count + 1);
717
718 setcookie($cookie, $reaction, time() + YEAR_IN_SECONDS, '/');
719
720 wp_send_json_success([
721 'happy' => get_post_meta($post_id, '_kng_doc_react_happy', true) ?: 0,
722 'neutral' => get_post_meta($post_id, '_kng_doc_react_neutral', true) ?: 0,
723 'sad' => get_post_meta($post_id, '_kng_doc_react_sad', true) ?: 0,
724 ]);
725 }
726
727 /* ═══════════════════════════════════════════
728 VIEW TRACKING (Pro)
729 ═══════════════════════════════════════════ */
730
731 public function track_view(): void
732 {
733 if (!is_singular(self::POST_TYPE)) return;
734 if (empty($this->options['analytics_enabled']) || !$this->is_premium()) return;
735
736 $id = get_the_ID();
737 update_post_meta($id, '_kng_doc_views', (get_post_meta($id, '_kng_doc_views', true) ?: 0) + 1);
738
739 $ck = 'kng_doc_viewed_' . $id;
740 if (!isset($_COOKIE[$ck])) {
741 update_post_meta($id, '_kng_doc_unique_views', (get_post_meta($id, '_kng_doc_unique_views', true) ?: 0) + 1);
742 setcookie($ck, '1', time() + DAY_IN_SECONDS, '/');
743 }
744 }
745
746 /* ═══════════════════════════════════════════
747 HELPERS
748 ═══════════════════════════════════════════ */
749
750 private function highlight(string $text, string $q): string
751 {
752 if (!$q) return $text;
753 foreach (explode(' ', $q) as $w) {
754 if (strlen($w) >= 2) {
755 $text = preg_replace('/(' . preg_quote($w, '/') . ')/iu', '<mark>$1</mark>', $text);
756 }
757 }
758 return $text;
759 }
760
761 private function log_search(string $q, int $cnt): void
762 {
763 $logs = get_option('king_addons_docs_search_logs', []);
764 $logs[] = ['query' => $q, 'results' => $cnt, 'ts' => current_time('mysql')];
765 if (count($logs) > 1000) $logs = array_slice($logs, -1000);
766 update_option('king_addons_docs_search_logs', $logs);
767 }
768
769 public static function estimate_reading_time(string $content): string
770 {
771 $min = max(1, (int)ceil(str_word_count(strip_tags($content)) / 200));
772 return sprintf(_n('%d min read', '%d min read', $min, 'king-addons'), $min);
773 }
774
775 /**
776 * Get all docs grouped by category — used by archive template.
777 *
778 * @return array
779 */
780 public function get_categories_with_docs(int $limit = 5): array
781 {
782 $cats = get_terms([
783 'taxonomy' => self::TAXONOMY,
784 'hide_empty' => true,
785 'parent' => 0,
786 'orderby' => 'meta_value_num',
787 'meta_key' => 'kng_doc_cat_order',
788 'order' => 'ASC',
789 ]);
790
791 $out = [];
792 foreach ($cats as $c) {
793 if (is_wp_error($c)) continue;
794
795 $docs = get_posts([
796 'post_type' => self::POST_TYPE,
797 'post_status' => 'publish',
798 'posts_per_page' => $limit,
799 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $c->term_id]],
800 'orderby' => 'menu_order title',
801 'order' => 'ASC',
802 ]);
803
804 $doc_items = [];
805 foreach ($docs as $d) {
806 $doc_items[] = ['id' => $d->ID, 'title' => $d->post_title, 'url' => get_permalink($d->ID)];
807 }
808
809 $subcats = get_terms([
810 'taxonomy' => self::TAXONOMY,
811 'hide_empty' => true,
812 'parent' => $c->term_id,
813 'orderby' => 'meta_value_num',
814 'meta_key' => 'kng_doc_cat_order',
815 'order' => 'ASC',
816 ]);
817
818 $sub_items = [];
819 foreach ($subcats as $sc) {
820 if (is_wp_error($sc)) continue;
821 $sub_items[] = [
822 'id' => $sc->term_id, 'name' => $sc->name, 'count' => $sc->count, 'url' => get_term_link($sc),
823 ];
824 }
825
826 $out[] = [
827 'id' => $c->term_id,
828 'name' => $c->name,
829 'slug' => $c->slug,
830 'description' => $c->description,
831 'count' => $c->count,
832 'icon' => get_term_meta($c->term_id, 'kng_doc_cat_icon', true) ?: 'book',
833 'url' => get_term_link($c),
834 'docs' => $doc_items,
835 'subcategories' => $sub_items,
836 ];
837 }
838
839 return $out;
840 }
841
842 public function get_related_docs(int $post_id, int $count = 3): array
843 {
844 $cats = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
845 if (empty($cats) || is_wp_error($cats)) return [];
846
847 $q = new \WP_Query([
848 'post_type' => self::POST_TYPE,
849 'post_status' => 'publish',
850 'posts_per_page' => $count,
851 'post__not_in' => [$post_id],
852 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cats]],
853 'orderby' => 'rand',
854 ]);
855
856 $out = [];
857 foreach ($q->posts as $p) {
858 $out[] = [
859 'id' => $p->ID,
860 'title' => $p->post_title,
861 'url' => get_permalink($p->ID),
862 'excerpt' => $p->post_excerpt ?: wp_trim_words($p->post_content, 15, ''),
863 ];
864 }
865 return $out;
866 }
867
868 public function get_article_navigation(int $post_id): array
869 {
870 $cats = wp_get_post_terms($post_id, self::TAXONOMY, ['fields' => 'ids']);
871 if (empty($cats) || is_wp_error($cats)) return ['prev' => null, 'next' => null];
872
873 $all = get_posts([
874 'post_type' => self::POST_TYPE,
875 'post_status' => 'publish',
876 'posts_per_page' => -1,
877 'tax_query' => [['taxonomy' => self::TAXONOMY, 'field' => 'term_id', 'terms' => $cats[0]]],
878 'orderby' => 'menu_order title',
879 'order' => 'ASC',
880 'fields' => 'ids',
881 ]);
882
883 $idx = array_search($post_id, $all);
884 $prev = $next = null;
885
886 if ($idx !== false) {
887 if ($idx > 0) {
888 $pid = $all[$idx - 1];
889 $prev = ['id' => $pid, 'title' => get_the_title($pid), 'url' => get_permalink($pid)];
890 }
891 if ($idx < count($all) - 1) {
892 $nid = $all[$idx + 1];
893 $next = ['id' => $nid, 'title' => get_the_title($nid), 'url' => get_permalink($nid)];
894 }
895 }
896
897 return ['prev' => $prev, 'next' => $next];
898 }
899
900 /* ═══════════════════════════════════════════
901 ADMIN PAGE
902 ═══════════════════════════════════════════ */
903
904 /**
905 * Highlight King Addons parent menu when editing docs.
906 */
907 public function fix_admin_parent_file(string $parent_file): string
908 {
909 $screen = get_current_screen();
910 if ($screen && ($screen->post_type === self::POST_TYPE || in_array($screen->taxonomy, [self::TAXONOMY, self::TAG_TAXONOMY], true))) {
911 return 'king-addons';
912 }
913 return $parent_file;
914 }
915
916 /**
917 * Highlight correct submenu item when editing docs.
918 */
919 public function fix_admin_submenu_file(?string $submenu_file): ?string
920 {
921 $screen = get_current_screen();
922 if (!$screen) return $submenu_file;
923
924 // Keep the single “Builder” page highlighted for any Docs KB admin screens.
925 if (
926 ($screen->post_type === self::POST_TYPE)
927 || (!empty($screen->taxonomy) && in_array($screen->taxonomy, [self::TAXONOMY, self::TAG_TAXONOMY], true))
928 ) {
929 return 'king-addons-docs-kb';
930 }
931 return $submenu_file;
932 }
933
934 public function render_admin_page(): void
935 {
936 if (!current_user_can('manage_options')) return;
937
938 $this->options = $this->get_options();
939 $options = $this->options;
940 $is_premium = $this->is_premium();
941
942 include __DIR__ . '/templates/admin-page.php';
943 }
944
945 public function handle_save_settings(): void
946 {
947 if (!current_user_can('manage_options')) wp_die('Unauthorized');
948 check_admin_referer('king_addons_docs_kb_save', 'king_docs_kb_nonce');
949
950 $old_slug = $this->options['docs_slug'] ?? 'docs';
951 $o = [];
952
953 // General
954 $o['enabled'] = !empty($_POST['enabled']);
955 $o['docs_slug'] = sanitize_title($_POST['docs_slug'] ?? 'docs');
956 $o['main_page_id'] = intval($_POST['main_page_id'] ?? 0);
957 $o['docs_per_page'] = max(1, intval($_POST['docs_per_page'] ?? 12));
958 $o['archive_title'] = sanitize_text_field($_POST['archive_title'] ?? '');
959 $o['archive_subtitle'] = sanitize_text_field($_POST['archive_subtitle'] ?? '');
960
961 // Layout
962 $valid_layouts = ['glass-card', 'glass-list', 'glass-grid'];
963 $o['layout'] = in_array($_POST['layout'] ?? 'glass-card', $valid_layouts) ? sanitize_text_field($_POST['layout']) : 'glass-card';
964 $o['columns'] = max(1, min(4, intval($_POST['columns'] ?? 3)));
965 $o['show_article_count'] = !empty($_POST['show_article_count']);
966 $o['show_category_icon'] = !empty($_POST['show_category_icon']);
967 $o['dark_mode'] = in_array($_POST['dark_mode'] ?? 'auto', ['light', 'dark', 'auto']) ? sanitize_text_field($_POST['dark_mode']) : 'auto';
968
969 // Single
970 $o['toc_enabled'] = !empty($_POST['toc_enabled']);
971 $o['toc_sticky'] = !empty($_POST['toc_sticky']);
972 $o['toc_headings'] = sanitize_text_field($_POST['toc_headings'] ?? 'h2,h3');
973 $o['sidebar_enabled'] = !empty($_POST['sidebar_enabled']);
974 $o['navigation_enabled'] = !empty($_POST['navigation_enabled']);
975 $o['print_button'] = !empty($_POST['print_button']);
976 $o['reading_time'] = !empty($_POST['reading_time']);
977 $o['social_share'] = !empty($_POST['social_share']);
978 $o['reactions_enabled'] = !empty($_POST['reactions_enabled']);
979
980 // Search
981 $o['search_enabled'] = !empty($_POST['search_enabled']);
982 $o['search_placeholder'] = sanitize_text_field($_POST['search_placeholder'] ?? '');
983 $o['search_min_chars'] = max(1, intval($_POST['search_min_chars'] ?? 2));
984
985 // Pro
986 $o['multiple_kb_enabled'] = !empty($_POST['multiple_kb_enabled']);
987 $o['internal_docs_enabled'] = !empty($_POST['internal_docs_enabled']);
988 $o['internal_docs_roles'] = isset($_POST['internal_docs_roles']) ? array_map('sanitize_text_field', (array)$_POST['internal_docs_roles']) : ['administrator'];
989 $o['feedback_enabled'] = !empty($_POST['feedback_enabled']);
990 $o['feedback_question'] = sanitize_text_field($_POST['feedback_question'] ?? '');
991 $o['feedback_thanks'] = sanitize_text_field($_POST['feedback_thanks'] ?? '');
992 $o['empty_text'] = sanitize_text_field($_POST['empty_text'] ?? '');
993 $o['related_enabled'] = !empty($_POST['related_enabled']);
994 $o['related_count'] = max(1, intval($_POST['related_count'] ?? 3));
995 $o['analytics_enabled'] = !empty($_POST['analytics_enabled']);
996 $o['analytics_email_report'] = !empty($_POST['analytics_email_report']);
997 $o['analytics_email'] = sanitize_email($_POST['analytics_email'] ?? '');
998
999 // Colors
1000 $o['primary_color'] = sanitize_hex_color($_POST['primary_color'] ?? '#0071e3');
1001 $o['category_icon_color'] = sanitize_hex_color($_POST['category_icon_color'] ?? '#0071e3');
1002 $o['link_color'] = sanitize_hex_color($_POST['link_color'] ?? '#0071e3');
1003
1004 update_option(self::OPTION_NAME, $o);
1005
1006 if ($old_slug !== $o['docs_slug']) {
1007 delete_option('king_addons_docs_kb_rewrite_flushed');
1008 }
1009
1010 wp_redirect(admin_url('admin.php?page=king-addons-docs-kb&saved=1'));
1011 exit;
1012 }
1013
1014 /* ═══════════════════════════════════════════
1015 ICONS
1016 ═══════════════════════════════════════════ */
1017
1018 /** @return array<string,string> */
1019 public static function get_available_icons(): array
1020 {
1021 return [
1022 'book' => 'Book',
1023 'lightbulb' => 'Lightbulb',
1024 'gear' => 'Gear',
1025 'rocket' => 'Rocket',
1026 'star' => 'Star',
1027 'code' => 'Code',
1028 'help' => 'Help',
1029 'video' => 'Video',
1030 'layers' => 'Layers',
1031 'shield' => 'Shield',
1032 'palette' => 'Palette',
1033 'globe' => 'Globe',
1034 ];
1035 }
1036
1037 public static function get_icon_svg(string $name): string
1038 {
1039 $icons = [
1040 'book' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1041 'book-open' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
1042 'home' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
1043 'calendar' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
1044 'clock' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
1045 'eye' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
1046 'list' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
1047 'file-text' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>',
1048 'printer' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1049 'lightbulb' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1050 'gear' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>',
1051 'rocket' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1052 'star' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1053 'code' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
1054 'help' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1055 'video' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>',
1056 'layers' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
1057 'shield' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
1058 'palette' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12.5" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>',
1059 'globe' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
1060 'search' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
1061 'folder' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1062 'arrow-right' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
1063 'arrow-left' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
1064 'print' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
1065 'share' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
1066 'link' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
1067 ];
1068
1069 return $icons[$name] ?? $icons['book'];
1070 }
1071 }
1072