PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.74
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.74
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 / Custom_Code_Manager / Custom_Code_Manager.php

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

1,612 lines 46.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Custom Code Manager Extension
4 *
5 * Manage custom CSS, JS, and HTML snippets with advanced targeting rules.
6 *
7 * @package King_Addons
8 */
9
10 namespace King_Addons;
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Custom Code Manager class.
18 */
19 final class Custom_Code_Manager
20 {
21 /**
22 * Instance.
23 *
24 * @var Custom_Code_Manager|null
25 */
26 private static ?Custom_Code_Manager $instance = null;
27
28 /**
29 * Post type name.
30 */
31 public const POST_TYPE = 'kng_custom_code';
32
33 /**
34 * Meta prefix.
35 */
36 public const META_PREFIX = '_kng_cc_';
37
38 /**
39 * Free snippets limit.
40 */
41 public const FREE_LIMIT = 10;
42
43 /**
44 * Cached snippets for current request.
45 *
46 * @var array|null
47 */
48 private ?array $cached_snippets = null;
49
50 /**
51 * Rule engine cache.
52 *
53 * @var array
54 */
55 private array $rule_cache = [];
56
57 /**
58 * Debug log.
59 *
60 * @var array
61 */
62 private array $debug_log = [];
63
64 /**
65 * Gets instance.
66 *
67 * @return Custom_Code_Manager
68 */
69 public static function getInstance(): Custom_Code_Manager
70 {
71 if (self::$instance === null) {
72 self::$instance = new self();
73 }
74 return self::$instance;
75 }
76
77 /**
78 * Check if Pro version is active.
79 *
80 * @return bool
81 */
82 public static function hasPro(): bool
83 {
84 return function_exists('king_addons_freemius') && king_addons_freemius()->can_use_premium_code();
85 }
86
87 /**
88 * Constructor.
89 */
90 private function __construct()
91 {
92 $this->init_hooks();
93 }
94
95 /**
96 * Initializes hooks.
97 *
98 * @return void
99 */
100 private function init_hooks(): void
101 {
102 // Post type registration
103 add_action('init', [$this, 'register_post_type']);
104
105 // Admin hooks
106 add_action('admin_menu', [$this, 'add_admin_menu'], 25);
107 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
108
109 // AJAX handlers
110 add_action('wp_ajax_kng_cc_save_snippet', [$this, 'ajax_save_snippet']);
111 add_action('wp_ajax_kng_cc_delete_snippet', [$this, 'ajax_delete_snippet']);
112 add_action('wp_ajax_kng_cc_toggle_snippet', [$this, 'ajax_toggle_snippet']);
113 add_action('wp_ajax_kng_cc_duplicate_snippet', [$this, 'ajax_duplicate_snippet']);
114 add_action('wp_ajax_kng_cc_export_snippet', [$this, 'ajax_export_snippet']);
115 add_action('wp_ajax_kng_cc_export_all', [$this, 'ajax_export_all']);
116 add_action('wp_ajax_kng_cc_import', [$this, 'ajax_import']);
117 add_action('wp_ajax_kng_cc_search_content', [$this, 'ajax_search_content']);
118 add_action('wp_ajax_kng_cc_get_snippet', [$this, 'ajax_get_snippet']);
119 add_action('wp_ajax_kng_cc_bulk_action', [$this, 'ajax_bulk_action']);
120 add_action('wp_ajax_kng_cc_save_settings', [$this, 'ajax_save_settings']);
121
122 // Frontend injection
123 add_action('wp_head', [$this, 'inject_head'], 1);
124 add_action('wp_footer', [$this, 'inject_footer'], 99);
125 add_action('wp_body_open', [$this, 'inject_body_open'], 1);
126
127 // Register custom hooks for snippets with custom_hook location
128 add_action('init', [$this, 'register_custom_hooks'], 20);
129
130 // Script attributes filter
131 add_filter('script_loader_tag', [$this, 'modify_script_tag'], 10, 3);
132
133 // Debug output for admins
134 if ($this->is_debug_mode() && current_user_can('manage_options')) {
135 add_action('wp_footer', [$this, 'output_debug_log'], 999);
136 }
137 }
138
139 /**
140 * Registers custom post type.
141 *
142 * @return void
143 */
144 public function register_post_type(): void
145 {
146 register_post_type(self::POST_TYPE, [
147 'labels' => [
148 'name' => __('Code Snippets', 'king-addons'),
149 'singular_name' => __('Code Snippet', 'king-addons'),
150 ],
151 'public' => false,
152 'show_ui' => false,
153 'show_in_menu' => false,
154 'supports' => ['title'],
155 'capability_type' => 'post',
156 'map_meta_cap' => true,
157 ]);
158 }
159
160 /**
161 * Adds admin menu.
162 *
163 * @return void
164 */
165 public function add_admin_menu(): void
166 {
167 add_menu_page(
168 __('Custom Code', 'king-addons'),
169 __('Custom Code', 'king-addons'),
170 'manage_options',
171 'king-addons-custom-code',
172 [$this, 'render_admin_page'],
173 'dashicons-editor-code',
174 54.7
175 );
176 }
177
178 /**
179 * Renders admin page.
180 *
181 * @return void
182 */
183 public function render_admin_page(): void
184 {
185 $view = isset($_GET['view']) ? sanitize_key($_GET['view']) : 'list';
186 $snippet_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
187
188 // Theme mode (shared across all views)
189 $theme_mode = get_user_meta(get_current_user_id(), 'king_addons_theme_mode', true);
190 if (!in_array($theme_mode, ['light', 'dark', 'auto'], true)) {
191 $theme_mode = 'dark';
192 }
193 // Store for sub-renderers
194 $this->theme_mode = $theme_mode;
195
196 // Early theme application script — runs before DOM renders to prevent flash
197 ?>
198 <script>
199 (function() {
200 var mode = '<?php echo esc_js($theme_mode); ?>';
201 var mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
202 function apply(isDark) {
203 document.documentElement.classList.toggle('ka-v3-dark', isDark);
204 if (document.body) document.body.classList.toggle('ka-v3-dark', isDark);
205 }
206 function onBody(fn) {
207 if (document.body) { fn(); return; }
208 document.addEventListener('DOMContentLoaded', fn, { once: true });
209 }
210 var isDark = mode === 'auto' ? !!(mql && mql.matches) : mode === 'dark';
211 apply(isDark);
212 onBody(function() { apply(isDark); });
213 })();
214 </script>
215 <?php
216
217 switch ($view) {
218 case 'edit':
219 case 'new':
220 $this->render_editor_page($snippet_id);
221 break;
222 case 'settings':
223 $this->render_settings_page();
224 break;
225 case 'import-export':
226 $this->render_import_export_page();
227 break;
228 default:
229 $this->render_list_page();
230 break;
231 }
232 }
233
234 /**
235 * Renders list page.
236 *
237 * @return void
238 */
239 private function render_list_page(): void
240 {
241 $snippets = $this->get_all_snippets();
242 $snippet_count = count($snippets);
243 $has_pro = self::hasPro();
244 $at_limit = !$has_pro && $snippet_count >= self::FREE_LIMIT;
245 $theme_mode = $this->theme_mode;
246
247 include __DIR__ . '/templates/admin-list.php';
248 }
249
250 /**
251 * Renders editor page.
252 *
253 * @param int $snippet_id Snippet ID.
254 * @return void
255 */
256 private function render_editor_page(int $snippet_id = 0): void
257 {
258 $snippet = null;
259 $is_new = $snippet_id === 0;
260
261 if (!$is_new) {
262 $snippet = $this->get_snippet($snippet_id);
263 if (!$snippet) {
264 wp_die(__('Snippet not found.', 'king-addons'));
265 }
266 }
267
268 $has_pro = self::hasPro();
269 $defaults = $this->get_default_snippet();
270 $config = $snippet ? array_merge($defaults, $snippet) : $defaults;
271 $theme_mode = $this->theme_mode;
272
273 include __DIR__ . '/templates/admin-editor.php';
274 }
275
276 /**
277 * Renders settings page.
278 *
279 * @return void
280 */
281 private function render_settings_page(): void
282 {
283 $settings = $this->get_settings();
284 $has_pro = self::hasPro();
285 $theme_mode = $this->theme_mode;
286
287 include __DIR__ . '/templates/admin-settings.php';
288 }
289
290 /**
291 * Renders import/export page.
292 *
293 * @return void
294 */
295 private function render_import_export_page(): void
296 {
297 $has_pro = self::hasPro();
298 $theme_mode = $this->theme_mode;
299
300 include __DIR__ . '/templates/admin-import-export.php';
301 }
302
303 /**
304 * Enqueues admin assets.
305 *
306 * @param string $hook Page hook.
307 * @return void
308 */
309 public function enqueue_admin_assets(string $hook): void
310 {
311 if (strpos($hook, 'king-addons-custom-code') === false) {
312 return;
313 }
314
315 // CodeMirror
316 wp_enqueue_code_editor(['type' => 'text/css']);
317 wp_enqueue_script('wp-theme-plugin-editor');
318 wp_enqueue_style('wp-codemirror');
319
320 // Admin styles
321 wp_enqueue_style(
322 'king-addons-cc-admin',
323 KING_ADDONS_URL . 'includes/extensions/Custom_Code_Manager/assets/admin.css',
324 [],
325 KING_ADDONS_VERSION
326 );
327
328 // Admin script
329 wp_enqueue_script(
330 'king-addons-cc-admin',
331 KING_ADDONS_URL . 'includes/extensions/Custom_Code_Manager/assets/admin.js',
332 ['jquery', 'wp-theme-plugin-editor', 'code-editor'],
333 KING_ADDONS_VERSION,
334 true
335 );
336
337 wp_localize_script('king-addons-cc-admin', 'kngCCAdmin', [
338 'ajaxUrl' => admin_url('admin-ajax.php'),
339 'nonce' => wp_create_nonce('kng_cc_admin'),
340 'themeNonce' => wp_create_nonce('king_addons_dashboard_ui'),
341 'hasPro' => self::hasPro(),
342 'freeLimit' => self::FREE_LIMIT,
343 'upgradeUrl' => 'https://kingaddons.com/pricing/?utm_source=kng-custom-code&utm_medium=wp-admin&utm_campaign=kng',
344 'strings' => [
345 'confirmDelete' => __('Are you sure you want to delete this snippet?', 'king-addons'),
346 'confirmBulkDelete' => __('Are you sure you want to delete the selected snippets?', 'king-addons'),
347 'saving' => __('Saving...', 'king-addons'),
348 'saved' => __('Saved!', 'king-addons'),
349 'error' => __('Error occurred', 'king-addons'),
350 'selectSnippets' => __('Please select at least one snippet', 'king-addons'),
351 'duplicated' => __('Snippet duplicated successfully', 'king-addons'),
352 'proRequired' => __('This feature requires Pro version', 'king-addons'),
353 'limitReached' => __('Free version limit reached. Upgrade to Pro for unlimited snippets.', 'king-addons'),
354 'copied' => __('Copied!', 'king-addons'),
355 'exportReady' => __('Export ready', 'king-addons'),
356 'importSuccess' => __('Import successful', 'king-addons'),
357 'invalidFile' => __('Invalid file format', 'king-addons'),
358 'noResults' => __('No results found', 'king-addons'),
359 ],
360 'codeMirrorSettings' => [
361 'css' => wp_enqueue_code_editor(['type' => 'text/css']),
362 'javascript' => wp_enqueue_code_editor(['type' => 'application/javascript']),
363 'html' => wp_enqueue_code_editor(['type' => 'text/html']),
364 ],
365 ]);
366 }
367
368 /**
369 * Gets default snippet config.
370 *
371 * @return array
372 */
373 private function get_default_snippet(): array
374 {
375 return [
376 'id' => 0,
377 'title' => '',
378 'code' => '',
379 'type' => 'css',
380 'status' => 'enabled',
381 'location' => 'head',
382 'custom_hook' => '',
383 'priority' => 10,
384 'js_load_mode' => 'inline',
385 'js_defer' => false,
386 'js_async' => false,
387 'js_module' => false,
388 'js_dom_ready' => false,
389 'scope_mode' => 'global',
390 'rules' => [],
391 'match_mode' => 'any',
392 'group_id' => 0,
393 'notes' => '',
394 'created' => '',
395 'modified' => '',
396 'author' => 0,
397 ];
398 }
399
400 /**
401 * Gets all snippets.
402 *
403 * @param array $args Query args.
404 * @return array
405 */
406 public function get_all_snippets(array $args = []): array
407 {
408 $defaults = [
409 'post_type' => self::POST_TYPE,
410 'posts_per_page' => -1,
411 'post_status' => 'any',
412 'orderby' => 'menu_order',
413 'order' => 'ASC',
414 ];
415
416 $query_args = array_merge($defaults, $args);
417 $posts = get_posts($query_args);
418 $snippets = [];
419
420 foreach ($posts as $post) {
421 $snippets[] = $this->post_to_snippet($post);
422 }
423
424 return $snippets;
425 }
426
427 /**
428 * Gets single snippet.
429 *
430 * @param int $id Snippet ID.
431 * @return array|null
432 */
433 public function get_snippet(int $id): ?array
434 {
435 $post = get_post($id);
436
437 if (!$post || $post->post_type !== self::POST_TYPE) {
438 return null;
439 }
440
441 return $this->post_to_snippet($post);
442 }
443
444 /**
445 * Converts post to snippet array.
446 *
447 * @param \WP_Post $post Post object.
448 * @return array
449 */
450 private function post_to_snippet(\WP_Post $post): array
451 {
452 $meta = get_post_meta($post->ID);
453
454 return [
455 'id' => $post->ID,
456 'title' => $post->post_title,
457 'code' => $post->post_content,
458 'type' => $this->get_meta($meta, 'type', 'css'),
459 'status' => $post->post_status === 'publish' ? 'enabled' : 'disabled',
460 'location' => $this->get_meta($meta, 'location', 'head'),
461 'custom_hook' => $this->get_meta($meta, 'custom_hook', ''),
462 'priority' => (int)$this->get_meta($meta, 'priority', 10),
463 'js_load_mode' => $this->get_meta($meta, 'js_load_mode', 'inline'),
464 'js_defer' => (bool)$this->get_meta($meta, 'js_defer', false),
465 'js_async' => (bool)$this->get_meta($meta, 'js_async', false),
466 'js_module' => (bool)$this->get_meta($meta, 'js_module', false),
467 'js_dom_ready' => (bool)$this->get_meta($meta, 'js_dom_ready', false),
468 'scope_mode' => $this->get_meta($meta, 'scope_mode', 'global'),
469 'rules' => json_decode($this->get_meta($meta, 'rules', '[]'), true) ?: [],
470 'match_mode' => $this->get_meta($meta, 'match_mode', 'any'),
471 'group_id' => (int)$this->get_meta($meta, 'group_id', 0),
472 'notes' => $this->get_meta($meta, 'notes', ''),
473 'created' => $post->post_date,
474 'modified' => $post->post_modified,
475 'author' => $post->post_author,
476 ];
477 }
478
479 /**
480 * Gets meta value with prefix.
481 *
482 * @param array $meta Meta array.
483 * @param string $key Meta key.
484 * @param mixed $default Default value.
485 * @return mixed
486 */
487 private function get_meta(array $meta, string $key, $default)
488 {
489 $full_key = self::META_PREFIX . $key;
490 return isset($meta[$full_key][0]) ? $meta[$full_key][0] : $default;
491 }
492
493 /**
494 * Saves snippet.
495 *
496 * @param array $data Snippet data.
497 * @return int|false Post ID or false on failure.
498 */
499 public function save_snippet(array $data)
500 {
501 $has_pro = self::hasPro();
502 $is_new = empty($data['id']);
503
504 // Check free limit
505 if (!$has_pro && $is_new) {
506 $count = count($this->get_all_snippets());
507 if ($count >= self::FREE_LIMIT) {
508 return false;
509 }
510 }
511
512 // Validate type (HTML is Pro only)
513 if (!$has_pro && $data['type'] === 'html') {
514 $data['type'] = 'css';
515 }
516
517 // Validate location (body_open, custom_hook are Pro only)
518 if (!$has_pro && in_array($data['location'], ['body_open', 'custom_hook'], true)) {
519 $data['location'] = 'head';
520 }
521
522 // Prepare post data
523 $post_data = [
524 'post_type' => self::POST_TYPE,
525 'post_title' => sanitize_text_field($data['title'] ?? ''),
526 'post_content' => $data['code'] ?? '',
527 'post_status' => ($data['status'] ?? 'enabled') === 'enabled' ? 'publish' : 'draft',
528 'menu_order' => (int)($data['priority'] ?? 10),
529 ];
530
531 if (!$is_new) {
532 $post_data['ID'] = (int)$data['id'];
533 $post_id = wp_update_post($post_data);
534 } else {
535 $post_id = wp_insert_post($post_data);
536 }
537
538 if (!$post_id || is_wp_error($post_id)) {
539 return false;
540 }
541
542 // Save meta
543 $meta_fields = [
544 'type' => sanitize_key($data['type'] ?? 'css'),
545 'location' => sanitize_key($data['location'] ?? 'head'),
546 'custom_hook' => sanitize_text_field($data['custom_hook'] ?? ''),
547 'priority' => (int)($data['priority'] ?? 10),
548 'js_load_mode' => sanitize_key($data['js_load_mode'] ?? 'inline'),
549 'js_defer' => !empty($data['js_defer']),
550 'js_async' => !empty($data['js_async']),
551 'js_module' => !empty($data['js_module']),
552 'js_dom_ready' => !empty($data['js_dom_ready']),
553 'scope_mode' => sanitize_key($data['scope_mode'] ?? 'global'),
554 'rules' => wp_json_encode($data['rules'] ?? []),
555 'match_mode' => sanitize_key($data['match_mode'] ?? 'any'),
556 'group_id' => (int)($data['group_id'] ?? 0),
557 'notes' => sanitize_textarea_field($data['notes'] ?? ''),
558 ];
559
560 // Reset Pro-only meta in free version
561 if (!$has_pro) {
562 $meta_fields['js_defer'] = false;
563 $meta_fields['js_async'] = false;
564 $meta_fields['js_module'] = false;
565 $meta_fields['match_mode'] = 'any';
566 }
567
568 foreach ($meta_fields as $key => $value) {
569 update_post_meta($post_id, self::META_PREFIX . $key, $value);
570 }
571
572 return $post_id;
573 }
574
575 /**
576 * Deletes snippet.
577 *
578 * @param int $id Snippet ID.
579 * @return bool
580 */
581 public function delete_snippet(int $id): bool
582 {
583 $post = get_post($id);
584
585 if (!$post || $post->post_type !== self::POST_TYPE) {
586 return false;
587 }
588
589 return wp_delete_post($id, true) !== false;
590 }
591
592 /**
593 * Duplicates snippet.
594 *
595 * @param int $id Snippet ID.
596 * @return int|false New snippet ID or false.
597 */
598 public function duplicate_snippet(int $id)
599 {
600 $snippet = $this->get_snippet($id);
601
602 if (!$snippet) {
603 return false;
604 }
605
606 $snippet['id'] = 0;
607 $snippet['title'] .= ' ' . __('(Copy)', 'king-addons');
608 $snippet['status'] = 'disabled';
609
610 return $this->save_snippet($snippet);
611 }
612
613 /**
614 * Toggles snippet status.
615 *
616 * @param int $id Snippet ID.
617 * @return string|false New status or false.
618 */
619 public function toggle_snippet(int $id)
620 {
621 $post = get_post($id);
622
623 if (!$post || $post->post_type !== self::POST_TYPE) {
624 return false;
625 }
626
627 $new_status = $post->post_status === 'publish' ? 'draft' : 'publish';
628
629 wp_update_post([
630 'ID' => $id,
631 'post_status' => $new_status,
632 ]);
633
634 return $new_status === 'publish' ? 'enabled' : 'disabled';
635 }
636
637 // =========================================================================
638 // AJAX Handlers
639 // =========================================================================
640
641 /**
642 * AJAX: Save snippet.
643 *
644 * @return void
645 */
646 public function ajax_save_snippet(): void
647 {
648 check_ajax_referer('kng_cc_admin', 'nonce');
649
650 if (!current_user_can('manage_options')) {
651 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
652 }
653
654 $data = isset($_POST['snippet']) ? json_decode(wp_unslash($_POST['snippet']), true) : [];
655
656 if (empty($data)) {
657 wp_send_json_error(['message' => __('Invalid data', 'king-addons')]);
658 }
659
660 $id = $this->save_snippet($data);
661
662 if ($id) {
663 wp_send_json_success([
664 'id' => $id,
665 'message' => __('Snippet saved successfully', 'king-addons'),
666 ]);
667 } else {
668 $has_pro = self::hasPro();
669 if (!$has_pro) {
670 wp_send_json_error(['message' => __('Free version limit reached', 'king-addons')]);
671 }
672 wp_send_json_error(['message' => __('Failed to save snippet', 'king-addons')]);
673 }
674 }
675
676 /**
677 * AJAX: Delete snippet.
678 *
679 * @return void
680 */
681 public function ajax_delete_snippet(): void
682 {
683 check_ajax_referer('kng_cc_admin', 'nonce');
684
685 if (!current_user_can('manage_options')) {
686 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
687 }
688
689 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
690
691 if ($this->delete_snippet($id)) {
692 wp_send_json_success(['message' => __('Snippet deleted', 'king-addons')]);
693 } else {
694 wp_send_json_error(['message' => __('Failed to delete snippet', 'king-addons')]);
695 }
696 }
697
698 /**
699 * AJAX: Toggle snippet.
700 *
701 * @return void
702 */
703 public function ajax_toggle_snippet(): void
704 {
705 check_ajax_referer('kng_cc_admin', 'nonce');
706
707 if (!current_user_can('manage_options')) {
708 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
709 }
710
711 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
712 $new_status = $this->toggle_snippet($id);
713
714 if ($new_status) {
715 wp_send_json_success([
716 'status' => $new_status,
717 'message' => __('Status updated', 'king-addons'),
718 ]);
719 } else {
720 wp_send_json_error(['message' => __('Failed to update status', 'king-addons')]);
721 }
722 }
723
724 /**
725 * AJAX: Duplicate snippet.
726 *
727 * @return void
728 */
729 public function ajax_duplicate_snippet(): void
730 {
731 check_ajax_referer('kng_cc_admin', 'nonce');
732
733 if (!current_user_can('manage_options')) {
734 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
735 }
736
737 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
738 $new_id = $this->duplicate_snippet($id);
739
740 if ($new_id) {
741 wp_send_json_success([
742 'id' => $new_id,
743 'snippet' => $this->get_snippet($new_id),
744 'message' => __('Snippet duplicated', 'king-addons'),
745 ]);
746 } else {
747 $has_pro = self::hasPro();
748 if (!$has_pro) {
749 wp_send_json_error(['message' => __('Free version limit reached', 'king-addons'), 'limit' => true]);
750 }
751 wp_send_json_error(['message' => __('Failed to duplicate snippet', 'king-addons')]);
752 }
753 }
754
755 /**
756 * AJAX: Export single snippet.
757 *
758 * @return void
759 */
760 public function ajax_export_snippet(): void
761 {
762 check_ajax_referer('kng_cc_admin', 'nonce');
763
764 if (!current_user_can('manage_options')) {
765 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
766 }
767
768 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
769 $snippet = $this->get_snippet($id);
770
771 if (!$snippet) {
772 wp_send_json_error(['message' => __('Snippet not found', 'king-addons')]);
773 }
774
775 // Remove internal fields
776 unset($snippet['author'], $snippet['created'], $snippet['modified']);
777
778 wp_send_json_success([
779 'export' => [
780 'version' => KING_ADDONS_VERSION,
781 'type' => 'single',
782 'exported' => current_time('mysql'),
783 'snippets' => [$snippet],
784 ],
785 ]);
786 }
787
788 /**
789 * AJAX: Export all snippets.
790 *
791 * @return void
792 */
793 public function ajax_export_all(): void
794 {
795 check_ajax_referer('kng_cc_admin', 'nonce');
796
797 if (!current_user_can('manage_options')) {
798 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
799 }
800
801 $snippets = $this->get_all_snippets();
802
803 foreach ($snippets as &$snippet) {
804 unset($snippet['author'], $snippet['created'], $snippet['modified']);
805 }
806
807 wp_send_json_success([
808 'export' => [
809 'version' => KING_ADDONS_VERSION,
810 'type' => 'all',
811 'exported' => current_time('mysql'),
812 'snippets' => $snippets,
813 'settings' => $this->get_settings(),
814 ],
815 ]);
816 }
817
818 /**
819 * AJAX: Import snippets.
820 *
821 * @return void
822 */
823 public function ajax_import(): void
824 {
825 check_ajax_referer('kng_cc_admin', 'nonce');
826
827 if (!current_user_can('manage_options')) {
828 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
829 }
830
831 $import_data = isset($_POST['import']) ? json_decode(wp_unslash($_POST['import']), true) : [];
832 $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'merge';
833
834 if (empty($import_data['snippets'])) {
835 wp_send_json_error(['message' => __('Invalid import data', 'king-addons')]);
836 }
837
838 $has_pro = self::hasPro();
839 $imported = 0;
840 $skipped = 0;
841 $errors = 0;
842
843 // Get existing snippets for duplicate check
844 $existing = [];
845 if ($mode === 'skip') {
846 foreach ($this->get_all_snippets() as $snippet) {
847 $existing[$snippet['title']] = true;
848 }
849 }
850
851 // Replace mode: delete all first
852 if ($mode === 'replace') {
853 foreach ($this->get_all_snippets() as $snippet) {
854 $this->delete_snippet($snippet['id']);
855 }
856 }
857
858 foreach ($import_data['snippets'] as $snippet) {
859 // Check duplicate
860 if ($mode === 'skip' && isset($existing[$snippet['title']])) {
861 $skipped++;
862 continue;
863 }
864
865 // Check free limit
866 if (!$has_pro) {
867 $count = count($this->get_all_snippets());
868 if ($count >= self::FREE_LIMIT) {
869 $errors++;
870 continue;
871 }
872 }
873
874 // Reset ID for new import
875 $snippet['id'] = 0;
876
877 if ($this->save_snippet($snippet)) {
878 $imported++;
879 } else {
880 $errors++;
881 }
882 }
883
884 wp_send_json_success([
885 'imported' => $imported,
886 'skipped' => $skipped,
887 'errors' => $errors,
888 'message' => sprintf(
889 __('Import complete: %d imported, %d skipped, %d errors', 'king-addons'),
890 $imported,
891 $skipped,
892 $errors
893 ),
894 ]);
895 }
896
897 /**
898 * AJAX: Search content (pages, posts, etc.)
899 *
900 * @return void
901 */
902 public function ajax_search_content(): void
903 {
904 check_ajax_referer('kng_cc_admin', 'nonce');
905
906 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
907 $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'page';
908
909 // If search is a numeric ID, fetch that specific post directly
910 if (ctype_digit($search)) {
911 $post = get_post((int)$search);
912 if ($post && $post->post_type === $content_type && $post->post_status === 'publish') {
913 wp_send_json_success([
914 [
915 'id' => $post->ID,
916 'title' => $post->post_title,
917 'type' => $post->post_type,
918 ]
919 ]);
920 } else {
921 wp_send_json_success([]);
922 }
923 return;
924 }
925
926 $args = [
927 'post_type' => $content_type,
928 'posts_per_page' => 20,
929 'post_status' => 'publish',
930 's' => $search,
931 ];
932
933 $posts = get_posts($args);
934 $results = [];
935
936 foreach ($posts as $post) {
937 $results[] = [
938 'id' => $post->ID,
939 'title' => $post->post_title,
940 'type' => $post->post_type,
941 ];
942 }
943
944 wp_send_json_success($results);
945 }
946
947 /**
948 * AJAX: Get single snippet.
949 *
950 * @return void
951 */
952 public function ajax_get_snippet(): void
953 {
954 check_ajax_referer('kng_cc_admin', 'nonce');
955
956 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
957 $snippet = $this->get_snippet($id);
958
959 if ($snippet) {
960 wp_send_json_success($snippet);
961 } else {
962 wp_send_json_error(['message' => __('Snippet not found', 'king-addons')]);
963 }
964 }
965
966 /**
967 * AJAX: Bulk action.
968 *
969 * @return void
970 */
971 public function ajax_bulk_action(): void
972 {
973 check_ajax_referer('kng_cc_admin', 'nonce');
974
975 if (!current_user_can('manage_options')) {
976 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
977 }
978
979 $action = isset($_POST['bulk_action']) ? sanitize_key($_POST['bulk_action']) : '';
980 $ids = isset($_POST['ids']) ? array_map('intval', (array)$_POST['ids']) : [];
981
982 if (empty($ids)) {
983 wp_send_json_error(['message' => __('No items selected', 'king-addons')]);
984 }
985
986 $processed = 0;
987
988 foreach ($ids as $id) {
989 switch ($action) {
990 case 'enable':
991 $post = get_post($id);
992 if ($post && $post->post_status !== 'publish') {
993 wp_update_post(['ID' => $id, 'post_status' => 'publish']);
994 $processed++;
995 }
996 break;
997
998 case 'disable':
999 $post = get_post($id);
1000 if ($post && $post->post_status === 'publish') {
1001 wp_update_post(['ID' => $id, 'post_status' => 'draft']);
1002 $processed++;
1003 }
1004 break;
1005
1006 case 'delete':
1007 if ($this->delete_snippet($id)) {
1008 $processed++;
1009 }
1010 break;
1011 }
1012 }
1013
1014 wp_send_json_success([
1015 'processed' => $processed,
1016 'message' => sprintf(__('%d items processed', 'king-addons'), $processed),
1017 ]);
1018 }
1019
1020 // =========================================================================
1021 // Frontend Injection
1022 // =========================================================================
1023
1024 /**
1025 * Gets snippets for current page.
1026 *
1027 * @param string $location Location filter.
1028 * @return array
1029 */
1030 private function get_snippets_for_current_page(string $location): array
1031 {
1032 if ($this->cached_snippets === null) {
1033 $this->cached_snippets = $this->get_all_snippets([
1034 'post_status' => 'publish',
1035 ]);
1036 }
1037
1038 $matching = [];
1039
1040 foreach ($this->cached_snippets as $snippet) {
1041 // Check location
1042 if ($snippet['location'] !== $location) {
1043 continue;
1044 }
1045
1046 // Check rules
1047 if ($this->should_load_snippet($snippet)) {
1048 $matching[] = $snippet;
1049 }
1050 }
1051
1052 // Sort by priority
1053 usort($matching, function ($a, $b) {
1054 $priority_diff = $a['priority'] - $b['priority'];
1055 if ($priority_diff !== 0) {
1056 return $priority_diff;
1057 }
1058 return $a['id'] - $b['id'];
1059 });
1060
1061 return $matching;
1062 }
1063
1064 /**
1065 * Checks if snippet should load on current page.
1066 *
1067 * @param array $snippet Snippet config.
1068 * @return bool
1069 */
1070 private function should_load_snippet(array $snippet): bool
1071 {
1072 $cache_key = 'snippet_' . $snippet['id'];
1073
1074 if (isset($this->rule_cache[$cache_key])) {
1075 return $this->rule_cache[$cache_key];
1076 }
1077
1078 $result = $this->evaluate_rules($snippet);
1079 $this->rule_cache[$cache_key] = $result;
1080
1081 if ($this->is_debug_mode()) {
1082 $this->debug_log[] = [
1083 'snippet' => $snippet['title'],
1084 'id' => $snippet['id'],
1085 'result' => $result,
1086 'scope_mode' => $snippet['scope_mode'],
1087 'rules' => $snippet['rules'],
1088 ];
1089 }
1090
1091 return $result;
1092 }
1093
1094 /**
1095 * Evaluates snippet rules.
1096 *
1097 * @param array $snippet Snippet config.
1098 * @return bool
1099 */
1100 private function evaluate_rules(array $snippet): bool
1101 {
1102 $scope_mode = $snippet['scope_mode'];
1103 $rules = $snippet['rules'];
1104 $match_mode = $snippet['match_mode'];
1105 $has_pro = self::hasPro();
1106
1107 // Global scope: always load
1108 if ($scope_mode === 'global') {
1109 return true;
1110 }
1111
1112 if (empty($rules)) {
1113 return $scope_mode === 'include' ? false : true;
1114 }
1115
1116 // Separate include and exclude rules
1117 $include_rules = [];
1118 $exclude_rules = [];
1119
1120 foreach ($rules as $rule) {
1121 if (isset($rule['exclude']) && $rule['exclude']) {
1122 $exclude_rules[] = $rule;
1123 } else {
1124 $include_rules[] = $rule;
1125 }
1126 }
1127
1128 // Check exclude rules first (Pro)
1129 if ($has_pro && !empty($exclude_rules)) {
1130 foreach ($exclude_rules as $rule) {
1131 if ($this->evaluate_single_rule($rule)) {
1132 return false;
1133 }
1134 }
1135 }
1136
1137 // Check include rules
1138 if ($scope_mode === 'include') {
1139 if (empty($include_rules)) {
1140 return false;
1141 }
1142
1143 // Match mode (Pro): ALL or ANY
1144 if ($has_pro && $match_mode === 'all') {
1145 foreach ($include_rules as $rule) {
1146 if (!$this->evaluate_single_rule($rule)) {
1147 return false;
1148 }
1149 }
1150 return true;
1151 }
1152
1153 // Default: ANY match
1154 foreach ($include_rules as $rule) {
1155 if ($this->evaluate_single_rule($rule)) {
1156 return true;
1157 }
1158 }
1159 return false;
1160 }
1161
1162 // Exclude scope mode
1163 if ($scope_mode === 'exclude') {
1164 foreach ($include_rules as $rule) {
1165 if ($this->evaluate_single_rule($rule)) {
1166 return false;
1167 }
1168 }
1169 return true;
1170 }
1171
1172 return true;
1173 }
1174
1175 /**
1176 * Evaluates a single rule.
1177 *
1178 * @param array $rule Rule config.
1179 * @return bool
1180 */
1181 private function evaluate_single_rule(array $rule): bool
1182 {
1183 $type = $rule['type'] ?? '';
1184 $value = $rule['value'] ?? '';
1185 $has_pro = self::hasPro();
1186
1187 switch ($type) {
1188 case 'page':
1189 case 'post':
1190 if (is_singular()) {
1191 return get_the_ID() === (int)$value;
1192 }
1193 return false;
1194
1195 case 'post_type':
1196 return is_singular($value) || is_post_type_archive($value);
1197
1198 case 'url_contains':
1199 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1200 return strpos($current_url, $value) !== false;
1201
1202 case 'url_starts':
1203 if (!$has_pro) return false;
1204 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1205 return strpos($current_url, $value) === 0;
1206
1207 case 'url_ends':
1208 if (!$has_pro) return false;
1209 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1210 return substr($current_url, -strlen($value)) === $value;
1211
1212 case 'url_regex':
1213 if (!$has_pro) return false;
1214 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1215 try {
1216 return (bool)preg_match($value, $current_url);
1217 } catch (\Exception $e) {
1218 return false;
1219 }
1220
1221 case 'taxonomy':
1222 if (!$has_pro) return false;
1223 $tax = $rule['taxonomy'] ?? 'category';
1224 $term_id = (int)$value;
1225 if (is_singular()) {
1226 return has_term($term_id, $tax);
1227 }
1228 if (is_tax($tax, $term_id) || is_category($term_id) || is_tag($term_id)) {
1229 return true;
1230 }
1231 return false;
1232
1233 case 'user_logged_in':
1234 if (!$has_pro) return false;
1235 return is_user_logged_in() === ($value === 'yes');
1236
1237 case 'user_role':
1238 if (!$has_pro) return false;
1239 if (!is_user_logged_in()) return false;
1240 $user = wp_get_current_user();
1241 return in_array($value, $user->roles, true);
1242
1243 case 'device':
1244 if (!$has_pro) return false;
1245 return $this->detect_device() === $value;
1246
1247 case 'front_page':
1248 return is_front_page();
1249
1250 case 'blog_page':
1251 return is_home();
1252
1253 case 'archive':
1254 return is_archive();
1255
1256 case 'search':
1257 return is_search();
1258
1259 case '404':
1260 return is_404();
1261
1262 default:
1263 return false;
1264 }
1265 }
1266
1267 /**
1268 * Detects current device type.
1269 *
1270 * @return string
1271 */
1272 private function detect_device(): string
1273 {
1274 if (!isset($_SERVER['HTTP_USER_AGENT'])) {
1275 return 'desktop';
1276 }
1277
1278 $ua = strtolower($_SERVER['HTTP_USER_AGENT']);
1279
1280 if (preg_match('/(tablet|ipad|playbook|silk)|(android(?!.*mobile))/i', $ua)) {
1281 return 'tablet';
1282 }
1283
1284 if (preg_match('/(mobile|android|iphone|ipod|blackberry|windows phone)/i', $ua)) {
1285 return 'mobile';
1286 }
1287
1288 return 'desktop';
1289 }
1290
1291 /**
1292 * Checks if the extension is enabled in settings.
1293 *
1294 * @return bool
1295 */
1296 private function is_enabled(): bool
1297 {
1298 $settings = $this->get_settings();
1299 return !empty($settings['enabled']);
1300 }
1301
1302 /**
1303 * Injects code into wp_head.
1304 *
1305 * @return void
1306 */
1307 public function inject_head(): void
1308 {
1309 if (!$this->is_enabled()) {
1310 return;
1311 }
1312
1313 $snippets = $this->get_snippets_for_current_page('head');
1314 $this->output_snippets($snippets);
1315 }
1316
1317 /**
1318 * Injects code into wp_footer.
1319 *
1320 * @return void
1321 */
1322 public function inject_footer(): void
1323 {
1324 if (!$this->is_enabled()) {
1325 return;
1326 }
1327
1328 $snippets = $this->get_snippets_for_current_page('footer');
1329 $this->output_snippets($snippets);
1330 }
1331
1332 /**
1333 * Injects code into wp_body_open.
1334 *
1335 * @return void
1336 */
1337 public function inject_body_open(): void
1338 {
1339 if (!$this->is_enabled() || !self::hasPro()) {
1340 return;
1341 }
1342
1343 $snippets = $this->get_snippets_for_current_page('body_open');
1344 $this->output_snippets($snippets);
1345 }
1346
1347 /**
1348 * Registers custom hooks for snippets with custom_hook location.
1349 *
1350 * @return void
1351 */
1352 public function register_custom_hooks(): void
1353 {
1354 if (!$this->is_enabled() || !self::hasPro()) {
1355 return;
1356 }
1357
1358 $snippets = $this->get_all_snippets([
1359 'post_status' => 'publish',
1360 ]);
1361
1362 $hooks_registered = [];
1363
1364 foreach ($snippets as $snippet) {
1365 if ($snippet['location'] !== 'custom_hook' || empty($snippet['custom_hook'])) {
1366 continue;
1367 }
1368
1369 $hook_name = $snippet['custom_hook'];
1370
1371 // Register each unique hook only once
1372 if (isset($hooks_registered[$hook_name])) {
1373 continue;
1374 }
1375
1376 $hooks_registered[$hook_name] = true;
1377
1378 add_action($hook_name, function () use ($hook_name) {
1379 if (!$this->is_enabled()) {
1380 return;
1381 }
1382
1383 $snippets = $this->get_snippets_for_custom_hook($hook_name);
1384 $this->output_snippets($snippets);
1385 }, 10);
1386 }
1387 }
1388
1389 /**
1390 * Gets snippets for a specific custom hook.
1391 *
1392 * @param string $hook_name Hook name.
1393 * @return array
1394 */
1395 private function get_snippets_for_custom_hook(string $hook_name): array
1396 {
1397 if ($this->cached_snippets === null) {
1398 $this->cached_snippets = $this->get_all_snippets([
1399 'post_status' => 'publish',
1400 ]);
1401 }
1402
1403 $matching = [];
1404
1405 foreach ($this->cached_snippets as $snippet) {
1406 if ($snippet['location'] !== 'custom_hook' || $snippet['custom_hook'] !== $hook_name) {
1407 continue;
1408 }
1409
1410 if ($this->should_load_snippet($snippet)) {
1411 $matching[] = $snippet;
1412 }
1413 }
1414
1415 usort($matching, function ($a, $b) {
1416 $priority_diff = $a['priority'] - $b['priority'];
1417 if ($priority_diff !== 0) {
1418 return $priority_diff;
1419 }
1420 return $a['id'] - $b['id'];
1421 });
1422
1423 return $matching;
1424 }
1425
1426 /**
1427 * Outputs snippets.
1428 *
1429 * @param array $snippets Snippets to output.
1430 * @return void
1431 */
1432 private function output_snippets(array $snippets): void
1433 {
1434 foreach ($snippets as $snippet) {
1435 $code = $snippet['code'];
1436
1437 if (empty(trim($code))) {
1438 continue;
1439 }
1440
1441 switch ($snippet['type']) {
1442 case 'css':
1443 echo "\n<!-- King Addons Custom CSS: " . esc_html($snippet['title']) . " -->\n";
1444 echo "<style id=\"kng-cc-" . esc_attr($snippet['id']) . "\">\n";
1445 echo $code;
1446 echo "\n</style>\n";
1447 break;
1448
1449 case 'js':
1450 echo "\n<!-- King Addons Custom JS: " . esc_html($snippet['title']) . " -->\n";
1451 $this->output_js_snippet($snippet);
1452 break;
1453
1454 case 'html':
1455 if (self::hasPro()) {
1456 echo "\n<!-- King Addons Custom HTML: " . esc_html($snippet['title']) . " -->\n";
1457 echo $code;
1458 echo "\n";
1459 }
1460 break;
1461 }
1462 }
1463 }
1464
1465 /**
1466 * Outputs JS snippet with proper attributes.
1467 *
1468 * @param array $snippet Snippet config.
1469 * @return void
1470 */
1471 private function output_js_snippet(array $snippet): void
1472 {
1473 $code = $snippet['code'];
1474 $has_pro = self::hasPro();
1475
1476 // Wrap in DOMContentLoaded if needed
1477 if ($snippet['js_dom_ready']) {
1478 $code = "document.addEventListener('DOMContentLoaded', function() {\n" . $code . "\n});";
1479 }
1480
1481 $attrs = ['id="kng-cc-' . esc_attr($snippet['id']) . '"'];
1482
1483 if ($has_pro) {
1484 if ($snippet['js_module']) {
1485 $attrs[] = 'type="module"';
1486 }
1487 // Note: defer and async attributes are only effective on scripts with a src attribute.
1488 // For inline scripts they have no effect per HTML spec, so we don't add them here.
1489 }
1490
1491 echo '<script ' . implode(' ', $attrs) . ">\n";
1492 echo $code;
1493 echo "\n</script>\n";
1494 }
1495
1496 /**
1497 * Modifies script tag for enqueued scripts.
1498 *
1499 * @param string $tag Script tag.
1500 * @param string $handle Script handle.
1501 * @param string $src Script source.
1502 * @return string
1503 */
1504 public function modify_script_tag(string $tag, string $handle, string $src): string
1505 {
1506 // For future use with external scripts
1507 return $tag;
1508 }
1509
1510 // =========================================================================
1511 // Settings
1512 // =========================================================================
1513
1514 /**
1515 * Gets settings.
1516 *
1517 * @return array
1518 */
1519 public function get_settings(): array
1520 {
1521 $defaults = [
1522 'enabled' => true,
1523 'default_location_css' => 'head',
1524 'default_location_js' => 'footer',
1525 'default_priority' => 10,
1526 'debug_mode' => false,
1527 ];
1528
1529 $saved = get_option('kng_cc_settings', []);
1530
1531 return array_merge($defaults, $saved);
1532 }
1533
1534 /**
1535 * Saves settings.
1536 *
1537 * @param array $settings Settings to save.
1538 * @return bool
1539 */
1540 public function save_settings(array $settings): bool
1541 {
1542 return update_option('kng_cc_settings', $settings);
1543 }
1544
1545 /**
1546 * AJAX: Save settings.
1547 *
1548 * @return void
1549 */
1550 public function ajax_save_settings(): void
1551 {
1552 check_ajax_referer('kng_cc_admin', 'nonce');
1553
1554 if (!current_user_can('manage_options')) {
1555 wp_send_json_error(['message' => __('Permission denied', 'king-addons')]);
1556 }
1557
1558 $data = isset($_POST['settings']) ? json_decode(wp_unslash($_POST['settings']), true) : [];
1559
1560 if (empty($data) || !is_array($data)) {
1561 wp_send_json_error(['message' => __('Invalid data', 'king-addons')]);
1562 }
1563
1564 $settings = [
1565 'enabled' => !empty($data['enabled']),
1566 'default_location_css' => sanitize_key($data['default_location_css'] ?? 'head'),
1567 'default_location_js' => sanitize_key($data['default_location_js'] ?? 'footer'),
1568 'default_priority' => (int)($data['default_priority'] ?? 10),
1569 'debug_mode' => !empty($data['debug_mode']),
1570 ];
1571
1572 if ($this->save_settings($settings)) {
1573 wp_send_json_success(['message' => __('Settings saved', 'king-addons')]);
1574 } else {
1575 wp_send_json_error(['message' => __('Failed to save settings', 'king-addons')]);
1576 }
1577 }
1578
1579 /**
1580 * Checks if debug mode is enabled.
1581 *
1582 * @return bool
1583 */
1584 private function is_debug_mode(): bool
1585 {
1586 $settings = $this->get_settings();
1587 return !empty($settings['debug_mode']);
1588 }
1589
1590 /**
1591 * Outputs debug log.
1592 *
1593 * @return void
1594 */
1595 public function output_debug_log(): void
1596 {
1597 if (empty($this->debug_log)) {
1598 return;
1599 }
1600
1601 echo "\n<!-- King Addons Custom Code Debug Log -->\n";
1602 echo "<script>\n";
1603 echo "console.group('King Addons Custom Code Manager');\n";
1604 foreach ($this->debug_log as $entry) {
1605 $status = $entry['result'] ? '🟢' : '🔴';
1606 echo "console.log('" . $status . " " . esc_js($entry['snippet']) . " (ID: " . $entry['id'] . ")');\n";
1607 }
1608 echo "console.groupEnd();\n";
1609 echo "</script>\n";
1610 }
1611 }
1612