PluginProbe
Microthemer Lite – Visual Editor to Customize CSS / trunk
Microthemer Lite – Visual Editor to Customize CSS vtrunk
trunk 5.0.0.2
microthemer / src / Content / AdminContent.php

AdminContent.php in Microthemer Lite – Visual Editor to Customize CSS trunk, at src/Content/AdminContent.php

1,245 lines 35.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Microthemer\Content;
4
5 use \Microthemer\TimerTrait;
6
7 /*
8 * Admin Content
9 *
10 * Manage content actions on the admin side
11 */
12
13 class AdminContent {
14
15 use TimerTrait;
16
17 var $Admin;
18 var $preferences = array();
19 var $tailwindClassCacheDir;
20 var $tailwindStyleCacheDir;
21
22 var $content_table;
23
24 var $features = array(
25 '2025-03-26' => array(
26 array(
27 'name' => 'inspect',
28 'title' => 'Inspect amendments',
29 'desc' => 'Review all amendments applying to the current page'
30 )
31 )
32 );
33
34 public function __construct(&$Admin) {
35
36 global $wpdb;
37
38 // save reference to calling class
39 $this->Admin = &$Admin;
40 $this->preferences = &$Admin->preferences;
41 $this->tailwindClassCacheDir = $Admin->micro_root_dir . 'mt/cache/tailwind/classes/';
42 $this->tailwindStyleCacheDir = $Admin->micro_root_dir . 'mt/cache/tailwind/styles/';
43 $this->content_table = $wpdb->prefix . "micro_content";
44 }
45
46 // Load TinyMCE scripts, but initialisation will be done on the client side
47 function loadTinyMCE(){
48 wp_enqueue_editor();
49 }
50
51 function getExistingMods($published){
52
53 global $wpdb;
54 $content_table = $wpdb->prefix . "micro_content";
55
56 return $wpdb->get_results(
57 $wpdb->prepare(
58 "SELECT DISTINCT slug FROM $content_table
59 WHERE type = %s AND published = %d",
60 'folder_mod', $published
61 ),
62 ARRAY_A
63 );
64 }
65
66 function maybeUpdateHTMLTable($type, $insertOrUpdate, $staleMods = array(), $published = 0){
67
68 //wp_die('maybeUpdateHTMLTable: <pre>' . print_r([$insertOrUpdate, $staleMods, count($insertOrUpdate), count($staleMods)], 1) . '</pre>');
69
70 global $wpdb;
71 $content_table = $wpdb->prefix . "micro_content";
72
73 if (count($insertOrUpdate)){
74
75 // insert / update
76 $insertString = '';
77 $insertArray = array();
78 $i = 0;
79 foreach ($insertOrUpdate as $slug => $mod){
80
81 $name = '';
82 $aspect = '';
83 $meta = '';
84 $func_ref = '';
85
86 if ($i > 0){
87 $insertString.= ',';
88 }
89
90 if ($type === 'snippet'){
91 $name = $mod['name'];
92 $aspect = $mod['aspect'];
93 $meta = isset($mod['meta'])
94 ? (is_array($mod['meta'])
95 ? wp_json_encode($mod['meta'])
96 : $mod['meta']
97 )
98 : '';
99 $func_ref = isset($mod['func_ref'])
100 ? $mod['func_ref']
101 : '';
102 $mod = $mod['value'];
103 }
104
105 $insertString.= '(%d, %s, %s, %s, %s, %d, %s, %s, %d)';
106 $insertArray[] = $i;
107 $insertArray[] = $slug;
108 $insertArray[] = $name;
109 $insertArray[] = $type;
110 $insertArray[] = $aspect;
111 $insertArray[] = $published;
112 $insertArray[] = $mod;
113 $insertArray[] = $meta;
114 $insertArray[] = $func_ref;
115
116 $i++;
117 }
118
119 // VALUES() is deprecated in MySQL 8.0.20+ but alias INSERT syntax was unreliable here.
120 $wpdb->query(
121 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- multi-row VALUES (%d,%s,...) built dynamically; table name is plugin-owned.
122 $wpdb->prepare(
123 "insert INTO $content_table(seq, slug, name, type, aspect, published, content, meta, func_ref)
124 VALUES $insertString
125 ON DUPLICATE KEY UPDATE
126 seq = VALUES(seq),
127 name = VALUES(name),
128 aspect = VALUES(aspect),
129 content = VALUES(content),
130 modified_at = NOW(),
131 meta = VALUES(meta),
132 func_ref = VALUES(func_ref)",
133 ...$insertArray
134 )
135 // phpcs:enable
136 );
137
138 }
139
140 // delete empty snippet
141 if ($type === 'snippet'){
142 // todo - if appropriate...
143 }
144
145 // remove any stale folders
146 if ($type === 'folder_mod'){
147 if (count($staleMods)){
148
149 /*wp_die('$staleMods: <pre>' . print_r([
150 $staleMods
151 ], 1) . '</pre>');*/
152
153 $deleteString = '';
154 $deleteArray = array($published);
155 $i = 0;
156
157 foreach ($staleMods as $slug => $one){
158
159 if ($i > 0){
160 $deleteString.= ' or ';
161 }
162
163 $deleteString.= 'slug = %s';
164 $deleteArray[] = $slug;
165 $i++;
166 }
167
168
169 $wpdb->query(
170 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- dynamic OR slug=%s list; table name is plugin-owned.
171 $wpdb->prepare(
172 "DELETE FROM $content_table
173 WHERE type = 'folder_mod' AND published = %d and ( $deleteString )",
174 ...$deleteArray
175 )
176 // phpcs:enable
177 );
178
179 //wp_die('$sql: <pre>' . print_r([$preparedSql, $staleMods], 1) . '</pre>');
180 //wp_die('maybeUpdateHTMLTable: <pre>' . print_r([$insertOrUpdate, $staleMods, $content_table, $sql, $deleteArray], 1) . '</pre>');
181
182 // clean stale folders
183 }
184 }
185
186
187 }
188
189 //
190 function getSnippets(
191 $num = 3, $published = 0, $output = OBJECT, $columns = null, $where = array()
192 ){
193
194 global $wpdb;
195
196 $allowed_columns = array(
197 'seq', 'slug', 'name', 'type', 'aspect', 'published', 'content', 'modified_at', 'meta', 'func_ref',
198 );
199
200 if (is_null($columns)){
201 $columns = 'slug, name, aspect, content, modified_at, meta, func_ref';
202 } else {
203 $requested_columns = array_map( 'trim', explode( ',', $columns ) );
204 $safe_columns = array();
205 foreach ( $requested_columns as $column ) {
206 if ( in_array( $column, $allowed_columns, true ) ) {
207 $safe_columns[] = $column;
208 }
209 }
210 $columns = ! empty( $safe_columns )
211 ? implode( ', ', $safe_columns )
212 : 'slug, name, aspect, content, modified_at, meta, func_ref';
213 }
214
215 $limit = '';
216 $whereString = '';
217 $values = array($published, 'snippet');
218
219 // Where
220 if (count($where)){
221
222 // and/or where conditions
223 foreach ($where as $type => $array){
224
225 if (count($array)){
226 $pieces = array();
227 foreach ($array as $item){
228 $col = $item[0];
229 $val = $item[1];
230
231 if ( ! in_array( $col, $allowed_columns, true ) ) {
232 continue;
233 }
234
235 // Check if $val starts with '!'
236 if (strpos($val, '!') === 0) {
237 $val = substr($val, 1); // Strip the '!'
238 $pieces[] = $col . ' != %s';
239 } else {
240 $pieces[] = $col . ' = %s';
241 }
242
243 $values[] = $val;
244 }
245 if ( count( $pieces ) ) {
246 $whereString .= ' AND ( ' . implode( ' ' . $type . ' ', $pieces ) . ') ';
247 }
248 }
249 }
250 }
251
252 // Limit
253 if ($num > 0){
254 $limit = "LIMIT %d";
255 $values[] = $num;
256 }
257
258 return $wpdb->get_results(
259 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQL.NotPrepared -- table/columns whitelisted; IN/LIMIT placeholders built dynamically.
260 $wpdb->prepare(
261 "SELECT $columns FROM $this->content_table
262 WHERE published = %d AND type = %s $whereString
263 ORDER BY aspect, modified_at DESC
264 $limit",
265 ...$values
266 ),
267 // phpcs:enable
268 $output
269 );
270
271 }
272
273 function getSingleSnippet($slug, $published = 0){
274
275 global $wpdb;
276
277 $result = $wpdb->get_row(
278 $wpdb->prepare(
279 "SELECT name, content, aspect FROM $this->content_table
280 WHERE type = %s AND published = %d AND slug = %s",
281 'snippet', $published, $slug
282 )
283 );
284
285 $this->Admin->jsonResponse($result);
286
287 }
288
289 /*function getAllSnippetNames(){
290
291 $array = array();
292
293 $results = $this->getSnippets(-1, 0, OBJECT, 'slug, name, aspect, modified_at');
294
295 if ($results) {
296 foreach ($results as $row) {
297
298 $dateTime = new \DateTime($row->modified_at);
299
300 $array[$row->slug] = array(
301 'id' => $row->slug,
302 'label' => $row->name,
303 'mysql_date' => $row->modified_at,
304 'category' => $row->aspect
305 //'modified_at' => $dateTime->format('M j, y @ g:ia')
306 );
307 }
308 }
309
310 return $array;
311 }*/
312
313 function getSnippetCache($method, $or = array(), $and = array()){
314
315 $cache = array();
316
317 //return $cache;
318
319 //$results = $this->getSnippets();
320 $results = $this->Admin->contentMethod($method, array($or, $and));
321
322 if ($results) {
323
324 foreach ($results as $row) {
325
326 //wp_die('$row: <pre>' . print_r(json_decode($row->content, true), 1). '</pre>');
327
328 $cache[$row->slug] = array(
329 'name' => $row->name,
330 'value' => $row->content,
331 'mysql_data' => $row->modified_at,
332 //'category' => $row->aspect,
333 'aspect' => $row->aspect,
334 'meta' => json_decode($row->meta, true)
335 );
336 }
337
338 //wp_die('here: <pre>' . print_r($cache, 1). '</pre>');
339 }
340
341 return $cache;
342
343 }
344
345
346
347
348 // Save snippets that have been added or edited
349 function addUpdateOrDeleteSnippets($snippet_cache, $snippets_deleted = null){
350
351 //wp_die('$snippet_cache: <pre>'. print_r($snippet_cache, 1). '</pre>');
352
353 // Delete snippets marked for deletion (do this first in case user re-adds snippet content after deletion)
354 if (is_array($snippets_deleted)){
355 $this->deleteSnippets(array_keys($snippets_deleted));
356 }
357
358 // Snippet cache
359 if (is_array($snippet_cache)){
360
361 $insertOrUpdate = array();
362
363 // get previous id/name from DB for file cleanup of renamed items
364 /*$slugs = array();
365 foreach ($snippet_cache as $slug => &$data){
366 $slugs[] = array('slug', $slug);
367 }*/
368 $previousSnippets = $this->getSnippetsFromIds(array_keys($snippet_cache), array(array('aspect', 'js'))); // $this->getSnippetCache('getSnippetsOfType', $slugs);
369
370 foreach ($snippet_cache as $slug => &$data){
371
372 $insertOrUpdate[$slug] = $data; // wp_json_encode($content);
373
374 // Update js file on server
375 if ($data['aspect'] === 'js'){
376 $file_name = ContentHelper::getJsFileName($data, $slug);
377 $prev_file_name = !empty($previousSnippets[$slug]) ? ContentHelper::getJsFileName($previousSnippets[$slug], $slug) : false;
378
379 // cleanup previous file if renamed
380 if ($file_name !== $prev_file_name){
381 $prev_file = $this->Admin->micro_root_dir . 'mt/js/draft/'. $prev_file_name;
382 if (file_exists($prev_file)){
383 wp_delete_file($prev_file);
384 }
385 }
386
387 // Write new file
388 $importStatements = '';
389 if (!empty($data['meta'])){
390 $importStatements = ContentHelper::getScriptDepsFromMeta($this->preferences['npm_dependencies'], $data['meta'], false, true);
391 if ($importStatements){
392 $importStatements.= "\n";
393 }
394 }
395
396 $file = $this->Admin->micro_root_dir . 'mt/js/draft/'. $file_name;
397 $this->Admin->write_file($file, $importStatements . $data['value'], 0, 'js');
398 }
399
400 }
401
402 $this->maybeUpdateHTMLTable('snippet', $insertOrUpdate);
403
404 // Create a cache file of snippets with TvrJS.func() dependencies
405 $this->updateFunctionCacheFile();
406
407 }
408
409 }
410
411 // To support getting all data from the content table in one DB query, we have a cache file listing the
412 // TVR.func() references a snippet has - so those slugs can be included in the query
413 // To support getting all data from the content table in one DB query, we have a cache file listing the
414 // TVR.func() references a snippet has - so those slugs can be included in the query
415 function updateFunctionCacheFile(){
416
417 $snippets = array();
418 $funcNames = array();
419
420 // Get all slugs and meta from the content table where func_ref = 1
421 global $wpdb;
422 $results = $wpdb->get_results(
423 $wpdb->prepare(
424 "SELECT slug, meta FROM $this->content_table
425 WHERE func_ref = %d",
426 1
427 )
428 );
429
430 if ($results) {
431 // Loop through all snippets and extract tvrjs-(funcName) from meta string
432 foreach ($results as $row) {
433 $scriptDeps = ContentHelper::getScriptDepsFromMeta($this->preferences['npm_dependencies'], $row->meta, true);
434
435 //$meta = !empty($row->meta) ? json_decode($row->meta) : array();
436 //!empty($meta['auto_script_deps']) ? $meta['auto_script_deps'] : array();
437 //$scriptDeps = explode(', ', $row->auto_script_deps); // Assuming auto_script_deps are comma-separated
438 if ($scriptDeps){
439 foreach ($scriptDeps as $dep => $importSyntax) {
440 $dep = trim($dep);
441 // Check if the dep contains a tvrjs function reference
442 if (strpos($dep, 'tvrjs-') === 0) {
443 // Extract the function name from the dep
444 $funcName = trim(str_replace('tvrjs-', '', $dep));
445 $snippets[$row->slug][$funcName] = 1; // Temporarily set to 1
446 $funcNames[$funcName] = 1;
447 }
448 }
449 }
450
451 }
452 }
453
454 // Get slugs for all funcNames from another DB query
455 if (!empty($funcNames)) {
456 $funcNames = array_keys($funcNames); // Extract keys as an array
457
458 // Create the placeholders for the query
459 $placeholders = implode(',', array_fill(0, count($funcNames), '%s'));
460
461 // Prepare and run the query
462 $results = $wpdb->get_results(
463 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- dynamic IN (%s,...) list; table name is plugin-owned.
464 $wpdb->prepare(
465 "SELECT slug, name FROM $this->content_table
466 WHERE name IN ($placeholders)",
467 ...$funcNames
468 )
469 // phpcs:enable
470 );
471
472 // Loop through the results and update the $snippets array
473 if ($results) {
474 foreach ($results as $row) {
475 foreach ($snippets as $slug => &$funcs) {
476 if (isset($funcs[$row->name])) {
477 $funcs[$row->name] = $row->slug; // Update with the correct slug reference
478 }
479 }
480 }
481 }
482 }
483
484 // Save the data to a file as JSON
485 $filePath = $this->Admin->micro_root_dir . 'mt/cache/content/function-deps.json';
486 $this->Admin->write_file($filePath, wp_json_encode($snippets));
487
488
489 }
490
491 function getSnippetsFromIds($snippetIds, $and = array()){
492 $or = array();
493 foreach ($snippetIds as $slug){
494 $or[] = array('slug', $slug);
495 }
496 return $this->getSnippetCache('getSnippetsOfType', $or, array(array('aspect', 'js')));
497 }
498
499 // Snippets can be deleted via the "Select snippet" menu
500 function deleteSnippets($snippetIds){
501
502 global $wpdb;
503
504 // Ensure the input is a non-empty array
505 if (is_array($snippetIds) && !empty($snippetIds)) {
506
507 // Get config for js files, which may need to be deleted from the server
508 /*$slugs = array();
509 foreach ($snippetIds as $slug){
510 $slugs[] = array('slug', $slug);
511 }*/
512 $previousSnippets = $this->getSnippetsFromIds($snippetIds, array(array('aspect', 'js'))); // $this->getSnippetCache('getSnippetsOfType', $slugs, array(array('aspect', 'js')));
513
514 // Create placeholders for the query
515 $placeholders = implode(',', array_fill(0, count($snippetIds), '%s'));
516
517 // Prepare and execute a delete query
518 $wpdb->query(
519 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- dynamic IN (%s,...) list; table name is plugin-owned.
520 $wpdb->prepare(
521 "DELETE FROM $this->content_table
522 WHERE type = %s AND published = %d AND slug IN ($placeholders)",
523 ...array_merge( array( 'snippet', 0 ), $snippetIds )
524 )
525 // phpcs:enable
526 );
527
528 // Clean up the files
529 foreach ($previousSnippets as $snippet_id => $snippet){
530 $file = $this->Admin->micro_root_dir . 'mt/js/draft/'. ContentHelper::getJsFileName($snippet, $snippet_id);
531 if (file_exists($file)){
532 wp_delete_file($file);
533 }
534 }
535
536 }
537 }
538
539 // Duplicate unpublished rows, making them published and updating any existing published rows
540 function publishHTMLTable() {
541 global $wpdb;
542
543 try {
544 // Start transaction
545 $wpdb->query("START TRANSACTION");
546
547 // Insert new published rows only if they don't exist
548 $wpdb->query(
549 "INSERT IGNORE INTO $this->content_table
550 (seq, slug, name, type, published, content, modified_at, aspect, meta, func_ref)
551 SELECT seq, slug, name, type, 1, content, modified_at, aspect, meta, func_ref
552 FROM $this->content_table
553 WHERE published = 0"
554 );
555
556 // Update existing published rows with new content
557 $wpdb->query(
558 "UPDATE $this->content_table t1
559 JOIN $this->content_table t2
560 ON t1.slug = t2.slug
561 AND t1.type = t2.type
562 AND t1.published = 1
563 AND t2.published = 0
564 SET
565 t1.name = t2.name,
566 t1.content = t2.content,
567 t1.modified_at = t2.modified_at,
568 t1.aspect = t2.aspect,
569 t1.meta = t2.meta,
570 t1.func_ref = t2.func_ref"
571 );
572
573 // Delete published rows that have no matching draft rows
574 $wpdb->query(
575 "DELETE t1 FROM $this->content_table t1
576 LEFT JOIN $this->content_table t2
577 ON t1.slug = t2.slug
578 AND t1.type = t2.type
579 AND t2.published = 0
580 WHERE t1.published = 1
581 AND t2.slug IS NULL"
582 );
583
584 // Commit if everything worked
585 $wpdb->query("COMMIT");
586
587 } catch (\Exception $e) {
588 // Rollback on error
589 $wpdb->query("ROLLBACK");
590 $this->Admin->log('Publish Amender error', '<p>' . $e->getMessage() . '</p>');
591 }
592 }
593
594 /*function publishHTMLTable() {
595 global $wpdb;
596
597 // Insert new published rows only if they don't exist
598 $wpdb->query(
599 "INSERT IGNORE INTO $this->content_table
600 (seq, slug, name, type, published, content, modified_at, aspect, meta, func_ref)
601 SELECT seq, slug, name, type, 1, content, modified_at, aspect, meta, func_ref
602 FROM $this->content_table
603 WHERE published = 0"
604 );
605
606 // Update existing published rows with new content
607 $wpdb->query(
608 "UPDATE $this->content_table t1
609 JOIN $this->content_table t2
610 ON t1.slug = t2.slug
611 AND t1.type = t2.type
612 AND t1.published = 1
613 AND t2.published = 0
614 SET
615 t1.name = t2.name,
616 t1.content = t2.content,
617 t1.modified_at = t2.modified_at,
618 t1.aspect = t2.aspect,
619 t1.meta = t2.meta,
620 t1.func_ref = t2.func_ref"
621 );
622 }*/
623
624 // Copy and minify user and npm JS
625 function publishJavaScript(){
626
627 $draftFolder = $this->Admin->micro_root_dir . 'mt/js/draft/';
628 $activeFolder = $this->Admin->micro_root_dir . 'mt/js/active/';
629 $origActiveFiles = $this->Admin->getDirectoryFileList($activeFolder);
630 $message = '';
631
632 // copy and minify user JS files (non-recursive)
633 $this->Admin->copyFolder($draftFolder, $activeFolder, 0, true, true, 0);
634
635 // Just copy already minified npm folders recursively
636 $this->Admin->copyFolder($draftFolder.'npm/', $activeFolder.'npm/');
637
638 // Clean up files that are not present in draft folder
639 foreach ($origActiveFiles as $relativePath){
640 if (!file_exists($draftFolder . $relativePath)){
641 wp_delete_file($activeFolder . $relativePath);
642 $message = $this->removeEmptyDirectories($activeFolder, $relativePath, $message);
643 }
644 }
645
646 }
647
648 function deleteDraftSnippets(){
649
650 global $wpdb;
651
652 // Delete all existing snippets from the content table
653 $wpdb->query(
654 "DELETE FROM $this->content_table WHERE published = 0"
655 );
656 }
657
658 /**
659 * @method void restoreSnippets(array &$snippets)
660 */
661 /**
662 * @method void restoreSnippets(array &$snippets)
663 */
664 function restoreSnippets($snippets, $isSerialised = true, $mergeWithExisting = false) {
665 global $wpdb;
666
667 if ($isSerialised) {
668 $snippets = unserialize($snippets);
669 }
670
671 if (is_array($snippets) && !empty($snippets)) {
672
673 $wpdb->query('START TRANSACTION');
674
675 try {
676 if (!$mergeWithExisting) {
677 $this->deleteDraftSnippets();
678 }
679
680 $insertPlaceholders = [];
681 $insertValues = [];
682
683 foreach ($snippets as $data) {
684 $insertPlaceholders[] = '(%d, %s, %s, %s, %s, %d, %s, %s, %s)';
685
686 array_push($insertValues,
687 0, // seq
688 isset($data['slug']) ? $data['slug'] : '',
689 isset($data['name']) ? $data['name'] : '',
690 'snippet',
691 isset($data['aspect']) ? $data['aspect'] : '',
692 0, // published
693 isset($data['content']) ? $data['content'] : '',
694 isset($data['meta']) ? $data['meta'] : '',
695 isset($data['func_ref']) ? $data['func_ref'] : ''
696 );
697 }
698
699 if (!empty($insertPlaceholders)) {
700 $insert_sql = "
701 INSERT INTO $this->content_table
702 (seq, slug, name, type, aspect, published, content, meta, func_ref)
703 VALUES " . implode(', ', $insertPlaceholders);
704
705 if ($mergeWithExisting) {
706 $insert_sql .= "
707 ON DUPLICATE KEY UPDATE
708 name = VALUES(name),
709 aspect = VALUES(aspect),
710 content = VALUES(content),
711 meta = VALUES(meta),
712 func_ref = VALUES(func_ref),
713 published = VALUES(published)";
714 }
715
716 $wpdb->query(
717 $wpdb->prepare(
718 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query string contains only % placeholders.
719 $insert_sql,
720 ...$insertValues
721 )
722 );
723 }
724
725 $wpdb->query('COMMIT');
726
727 } catch (\Exception $e) {
728 $wpdb->query('ROLLBACK');
729 throw $e;
730 }
731 }
732 }
733
734
735
736
737
738 /*function restoreSnippets($snippets, $isSerialised = true, $mergeWithExisting = false) {
739
740 global $wpdb;
741
742 // Deserialize the provided snippets
743 if ($isSerialised){
744 $snippets = unserialize($snippets);
745 }
746
747 if (is_array($snippets) && !empty($snippets)) {
748 // Start a transaction to ensure atomicity
749 $wpdb->query('START TRANSACTION');
750
751 try {
752
753 // Clear out the existing draft snippets
754 $this->deleteDraftSnippets();
755
756 // Prepare data for insertion
757 $insertValues = array();
758 $insertPlaceholders = array();
759
760 foreach ($snippets as $data) {
761 $insertPlaceholders[] = '(%s, %s, %s, %s, %s, %d, %s, %s, %s)';
762 $insertValues = array_merge($insertValues, [
763 0,
764 $data['slug'],
765 $data['name'],
766 'snippet',
767 $data['aspect'],
768 0, // we only backup/restore unpublished snippets (published ones are copied from unpublished)
769 $data['content'],
770 isset($data['meta']) ? $data['meta'] : '',
771 isset($data['func_ref']) ? $data['func_ref'] : ''
772 ]);
773 }
774
775 if (!empty($insertPlaceholders)) {
776 // Insert the restored snippets into the database
777 $wpdb->query(
778 $wpdb->prepare(
779 "INSERT INTO $this->content_table
780 (seq, slug, name, type, aspect, published, content, meta, func_ref)
781 VALUES " . implode(', ', $insertPlaceholders),
782 ...$insertValues
783 )
784 );
785 }
786
787 // Commit the transaction
788 $wpdb->query('COMMIT');
789
790 } catch (\Exception $e) {
791 // Rollback the transaction on error
792 $wpdb->query('ROLLBACK');
793 throw $e; // Re-throw the exception after rollback
794 }
795 }
796 }*/
797
798
799 function ajaxActions(){
800
801 // get a code snippet
802 if (isset($_GET['get_single_snippet'])) {
803 $this->getSingleSnippet(sanitize_text_field(wp_unslash($_GET["snippet_id"])));
804 }
805
806 // update an install
807 elseif (isset($_GET['update_npm_install'])) {
808 echo $this->adjustNPMInstall(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- generated UI markup, values escaped in builder
809 $this->Admin->jsonResponse(array('message' => 'done'));
810 }
811
812 // Update dependencies
813 elseif (isset($_GET['update_npm_dependencies'])) {
814 $this->updateNPMDependencies();
815 $this->Admin->jsonResponse(array('message' => 'done'));
816 }
817
818 elseif (isset($_POST['tailwind_actions'])) {
819 $this->tailwindAjaxActions();
820 }
821
822 // update mt_rich_text preference
823 $binaryPreferences = array(
824 'mt_rich_text',
825 'mt_rich_text_code',
826 'show_snippet_adv'
827 );
828 foreach ($binaryPreferences as $key){
829 if (isset($_GET[$key])) {
830 $this->Admin->savePreferences(array(
831 $key => intval($_GET[$key])
832 ));
833 wp_die();
834 }
835 }
836
837 }
838
839 function updateNPMDependencies(){
840
841 $npm_dependencies = isset($_POST['npm_dependencies'])
842 ? $_POST["npm_dependencies"] // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- structured package config, validated on install; nonce + capability gated
843 : array();
844 $npm_dependencies_in_use = isset($_POST['npm_dependencies_in_use'])
845 ? $_POST["npm_dependencies_in_use"] // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- structured package config, validated on install; nonce + capability gated
846 : array();
847
848 $this->Admin->savePreferences(
849 array(
850 'npm_dependencies' => (object) $npm_dependencies,
851 'npm_dependencies_in_use' => (object) $npm_dependencies_in_use
852 )
853 );
854 }
855
856 function removeEmptyDirectories($vendorDir, $addonDepPath, $message){
857
858 // check directories going backwards from the file, deleting any empty one
859 $pathParts = explode('/', $addonDepPath);
860 array_pop($pathParts); // remove the filename
861
862 while (!empty($pathParts)) {
863 $dirPath = $vendorDir . implode('/', $pathParts);
864 if (is_dir($dirPath) && count(scandir($dirPath)) <= 2) { // only . and .. remain
865 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- empty vendor subdir cleanup under micro-themes; WP_Filesystem not available in AJAX npm flows.
866 if (@rmdir($dirPath)) {
867 $message .= '<p>Empty directory removed: ' . implode('/', $pathParts) . '</p>';
868 } else {
869 $message .= '<p>Could not remove directory: ' . implode('/', $pathParts) . '</p>';
870 break; // if we can't remove this directory, we won't be able to remove parents
871 }
872 } else {
873 break; // directory not empty or doesn't exist, stop checking parents
874 //$message .= '<p>directory not empty or does not exist:' . implode('/', $pathParts) . '</p>';
875 }
876 array_pop($pathParts); // move up to parent directory
877 }
878
879 return $message;
880 }
881
882 function adjustNPMInstall(){
883
884 $vendorDir = $this->Admin->micro_root_dir . 'mt/js/draft/npm/';
885 $message = '';
886 $results = array();
887 $addonFiles = &$this->preferences['npm_addon_files'];
888 $saveAddonFiles = false;
889
890 if (!empty($_POST['packages']) && is_array($_POST['packages'])){
891 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- structured package descriptors, fields validated below before use
892 foreach(wp_unslash($_POST["packages"]) as $item){
893
894 if (empty($item['package'])){
895 continue;
896 }
897
898 $package = $item['package'];
899
900 // Uninstall
901 if (empty($item['install']) && !empty($item['local'])){
902 $local = $item['local'];
903 $file = $vendorDir . $local;
904 if (file_exists($file)){
905 if ( ! wp_delete_file( $file ) ){
906 $message.= '<p>Package could not be deleted: ' . $package . '</p>';
907 } else {
908 $message.= '<p>Package uninstalled: ' . $package . '</p>';
909 $results[$package]['isInstalled'] = 0;
910
911 // Some non-addon packages will be installed in a subdirectory
912 // e.g.@react-three/drei
913 $message = $this->removeEmptyDirectories(
914 $vendorDir, $local, $message
915 );
916
917 // Find all addon dependent file
918 foreach ($addonFiles as $addonDepPath => $addonArray){
919
920 //echo '$addonDepPath: <pre>'.print_r($addonArray, 1).'</pre>';
921
922 // If the addon references it
923 if (!empty($addonArray[$local])){
924
925 //echo 'Is there: <pre>'.$package.'</pre>';
926
927 // unset the entry
928 unset($addonFiles[$addonDepPath][$local]);
929
930 // If the file is not used by any other dependencies
931 if (empty($addonFiles[$addonDepPath])){
932
933 $addonFile = $vendorDir . $addonDepPath;
934
935 if (file_exists($addonFile)){
936 if ( ! wp_delete_file( $addonFile ) ){
937 $message.= '<p>Addon dep could not be deleted: ' . $addonDepPath . '</p>';
938 } else {
939 $message.= '<p>Addon dep successfully deleted: ' . $addonDepPath . '</p>';
940 }
941 }
942
943 // clean up any empty directories
944 $message = $this->removeEmptyDirectories(
945 $vendorDir, $addonDepPath, $message
946 );
947
948 unset($addonFiles[$addonDepPath]);
949
950 }
951
952 $saveAddonFiles = true;
953 } else {
954 //echo 'NOT there: <pre>'.$package.'</pre>';
955 }
956 }
957
958 /*if (!empty($item['isAddon'])){
959
960 }*/
961
962
963 }
964 } else {
965 $message.= '<p>Package already uninstalled: ' . $package . '</p>';
966 $results[$package]['isInstalled'] = 0;
967 }
968 }
969
970 // Install
971 else {
972 if (!empty($item['cdn']) && !empty($item['package'])){
973
974 // Use content if provided
975 if (isset($item['content'])){
976 $content = stripslashes($item['content']);
977 }
978
979 // Else copy from CDN
980 else {
981 $cdnUrl = 'https://cdn.jsdelivr.net/npm/' . $item['cdn'];
982 $response = wp_remote_get($cdnUrl, [
983 'timeout' => 15,
984 'redirection' => 5,
985 'headers' => [
986 'User-Agent' => 'WordPress/' . get_bloginfo('version'),
987 ],
988 ]);
989
990 if (is_wp_error($response)) {
991 error_log('CDN fetch error: ' . $response->get_error_message());
992 $message.= '<p>Error fetching package from CDN: ' . $cdnUrl . '</p>';
993 } else {
994 $content = wp_remote_retrieve_body($response);
995 }
996 }
997
998 // Write to file if we have content
999 if (!empty($content)) {
1000 $local = $item['local']; // str_replace('@', '', $item['package']) . '.js';
1001 $this->Admin->write_file($vendorDir . $local, $content);
1002 $message.= '<p>File installed locally: ' . $local . '</p>';
1003 $results[$package]['isInstalled'] = 1;
1004 $results[$package]['local'] = $local;
1005
1006 // Log files used for addon
1007 if (!empty($item['isAddon'])){
1008 $addonFiles[$local][$item['rootName'] . '.js'] = 1;
1009 $saveAddonFiles = true;
1010 }
1011
1012 } else {
1013 $message.= '<p>Content was empty.</p>';
1014 }
1015
1016 }
1017 }
1018 }
1019 }
1020
1021 if ($saveAddonFiles){
1022 $this->Admin->savePreferences(array('npm_addon_files' => $addonFiles));
1023 }
1024
1025 return wp_json_encode(array(
1026 'addonFiles' => $addonFiles,
1027 'results' => $results,
1028 'message' => $message,
1029 //'packages' => $_POST['packages']
1030 ));
1031 }
1032
1033
1034 // handle Tailwind ajax actions
1035 function tailwindAjaxActions(){
1036
1037 //wp_die( '<pre> we got actions' . print_r($GLOBALS, 1 ) . '</pre>');
1038
1039 // update single page class cache
1040 if (isset($_POST['update_single_page_classes'])) {
1041 $this->updateTailwindClasses(
1042 sanitize_title(wp_unslash($_POST["single_page_slug"])),
1043 wp_unslash($_POST["single_page_classes"]) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- user-authored Tailwind class list, cached verbatim; nonce + capability gated
1044 );
1045 }
1046
1047 // update single page style cache
1048 if (isset($_POST['update_single_page_styles'])) {
1049 $this->updateTailwindStyles(
1050 sanitize_title(wp_unslash($_POST["single_page_slug"])),
1051 wp_unslash($_POST["single_page_styles"]) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- user-authored CSS, sanitizers would corrupt it; nonce + capability gated
1052 );
1053 }
1054
1055 // update site-wide styles cache
1056 if (isset($_POST['update_site_wide_styles'])) {
1057 $this->updateTailwindStyles(
1058 sanitize_title(wp_unslash($_POST["site_wide_slug"])),
1059 wp_unslash($_POST["site_wide_styles"]) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- user-authored CSS, sanitizers would corrupt it; nonce + capability gated
1060 );
1061 }
1062
1063 // get a list of side-wide tailwind classes
1064 if (isset($_POST['get_site_wide_classes'])) {
1065
1066 $this->Admin->jsonResponse(
1067 $this->getTailwindClasses(
1068 sanitize_title(wp_unslash($_POST["site_wide_slug"])),
1069 isset($_POST['from_cache']),
1070 ),
1071 false
1072 );
1073 }
1074
1075 wp_die();
1076
1077 }
1078
1079 // update the Tailwind CSS styles
1080 function updateTailwindStyles($slug, $content = ''){
1081
1082 $this->Admin->write_file(
1083 $this->tailwindStyleCacheDir . $slug . '.css', $content, true
1084 );
1085
1086 $this->Admin->savePreferences(array(
1087 'tailwind_num_saves' => ($this->preferences['tailwind_num_saves'] + 1),
1088 ));
1089 }
1090
1091 // Update the tailwind classes and styles for a specific page and / or site-wide
1092 function updateTailwindClasses($slug, $content = '', $rebuildCache = false){
1093
1094 $dir = $this->tailwindClassCacheDir;
1095 $siteWide = $slug === 'site-wide';
1096 $path = $dir . $slug . '.json';
1097
1098 if ($siteWide && $rebuildCache){
1099 $this->refreshSiteWideTailwindCache($path);
1100 }
1101
1102 else {
1103
1104 //wp_die( '<pre>' . print_r([$path, $content], 1 ) . '</pre>');
1105
1106 $this->Admin->write_file($path, $content);
1107 }
1108
1109 }
1110
1111 function refreshSiteWideTailwindCache($path = null){
1112
1113 $dir = $this->tailwindClassCacheDir;
1114 $data = array();
1115
1116 foreach (new \DirectoryIterator($dir) as $fileInfo) {
1117
1118 if (!$fileInfo->isDot() && $fileInfo->getFilename() !== 'site-wide.json' ) {
1119
1120 $classArray = json_decode(file_get_contents($dir . $fileInfo), true);
1121
1122 //echo "Test all <pre>" . print_r([$dir . $fileInfo, $classArray], 1) . "\n</pre>";
1123
1124 if (is_array($classArray) && count($classArray)){
1125
1126 foreach ($classArray as $className){
1127
1128 /*$value = isset($data[$className]) ? $data[$className]+1 : 1;
1129 $data[$className] = $value;*/
1130 // for now, we're not using counts for a perf boost, and have 1 means string comp is easier
1131 $data[$className] = 1;
1132 }
1133 }
1134 };
1135 }
1136
1137 $this->Admin->write_file($path, wp_json_encode($data));
1138
1139 return $data;
1140 }
1141
1142 function getTailwindClasses($slug, $fromCache = true){
1143
1144 $dir = $this->tailwindClassCacheDir;
1145 $siteWide = $slug === 'site-wide';
1146 $path = $dir . $slug . '.json';
1147 $data = '';
1148
1149 //wp_die('file_exists <pre>' . print_r([$path], 1) . '</pre>');
1150
1151 if ($siteWide && !$fromCache) {
1152 $data = $this->refreshSiteWideTailwindCache($path);
1153 } elseif (file_exists($path)){
1154 $data = json_decode(file_get_contents($path), true);
1155 }
1156
1157 return $data;
1158
1159 }
1160
1161 function groupContextMenu(){
1162
1163 $html = '';
1164 $types = array(
1165 'current' => 'Current Tabs',
1166 'default' => 'Default Tabs',
1167 );
1168
1169 $html.= '
1170 <div id="group-management-tabs" class="query-tabs">';
1171
1172 foreach($types as $key => $title){
1173 $active = $key === 'default' ? ' active' : '';
1174 $html.= '<span class="mt-tab group-management-tab group-management-tab-'.$key.$active.'" rel="'.$key.'">'.$title.'</span>';
1175 }
1176
1177 $html.= '
1178 </div>';
1179
1180 foreach($types as $key => $title){
1181 $show= $key === 'default' ? ' show' : '';
1182 $addTab = esc_attr__('Add tab', 'microthemer');
1183 $html.= '
1184 <div class="group-management-field group-management-field-'.$key.$show.' hidden">
1185 <ul class="html-tabs-list-'.$key.'"></ul>
1186 <div class="add-tab-wrap">
1187 '.$this->Admin->iconFont('add', array(
1188 'class' => 'group-management-add-tab group-tab-action-icon',
1189 'adjacentText' => array(
1190 'text' => $addTab,
1191 'class' => 'mti-text group-management-add-tab group-tab-action-icon'
1192 ),
1193 )).
1194 '<span class="mt-management-spacer"></span>'.
1195 $this->Admin->iconFont('undo', array(
1196 'class' => 'group-management-reset-tabs group-tab-action-icon',
1197 'data-group-tab-action' => 'reset',
1198 'adjacentText' => array(
1199 'text' => esc_html__('Reset default tabs', 'microthemer'),
1200 'class' => 'mti-text group-management-reset-tabs group-tab-action-icon',
1201 'data-group-tab-action' => 'reset',
1202 ),
1203 )).'
1204 </div>
1205
1206 </div>';
1207 }
1208
1209 // phpcs:disable WordPress.Security.EscapeOutput
1210 echo $this->Admin->context_menu_content(array(
1211 'base_key' => 'group-html',
1212 'title' => esc_html__('Amender tabs', 'microthemer'),
1213 'sections' => array(
1214 $html
1215 )
1216 ));
1217 // phpcs:enable WordPress.Security.EscapeOutput
1218
1219 }
1220
1221 function getSnippetsOfType($or = array(), $and = array(), $columns = null){
1222 return $this->getSnippets(
1223 -1, 0, OBJECT, $columns, array(
1224 'OR' => $or,
1225 'AND' => $and
1226 )
1227 );
1228 }
1229
1230 /*function getLoadingHTMLSnippets(){
1231 return $this->getSnippetsOfType(array(), array(
1232 array('aspect', 'html'),
1233 ), 'slug, content');
1234 }*/
1235
1236 function storeCSSSnippets(&$asset){
1237 $snippets = $this->getSnippetsOfType(array(
1238 array('aspect', 'css'),
1239 ));
1240 foreach ($snippets as $item){
1241 $asset['snippets'][$item->aspect][$item->slug] = $item->content;
1242 }
1243 }
1244
1245 }