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 / 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.49, at includes/extensions/Custom_Code_Manager/Custom_Code_Manager.php

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