PluginProbe
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits / 3.2.2
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits v3.2.2
3.2.2 3.2.3 3.2.1 3.2.0 3.1.9 3.1.8 3.1.7 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.9 trunk 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.3 1.1.4 1.1.5 All 174 releases
← All changes | inc/classes/template-library-cache.php +427 -36 3.1.9 → 3.2.2 View file →
@@ -20,8 +20,10 @@
20 20 private static $instance = null;
21 21 private $cache_dir;
22 22 private $cache_expiry;
23 23 private $config;
24 + private $initialized = false;
25 + private $cache_dir_ready = false;
24 26
25 27 public function __construct()
26 28 {
27 29 $upload_dir = wp_upload_dir();
@@ -28,15 +30,27 @@
28 30 $basedir = !empty($upload_dir['basedir']) ? $upload_dir['basedir'] : '';
29 31 $this->cache_dir = $basedir . '/master_addons/templates-library/';
30 32 $this->cache_expiry = apply_filters('jltma_cache_expiry', 6 * HOUR_IN_SECONDS); // 6 hours default, filterable
31 33
32 - // Initialize config safely
33 - add_action('init', [$this, 'init_config'], 20);
34 - add_action('init', [$this, 'init'], 25);
34 + // The class is instantiated lazily (first get_instance() call), which may
35 + // happen before or after `init` has run. Hooking `init` after it already
36 + // fired would silently skip bootstrapping, leaving $config empty and the
37 + // cache directory missing, so bootstrap immediately in that case.
38 + if (did_action('init')) {
39 + $this->init_config();
40 + $this->init();
41 + } else {
42 + add_action('init', [$this, 'init_config'], 20);
43 + add_action('init', [$this, 'init'], 25);
44 + }
35 45 }
36 46
37 47 public function init_config()
38 48 {
49 + if (!empty($this->config)) {
50 + return $this->config;
51 + }
52 +
39 53 // Initialize config after templates system is ready
40 54 if (function_exists('MasterAddons\\Inc\\Admin\\Templates\\master_addons_templates')) {
41 55 $templates_instance = Templates\master_addons_templates();
42 56 if ($templates_instance && isset($templates_instance->config)) {
@@ -43,18 +57,36 @@
43 57 $this->config = $templates_instance->config->get('api');
44 58 }
45 59 }
46 60
61 + return $this->config;
47 62 }
48 63
64 + /**
65 + * Resolve the API config on demand.
66 + *
67 + * The templates system may register itself after this class is built, so
68 + * retry the lookup instead of assuming init_config() succeeded.
69 + */
70 + private function get_api_config()
71 + {
72 + if (empty($this->config)) {
73 + $this->init_config();
74 + }
75 +
76 + return $this->config;
77 + }
78 +
49 79 public function init()
50 80 {
51 - // Clean up old incorrect template-kits folder if it exists
52 - $this->cleanup_incorrect_folders();
53 -
54 - // Ensure cache directory exists
55 - $this->ensure_cache_directory();
81 + if ($this->initialized) {
82 + return;
83 + }
84 + $this->initialized = true;
56 85
86 + // Directory cleanup/creation is deferred to the first actual cache access
87 + // (see maybe_bootstrap_cache_dir()) so a plain page load does no disk work.
88 +
57 89 // Schedule cache updates
58 90 add_action('wp', [$this, 'schedule_cache_updates']);
59 91 add_action('jltma_templates_cache_update', [$this, 'update_templates_cache']);
60 92
@@ -74,8 +106,15 @@
74 106 * Ensure cache directory exists with proper structure
75 107 */
76 108 private function ensure_cache_directory()
77 109 {
110 + // Nothing is written to disk unless the site opted back in, so creating
111 + // the tree would leave a client with six empty folders under uploads
112 + // that never fill up and that the cleanup routine then has to remove.
113 + if (!$this->local_cache_enabled()) {
114 + return false;
115 + }
116 +
78 117 // Check if uploads directory is writable
79 118 if (!$this->is_uploads_writable()) {
80 119 return false;
81 120 }
@@ -86,9 +125,9 @@
86 125 }
87 126
88 127 // Create subdirectories for different template types
89 128 // Removed 'template-kits' as it should be in its own separate folder
90 - $subdirs = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
129 + $subdirs = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers', 'master_widgets'];
91 130 foreach ($subdirs as $subdir) {
92 131 wp_mkdir_p($this->cache_dir . $subdir . '/');
93 132 wp_mkdir_p($this->cache_dir . $subdir . '/categories/');
94 133 wp_mkdir_p($this->cache_dir . $subdir . '/keywords/');
@@ -188,8 +227,12 @@
188 227 * Schedule cache update events via Background_Task_Manager
189 228 */
190 229 public function schedule_cache_updates()
191 230 {
231 + if (!$this->local_cache_enabled()) {
232 + return;
233 + }
234 +
192 235 Background_Task_Manager::get_instance()->schedule_recurring(
193 236 'jltma_templates_cache_update',
194 237 12 * HOUR_IN_SECONDS
195 238 );
@@ -197,9 +240,9 @@
197 240
198 241 /**
199 242 * Get cached templates for specific tab
200 243 */
201 - public function get_cached_templates($tab, $force_refresh = false)
244 + public function get_cached_templates($tab, $force_refresh = false, $per_page = 0)
202 245 {
203 246 // Try transient cache first if file cache is not available
204 247 if (!$this->is_file_cache_available()) {
205 248 return $this->get_transient_cached_templates($tab, $force_refresh);
@@ -223,9 +266,9 @@
223 266 }
224 267 }
225 268
226 269 // Fetch fresh data from remote API
227 - $fresh_data = $this->fetch_remote_templates($tab);
270 + $fresh_data = $this->fetch_remote_templates($tab, $per_page);
228 271
229 272 if ($fresh_data !== false) {
230 273 // Update thumbnail URLs to use cache folder first
231 274 foreach ($fresh_data as &$template) {
@@ -353,18 +396,217 @@
353 396 $this->write_cache_meta($cache_meta_file);
354 397 }
355 398
356 399 /**
400 + * Get the cached widgets catalog.
401 + *
402 + * The Widgets Library has a single flat endpoint rather than one per tab,
403 + * so it gets its own accessor instead of a $tab argument, but it lands in
404 + * the same uploads/master_addons/templates-library tree as the templates
405 + * and is cleared by the same clear_cache().
406 + */
407 + public function get_cached_widgets($force_refresh = false)
408 + {
409 + if (!$this->is_file_cache_available()) {
410 + return $this->get_transient_cached_widgets($force_refresh);
411 + }
412 +
413 + $cache_file = $this->cache_dir . 'master_widgets/widgets/widgets.json';
414 + $meta_file = $this->cache_dir . 'master_widgets/widgets/meta.json';
415 +
416 + if (!$force_refresh && $this->is_cache_valid($meta_file)) {
417 + $cached_data = $this->read_cache_file($cache_file);
418 + if ($cached_data !== false) {
419 + return $this->localize_widget_images($cached_data);
420 + }
421 + }
422 +
423 + $fresh_data = $this->fetch_remote_widgets($force_refresh);
424 +
425 + if ($fresh_data !== false) {
426 + $this->write_cache_file($cache_file, $fresh_data);
427 + $this->write_cache_meta($meta_file);
428 + $this->cache_widget_images($fresh_data);
429 +
430 + return $this->localize_widget_images($fresh_data);
431 + }
432 +
433 + // Serve the expired copy rather than an empty grid when the hub is down.
434 + $fallback_data = $this->read_cache_file($cache_file);
435 +
436 + return $fallback_data === false ? false : $this->localize_widget_images($fallback_data);
437 + }
438 +
439 + /**
440 + * Transient fallback, for installs whose uploads directory is not writable.
441 + */
442 + private function get_transient_cached_widgets($force_refresh = false)
443 + {
444 + $transient_key = 'jltma_widgets_catalog';
445 + $meta_transient_key = 'jltma_widgets_catalog_meta';
446 +
447 + if (!$force_refresh) {
448 + $cached_meta = get_transient($meta_transient_key);
449 + if ($cached_meta && (time() - $cached_meta['timestamp']) < $this->cache_expiry) {
450 + $cached_data = get_transient($transient_key);
451 + if ($cached_data !== false) {
452 + return $cached_data;
453 + }
454 + }
455 + }
456 +
457 + $fresh_data = $this->fetch_remote_widgets($force_refresh);
458 +
459 + if ($fresh_data !== false) {
460 + set_transient($transient_key, $fresh_data, $this->cache_expiry);
461 + set_transient($meta_transient_key, ['timestamp' => time()], $this->cache_expiry);
462 +
463 + return $fresh_data;
464 + }
465 +
466 + return get_transient($transient_key);
467 + }
468 +
469 + /**
470 + * Fetch the widgets catalog from the remote API
471 + */
472 + private function fetch_remote_widgets($force_refresh = false)
473 + {
474 + $config = $this->get_api_config();
475 +
476 + if (empty($config) || empty($config['endpoints']['widgets'])) {
477 + return false;
478 + }
479 +
480 + $api_url = $this->remote_url($config['base'] . $config['path'] . $config['endpoints']['widgets'], $force_refresh);
481 +
482 + $response = wp_remote_get($api_url, [
483 + 'timeout' => 60,
484 + 'sslverify' => false,
485 + 'headers' => [
486 + 'User-Agent' => 'Master Addons Widgets Cache/' . JLTMA_VER
487 + ]
488 + ]);
489 +
490 + if (is_wp_error($response)) {
491 + return false;
492 + }
493 +
494 + $data = json_decode(wp_remote_retrieve_body($response), true);
495 +
496 + if (json_last_error() !== JSON_ERROR_NONE || empty($data['success']) || !isset($data['widgets'])) {
497 + return false;
498 + }
499 +
500 + return $data['widgets'];
501 + }
502 +
503 + /**
504 + * Pull widget thumbnails down beside the catalog
505 + */
506 + private function cache_widget_images($widgets)
507 + {
508 + if (!is_array($widgets)) {
509 + return;
510 + }
511 +
512 + foreach ($widgets as $widget) {
513 + if (empty($widget['widget_id'])) {
514 + continue;
515 + }
516 +
517 + if (!empty($widget['thumbnail'])) {
518 + $this->cache_image($widget['thumbnail'], 'master_widgets', "widget-{$widget['widget_id']}-thumb");
519 + }
520 + }
521 + }
522 +
523 + /**
524 + * Swap remote thumbnail URLs for local copies where one has been pulled down.
525 + */
526 + private function localize_widget_images($widgets)
527 + {
528 + if (!is_array($widgets)) {
529 + return $widgets;
530 + }
531 +
532 + $upload_dir = wp_upload_dir();
533 +
534 + foreach ($widgets as &$widget) {
535 + if (empty($widget['widget_id']) || empty($widget['thumbnail'])) {
536 + continue;
537 + }
538 +
539 + $extension = pathinfo($widget['thumbnail'], PATHINFO_EXTENSION);
540 + if (empty($extension)) {
541 + $extension = 'jpg';
542 + }
543 +
544 + $local_file = $this->cache_dir . "master_widgets/images/widget-{$widget['widget_id']}-thumb.{$extension}";
545 +
546 + if (!file_exists($local_file)) {
547 + continue;
548 + }
549 +
550 + $local_url = $upload_dir['baseurl'] . str_replace($upload_dir['basedir'], '', $local_file);
551 +
552 + // The hub sends the same image as preview unless the entry has a
553 + // laid-out preview page, in which case preview_url carries the
554 + // permalink and must stay pointing at the hub.
555 + $preview_matches = !empty($widget['preview']) && $widget['preview'] === $widget['thumbnail'];
556 +
557 + $widget['thumbnail'] = $local_url;
558 +
559 + if ($preview_matches) {
560 + $widget['preview'] = $local_url;
561 + }
562 + }
563 +
564 + return $widgets;
565 + }
566 +
567 + /**
568 + * Add the cache-busting parameter for an explicit refresh.
569 + *
570 + * The library API serves its list endpoints from a static file on the
571 + * server, rebuilt on a schedule. A plain request is answered from that
572 + * file, so clearing the local cache and re-requesting returns the same
573 + * stale listing — which is what made "Refresh from server" appear to do
574 + * nothing. Any query parameter makes the server answer live, so only an
575 + * explicit refresh sends one; routine reads still hit the static file and
576 + * leave the server's database alone.
577 + *
578 + * @param string $url
579 + * @param bool $force_refresh
580 + * @return string
581 + */
582 + private function remote_url($url, $force_refresh)
583 + {
584 + return $force_refresh ? add_query_arg('force_refresh', '1', $url) : $url;
585 + }
586 +
587 + /**
357 588 * Fetch templates from remote API
358 589 */
359 - private function fetch_remote_templates($tab)
590 + private function fetch_remote_templates($tab, $per_page = 0)
360 591 {
361 - if (empty($this->config)) {
592 + $config = $this->get_api_config();
593 +
594 + pretty_log('$config', $config);
595 +
596 + if (empty($config) || empty($config['endpoints']['templates'])) {
362 597 return false;
363 598 }
364 599
365 - $api_url = $this->config['base'] . $this->config['path'] . $this->config['endpoints']['templates'] . $tab;
600 + $api_url = $config['base'] . $config['path'] . $config['endpoints']['templates'] . $tab;
366 601
602 + // Without this the listing falls back to the API's own default page
603 + // size, which is deliberately small — the popup picker was showing six
604 + // templates and had nothing left to scroll through.
605 + if ($per_page > 0) {
606 + $api_url = add_query_arg(['page' => 1, 'per_page' => (int) $per_page], $api_url);
607 + }
608 +
367 609 $response = wp_remote_get($api_url, [
368 610 'timeout' => 60,
369 611 'sslverify' => false,
370 612 'headers' => [
@@ -370,8 +612,11 @@
370 612 'headers' => [
371 613 'User-Agent' => 'Master Addons Templates Cache/' . JLTMA_VER
372 614 ]
373 615 ]);
616 +
617 + pretty_log('$response', $response);
618 +
374 619 if (is_wp_error($response)) {
375 620 return false;
376 621 }
377 622
@@ -381,21 +626,73 @@
381 626 if (json_last_error() !== JSON_ERROR_NONE || !isset($data['success']) || !$data['success']) {
382 627 return false;
383 628 }
384 629
385 - return isset($data['templates']) ? $data['templates'] : [];
630 + return $this->expand_thumbnails(
631 + isset($data['templates']) ? $data['templates'] : [],
632 + isset($data['thumb_base']) ? $data['thumb_base'] : ''
633 + );
386 634 }
387 635
388 636 /**
637 + * Put the uploads URL back on the front of each thumbnail.
638 + *
639 + * The paged listing sends thumb_base once and each row's path relative to
640 + * it, which keeps the response small. Consumers that read this class
641 + * directly — the popup builder's template picker among them — use
642 + * 'thumbnail' as an img src, so they were rendering "2025/10/sale-shoes.png"
643 + * and showing a broken image. Expanding here covers every caller of this
644 + * class rather than each one repeating it.
645 + *
646 + * @param array $templates
647 + * @param string $thumb_base Absolute uploads URL, empty on the legacy shape.
648 + * @return array
649 + */
650 + private function expand_thumbnails($templates, $thumb_base)
651 + {
652 + if (empty($thumb_base) || !is_array($templates)) {
653 + return $templates;
654 + }
655 +
656 + $base = trailingslashit($thumb_base);
657 +
658 + foreach ($templates as $i => $template) {
659 + if (!is_array($template)) {
660 + continue;
661 + }
662 +
663 + foreach (['thumbnail', 'preview'] as $key) {
664 + if (empty($template[$key]) || !is_string($template[$key])) {
665 + continue;
666 + }
667 + // Already absolute on the legacy shape; leave those alone.
668 + if (preg_match('#^(https?:)?//#', $template[$key])) {
669 + continue;
670 + }
671 + $templates[$i][$key] = $base . ltrim($template[$key], '/');
672 + }
673 +
674 + // The paged shape omits 'preview'; it is the same image.
675 + if (empty($templates[$i]['preview']) && !empty($templates[$i]['thumbnail'])) {
676 + $templates[$i]['preview'] = $templates[$i]['thumbnail'];
677 + }
678 + }
679 +
680 + return $templates;
681 + }
682 +
683 + /**
389 684 * Fetch categories from remote API
390 685 */
391 686 private function fetch_remote_categories($tab)
392 687 {
393 - if (empty($this->config)) {
688 + $config = $this->get_api_config();
689 +
690 + if (empty($config) || empty($config['endpoints']['categories'])) {
394 691 return false;
395 692 }
396 693
397 - $api_url = $this->config['base'] . $this->config['path'] . $this->config['endpoints']['categories'] . $tab;
694 + $api_url = $config['base'] . $config['path'] . $config['endpoints']['categories'] . $tab;
398 695
399 696 $response = wp_remote_get($api_url, [
400 697 'timeout' => 60,
401 698 'sslverify' => false
@@ -419,13 +716,15 @@
419 716 * Fetch keywords from remote API
420 717 */
421 718 private function fetch_remote_keywords($tab)
422 719 {
423 - if (empty($this->config)) {
720 + $config = $this->get_api_config();
721 +
722 + if (empty($config) || empty($config['endpoints']['keywords'])) {
424 723 return false;
425 724 }
426 725
427 - $api_url = $this->config['base'] . $this->config['path'] . $this->config['endpoints']['keywords'] . $tab;
726 + $api_url = $config['base'] . $config['path'] . $config['endpoints']['keywords'] . $tab;
428 727
429 728 $response = wp_remote_get($api_url, [
430 729 'timeout' => 60,
431 730 'sslverify' => false
@@ -515,8 +814,15 @@
515 814 * Update templates cache (scheduled event)
516 815 */
517 816 public function update_templates_cache()
518 817 {
818 + // This job exists to refresh the local mirror. With no mirror there is
819 + // nothing to refresh, and running it anyway means re-downloading every
820 + // listing on a schedule — which is what exhausted memory here before.
821 + if (!$this->local_cache_enabled()) {
822 + return;
823 + }
824 +
519 825 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
520 826 $btm = Background_Task_Manager::get_instance();
521 827
522 828 foreach ($template_types as $tab) {
@@ -533,8 +839,18 @@
533 839 // Individual tab failure logged by execute_with_retry; continue with others
534 840 }
535 841 }
536 842
843 + // The widgets catalog is a single flat endpoint, so it sits outside the
844 + // per-tab loop.
845 + try {
846 + $btm->execute_with_retry(function () {
847 + $this->get_cached_widgets(true);
848 + }, 3, 'widgets_cache_sync');
849 + } catch (\Exception $e) {
850 + // Logged by execute_with_retry.
851 + }
852 +
537 853 // Clean up old cache files
538 854 $this->cleanup_old_cache();
539 855
540 856 // Update last cache time
@@ -543,10 +859,29 @@
543 859
544 860 /**
545 861 * Check if cache is valid
546 862 */
863 + /**
864 + * Whether this site may keep a local copy of the remote library on disk.
865 + *
866 + * Off by default: client sites read everything from the remote API, which
867 + * is CDN-cached, so a local mirror only costs them disk and write errors.
868 + * el.master-addons.com itself turns this back on (it IS the source), and
869 + * any site can opt in with:
870 + *
871 + * add_filter('jltma_local_template_cache', '__return_true');
872 + */
873 + private function local_cache_enabled()
874 + {
875 + return (bool) apply_filters('jltma_local_template_cache', false);
876 + }
877 +
547 878 private function is_cache_valid($meta_file)
548 879 {
880 + if (!$this->local_cache_enabled()) {
881 + return false;
882 + }
883 +
549 884 if (!file_exists($meta_file)) {
550 885 return false;
551 886 }
552 887
@@ -587,8 +922,12 @@
587 922 * @return bool
588 923 */
589 924 private function fs_put_contents($file, $contents)
590 925 {
926 + if (!$this->local_cache_enabled()) {
927 + return false;
928 + }
929 +
591 930 global $wp_filesystem;
592 931 if (empty($wp_filesystem)) {
593 932 require_once ABSPATH . 'wp-admin/includes/file.php';
594 933 WP_Filesystem();
@@ -800,13 +1139,22 @@
800 1139 if (!file_exists($dir)) {
801 1140 return;
802 1141 }
803 1142
804 - $files = glob($dir . '*', GLOB_MARK);
1143 + // glob() skips dotfiles, so the cache directories' own .htaccess (and a
1144 + // stray .DS_Store) used to survive and every rmdir below then failed
1145 + // with "Directory not empty", filling debug.log with warnings and
1146 + // leaving the tree behind. Match dot entries too, minus . and ..
1147 + $files = array_merge(
1148 + glob($dir . '*', GLOB_MARK) ?: array(),
1149 + array_diff(glob($dir . '.*', GLOB_MARK) ?: array(), array($dir . './', $dir . '../'))
1150 + );
805 1151 foreach ($files as $file) {
806 1152 if (is_dir($file)) {
807 1153 $this->delete_directory_contents($file);
808 - rmdir($file);
1154 + // Suppressed: a directory the caller cannot remove is not worth
1155 + // a warning, and the sweep should continue regardless.
1156 + @rmdir($file); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
809 1157 } else {
810 1158 wp_delete_file($file);
811 1159 }
812 1160 }
@@ -870,23 +1218,26 @@
870 1218 // Normalize kit name for filename
871 1219 $kit_slug = sanitize_title($kit_name);
872 1220 $template_slug = sanitize_title($template_name);
873 1221
874 - // Check cache directory first (templates-library images)
875 - $cache_image_dir = $this->cache_dir . 'master_section/images/';
876 - $cached_file_patterns = [
877 - "{$kit_slug}-{$template_slug}.jpg",
878 - "{$kit_slug}-{$template_slug}.png",
879 - "{$kit_slug}.jpg",
880 - "{$kit_slug}.png"
881 - ];
1222 + // Without a local mirror there is nothing on disk to look for, and the
1223 + // stat calls below would just cost I/O on every template in the grid.
1224 + if ($this->local_cache_enabled()) {
1225 + $cache_image_dir = $this->cache_dir . 'master_section/images/';
1226 + $cached_file_patterns = [
1227 + "{$kit_slug}-{$template_slug}.jpg",
1228 + "{$kit_slug}-{$template_slug}.png",
1229 + "{$kit_slug}.jpg",
1230 + "{$kit_slug}.png"
1231 + ];
882 1232
883 - foreach ($cached_file_patterns as $pattern) {
884 - $cached_file = $cache_image_dir . $pattern;
885 - if (file_exists($cached_file)) {
886 - $upload_dir = wp_upload_dir();
887 - $relative_path = str_replace($upload_dir['basedir'], '', $cached_file);
888 - return $upload_dir['baseurl'] . $relative_path;
1233 + foreach ($cached_file_patterns as $pattern) {
1234 + $cached_file = $cache_image_dir . $pattern;
1235 + if (file_exists($cached_file)) {
1236 + $upload_dir = wp_upload_dir();
1237 + $relative_path = str_replace($upload_dir['basedir'], '', $cached_file);
1238 + return $upload_dir['baseurl'] . $relative_path;
1239 + }
889 1240 }
890 1241 }
891 1242
892 1243 // If original URL provided, return it
@@ -893,9 +1244,17 @@
893 1244 if ($original_url) {
894 1245 return $original_url;
895 1246 }
896 1247
897 - // Generate expected thumbnail URL from master-addons.com
1248 + // Last resort: guess the published thumbnail URL from the kit name.
1249 + // Callers that only know the template title pass an empty $kit_name,
1250 + // which used to produce ".../templates-kit/-v1/<title>.jpg" — a URL
1251 + // that cannot exist and 404s for every template on the screen. With no
1252 + // kit to build a path from there is nothing to guess, so say so.
1253 + if ('' === $kit_slug) {
1254 + return '';
1255 + }
1256 +
898 1257 $kit_version = $this->get_kit_version($kit_name);
899 1258 return "https://master-addons.com/templates-kit/{$kit_slug}{$kit_version}/{$template_slug}.jpg";
900 1259 }
901 1260
@@ -916,8 +1275,15 @@
916 1275 return $version_patterns[$kit_slug] ?? '-v1';
917 1276 }
918 1277
919 1278 /**
1279 + * Get only the cached templates count.
1280 + *
1281 + * Lightweight counterpart of get_cache_stats(): skips directory size
1282 + * calculation and kit manifest parsing, so it is safe to call on demand
1283 + * (e.g. when the templates modal is opened).
1284 + */
1285 + /**
920 1286 * Get cache statistics
921 1287 */
922 1288 public function get_cache_stats()
923 1289 {
@@ -1018,9 +1384,34 @@
1018 1384 * Check if file cache is available
1019 1385 */
1020 1386 private function is_file_cache_available()
1021 1387 {
1388 + $this->maybe_bootstrap_cache_dir();
1389 +
1022 1390 return file_exists($this->cache_dir) && is_writable($this->cache_dir);
1391 + }
1392 +
1393 + /**
1394 + * Create/clean the cache directory the first time the cache is touched.
1395 + *
1396 + * Runs once per request, and only when a cache read/write actually happens.
1397 + */
1398 + private function maybe_bootstrap_cache_dir()
1399 + {
1400 + if ($this->cache_dir_ready) {
1401 + return;
1402 + }
1403 + $this->cache_dir_ready = true;
1404 +
1405 + if (!$this->local_cache_enabled()) {
1406 + return;
1407 + }
1408 +
1409 + // Clean up old incorrect template-kits folders if they exist
1410 + $this->cleanup_incorrect_folders();
1411 +
1412 + // Ensure cache directory exists
1413 + $this->ensure_cache_directory();
1023 1414 }
1024 1415
1025 1416 /**
1026 1417 * Get cached templates using transients (fallback method)