PluginProbe
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits / trunk
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits vtrunk
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
master-addons / inc / classes / template-library-cache.php

template-library-cache.php in Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits trunk, at inc/classes/template-library-cache.php

1,616 lines 53.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace MasterAddons\Inc\Classes;
4
5 use MasterAddons\Inc\Admin\Templates;
6
7 if (!defined('ABSPATH')) {
8 exit;
9 }
10
11 // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.WP.AlternativeFunctions.file_system_operations_is_writable, WordPress.WP.AlternativeFunctions.rename_rename -- Local template-library cache directory housekeeping (under wp-content/uploads); native PHP filesystem calls are appropriate here and WP_Filesystem credential prompts are not desirable for background cache operations.
12
13 /**
14 * Template Library Cache
15 * Provides file-based caching with scheduled updates for template import functionality
16 */
17 class Template_Library_Cache
18 {
19
20 private static $instance = null;
21 private $cache_dir;
22 private $cache_expiry;
23 private $config;
24 private $initialized = false;
25 private $cache_dir_ready = false;
26
27 public function __construct()
28 {
29 $upload_dir = wp_upload_dir();
30 $basedir = !empty($upload_dir['basedir']) ? $upload_dir['basedir'] : '';
31 $this->cache_dir = $basedir . '/master_addons/templates-library/';
32 $this->cache_expiry = apply_filters('jltma_cache_expiry', 6 * HOUR_IN_SECONDS); // 6 hours default, filterable
33
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 }
45 }
46
47 public function init_config()
48 {
49 if (!empty($this->config)) {
50 return $this->config;
51 }
52
53 // Initialize config after templates system is ready
54 if (function_exists('MasterAddons\\Inc\\Admin\\Templates\\master_addons_templates')) {
55 $templates_instance = Templates\master_addons_templates();
56 if ($templates_instance && isset($templates_instance->config)) {
57 $this->config = $templates_instance->config->get('api');
58 }
59 }
60
61 return $this->config;
62 }
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
79 public function init()
80 {
81 if ($this->initialized) {
82 return;
83 }
84 $this->initialized = true;
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
89 // Schedule cache updates
90 add_action('wp', [$this, 'schedule_cache_updates']);
91 add_action('jltma_templates_cache_update', [$this, 'update_templates_cache']);
92
93 // Admin hooks
94 add_action('admin_init', [$this, 'maybe_clear_cache']);
95
96 // Performance optimizations
97 add_action('wp_ajax_jltma_preload_cache', [$this, 'preload_cache_ajax']);
98 add_action('jltma_templates_preload_cache', [$this, 'preload_popular_templates']);
99 add_action('jltma_background_preload', [$this, 'do_background_preload']);
100
101 // Extend existing cache methods
102 add_filter('jltma_templates_cache_enabled', '__return_true');
103 }
104
105 /**
106 * Ensure cache directory exists with proper structure
107 */
108 private function ensure_cache_directory()
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
117 // Check if uploads directory is writable
118 if (!$this->is_uploads_writable()) {
119 return false;
120 }
121
122 if (!file_exists($this->cache_dir)) {
123 if (!wp_mkdir_p($this->cache_dir)) {
124 return false;
125 }
126
127 // Create subdirectories for different template types
128 // Removed 'template-kits' as it should be in its own separate folder
129 $subdirs = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers', 'master_widgets'];
130 foreach ($subdirs as $subdir) {
131 wp_mkdir_p($this->cache_dir . $subdir . '/');
132 wp_mkdir_p($this->cache_dir . $subdir . '/categories/');
133 wp_mkdir_p($this->cache_dir . $subdir . '/keywords/');
134 wp_mkdir_p($this->cache_dir . $subdir . '/templates/');
135 wp_mkdir_p($this->cache_dir . $subdir . '/images/');
136 }
137
138 // Create .htaccess for security
139 $htaccess_content = "Options -Indexes\n<Files \"*.json\">\nOrder allow,deny\nAllow from all\n</Files>";
140 $this->fs_put_contents($this->cache_dir . '.htaccess', $htaccess_content);
141
142 // Create index.php files
143 $index_content = "<?php\n// Silence is golden.\n";
144 $this->fs_put_contents($this->cache_dir . 'index.php', $index_content);
145
146 foreach ($subdirs as $subdir) {
147 $this->fs_put_contents($this->cache_dir . $subdir . '/index.php', $index_content);
148 }
149 }
150
151 return true;
152 }
153
154 /**
155 * Clean up incorrect folders created in wrong location
156 */
157 private function cleanup_incorrect_folders()
158 {
159 // Remove template-kits folder from templates-library if it exists
160 $incorrect_folder = $this->cache_dir . 'template-kits/';
161 if (file_exists($incorrect_folder)) {
162 $this->delete_directory_recursively($incorrect_folder);
163 }
164
165 // Also check for any variations that might have been created
166 $variations = ['template_kits', 'templatekits', 'template-kit'];
167 foreach ($variations as $variant) {
168 $incorrect_variant = $this->cache_dir . $variant . '/';
169 if (file_exists($incorrect_variant)) {
170 $this->delete_directory_recursively($incorrect_variant);
171 }
172 }
173 }
174
175 /**
176 * Delete a directory and all its contents recursively
177 */
178 private function delete_directory_recursively($dir)
179 {
180 if (!file_exists($dir)) {
181 return;
182 }
183
184 if (!is_dir($dir)) {
185 return;
186 }
187
188 // Normalize the directory path to avoid double slashes
189 $dir = rtrim($dir, '/\\');
190
191 // Try to scan the directory, but handle failures gracefully
192 $scan_result = @scandir($dir);
193 if ($scan_result === false) {
194 // If we can't scan it, try to remove it directly
195 @rmdir($dir);
196 return;
197 }
198
199 $files = array_diff($scan_result, array('.', '..'));
200 foreach ($files as $file) {
201 $path = $dir . DIRECTORY_SEPARATOR . $file;
202 if (is_dir($path)) {
203 $this->delete_directory_recursively($path);
204 } else {
205 wp_delete_file($path);
206 }
207 }
208 @rmdir($dir);
209 }
210
211 /**
212 * Check if uploads directory is writable
213 */
214 private function is_uploads_writable()
215 {
216 $upload_dir = wp_upload_dir();
217
218 // Check if uploads dir exists and is writable
219 if (!file_exists($upload_dir['basedir'])) {
220 return false;
221 }
222
223 return is_writable($upload_dir['basedir']);
224 }
225
226 /**
227 * Schedule cache update events via Background_Task_Manager
228 */
229 public function schedule_cache_updates()
230 {
231 if (!$this->local_cache_enabled()) {
232 return;
233 }
234
235 Background_Task_Manager::get_instance()->schedule_recurring(
236 'jltma_templates_cache_update',
237 12 * HOUR_IN_SECONDS
238 );
239 }
240
241 /**
242 * Get cached templates for specific tab
243 */
244 public function get_cached_templates($tab, $force_refresh = false, $per_page = 0)
245 {
246 // Try transient cache first if file cache is not available
247 if (!$this->is_file_cache_available()) {
248 return $this->get_transient_cached_templates($tab, $force_refresh);
249 }
250
251 $cache_file = $this->cache_dir . "{$tab}/templates/templates.json";
252 $cache_meta_file = $this->cache_dir . "{$tab}/templates/meta.json";
253
254 // Check if cache exists and is valid
255 if (!$force_refresh && $this->is_cache_valid($cache_meta_file)) {
256 $cached_data = $this->read_cache_file($cache_file);
257 if ($cached_data !== false) {
258 // Update thumbnail URLs to use cache folder first
259 foreach ($cached_data as &$template) {
260 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
261 if ($cached_thumbnail) {
262 $template['thumbnail'] = $cached_thumbnail;
263 }
264 }
265 return $cached_data;
266 }
267 }
268
269 // Fetch fresh data from remote API
270 $fresh_data = $this->fetch_remote_templates($tab, $per_page);
271
272 if ($fresh_data !== false) {
273 // Update thumbnail URLs to use cache folder first
274 foreach ($fresh_data as &$template) {
275 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
276 if ($cached_thumbnail) {
277 $template['thumbnail'] = $cached_thumbnail;
278 }
279 }
280
281 // Cache the data
282 $this->write_cache_file($cache_file, $fresh_data);
283 $this->write_cache_meta($cache_meta_file);
284
285 // Cache individual template thumbnails
286 $this->cache_template_images($fresh_data, $tab);
287
288 return $fresh_data;
289 }
290
291 // Fallback to expired cache if available
292 $fallback_data = $this->read_cache_file($cache_file);
293 if ($fallback_data !== false) {
294 // Update thumbnail URLs to use cache folder first for fallback data
295 foreach ($fallback_data as &$template) {
296 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
297 if ($cached_thumbnail) {
298 $template['thumbnail'] = $cached_thumbnail;
299 }
300 }
301 }
302 return $fallback_data;
303 }
304
305 /**
306 * Get cached categories for specific tab
307 */
308 public function get_cached_categories($tab, $force_refresh = false)
309 {
310 // Try transient cache first if file cache is not available
311 if (!$this->is_file_cache_available()) {
312 return $this->get_transient_cached_categories($tab, $force_refresh);
313 }
314
315 $cache_file = $this->cache_dir . "{$tab}/categories/categories.json";
316 $cache_meta_file = $this->cache_dir . "{$tab}/categories/meta.json";
317
318 if (!$force_refresh && $this->is_cache_valid($cache_meta_file)) {
319 $cached_data = $this->read_cache_file($cache_file);
320 if ($cached_data !== false) {
321 return $cached_data;
322 }
323 }
324
325 $fresh_data = $this->fetch_remote_categories($tab);
326
327 if ($fresh_data !== false) {
328 $this->write_cache_file($cache_file, $fresh_data);
329 $this->write_cache_meta($cache_meta_file);
330 return $fresh_data;
331 }
332
333 return $this->read_cache_file($cache_file);
334 }
335
336 /**
337 * Get cached keywords for specific tab
338 */
339 public function get_cached_keywords($tab, $force_refresh = false)
340 {
341 // Try transient cache first if file cache is not available
342 if (!$this->is_file_cache_available()) {
343 return $this->get_transient_cached_keywords($tab, $force_refresh);
344 }
345
346 $cache_file = $this->cache_dir . "{$tab}/keywords/keywords.json";
347 $cache_meta_file = $this->cache_dir . "{$tab}/keywords/meta.json";
348
349 if (!$force_refresh && $this->is_cache_valid($cache_meta_file)) {
350 $cached_data = $this->read_cache_file($cache_file);
351 if ($cached_data !== false) {
352 return $cached_data;
353 }
354 }
355
356 $fresh_data = $this->fetch_remote_keywords($tab);
357
358 if ($fresh_data !== false) {
359 $this->write_cache_file($cache_file, $fresh_data);
360 $this->write_cache_meta($cache_meta_file);
361 return $fresh_data;
362 }
363
364 return $this->read_cache_file($cache_file);
365 }
366
367 /**
368 * Get cached individual template
369 */
370 public function get_cached_template($template_id, $tab, $force_refresh = false)
371 {
372 $cache_file = $this->cache_dir . "{$tab}/templates/template-{$template_id}.json";
373 $cache_meta_file = $this->cache_dir . "{$tab}/templates/template-{$template_id}-meta.json";
374
375 if (!$force_refresh && $this->is_cache_valid($cache_meta_file)) {
376 $cached_data = $this->read_cache_file($cache_file);
377 if ($cached_data !== false) {
378 return $cached_data;
379 }
380 }
381
382 // For individual templates, we don't cache them unless they're part of a larger fetch
383 // This prevents excessive API calls for single template requests
384 return false;
385 }
386
387 /**
388 * Cache individual template data (called after successful API fetch)
389 */
390 public function cache_template_data($template_id, $tab, $data)
391 {
392 $cache_file = $this->cache_dir . "{$tab}/templates/template-{$template_id}.json";
393 $cache_meta_file = $this->cache_dir . "{$tab}/templates/template-{$template_id}-meta.json";
394
395 $this->write_cache_file($cache_file, $data);
396 $this->write_cache_meta($cache_meta_file);
397 }
398
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 /**
588 * Fetch templates from remote API
589 */
590 private function fetch_remote_templates($tab, $per_page = 0)
591 {
592 $config = $this->get_api_config();
593
594 pretty_log('$config', $config);
595
596 if (empty($config) || empty($config['endpoints']['templates'])) {
597 return false;
598 }
599
600 $api_url = $config['base'] . $config['path'] . $config['endpoints']['templates'] . $tab;
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
609 $response = wp_remote_get($api_url, [
610 'timeout' => 60,
611 'sslverify' => false,
612 'headers' => [
613 'User-Agent' => 'Master Addons Templates Cache/' . JLTMA_VER
614 ]
615 ]);
616
617 pretty_log('$response', $response);
618
619 if (is_wp_error($response)) {
620 return false;
621 }
622
623 $body = wp_remote_retrieve_body($response);
624 $data = json_decode($body, true);
625
626 if (json_last_error() !== JSON_ERROR_NONE || !isset($data['success']) || !$data['success']) {
627 return false;
628 }
629
630 return $this->expand_thumbnails(
631 isset($data['templates']) ? $data['templates'] : [],
632 isset($data['thumb_base']) ? $data['thumb_base'] : ''
633 );
634 }
635
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 /**
684 * Fetch categories from remote API
685 */
686 private function fetch_remote_categories($tab)
687 {
688 $config = $this->get_api_config();
689
690 if (empty($config) || empty($config['endpoints']['categories'])) {
691 return false;
692 }
693
694 $api_url = $config['base'] . $config['path'] . $config['endpoints']['categories'] . $tab;
695
696 $response = wp_remote_get($api_url, [
697 'timeout' => 60,
698 'sslverify' => false
699 ]);
700
701 if (is_wp_error($response)) {
702 return false;
703 }
704
705 $body = wp_remote_retrieve_body($response);
706 $data = json_decode($body, true);
707
708 if (json_last_error() !== JSON_ERROR_NONE || !isset($data['success']) || !$data['success']) {
709 return false;
710 }
711
712 return isset($data['terms']) ? $data['terms'] : [];
713 }
714
715 /**
716 * Fetch keywords from remote API
717 */
718 private function fetch_remote_keywords($tab)
719 {
720 $config = $this->get_api_config();
721
722 if (empty($config) || empty($config['endpoints']['keywords'])) {
723 return false;
724 }
725
726 $api_url = $config['base'] . $config['path'] . $config['endpoints']['keywords'] . $tab;
727
728 $response = wp_remote_get($api_url, [
729 'timeout' => 60,
730 'sslverify' => false
731 ]);
732
733 if (is_wp_error($response)) {
734 return false;
735 }
736
737 $body = wp_remote_retrieve_body($response);
738 $data = json_decode($body, true);
739
740 if (json_last_error() !== JSON_ERROR_NONE || !isset($data['success']) || !$data['success']) {
741 return false;
742 }
743
744 return isset($data['terms']) ? $data['terms'] : [];
745 }
746
747 /**
748 * Cache template images locally
749 */
750 private function cache_template_images($templates, $tab)
751 {
752 if (!is_array($templates)) {
753 return;
754 }
755
756 foreach ($templates as $template) {
757 if (isset($template['thumbnail']) && !empty($template['thumbnail'])) {
758 $template_id = $template['template_id'] ?? uniqid();
759 $this->cache_image($template['thumbnail'], $tab, "template-{$template_id}-thumb");
760 }
761
762 if (isset($template['preview']) && !empty($template['preview'])) {
763 $template_id = $template['template_id'] ?? uniqid();
764 $this->cache_image($template['preview'], $tab, "template-{$template_id}-preview");
765 }
766 }
767 }
768
769 /**
770 * Cache individual image
771 */
772 private function cache_image($image_url, $tab, $filename)
773 {
774 if (empty($image_url)) {
775 return false;
776 }
777
778 $extension = pathinfo($image_url, PATHINFO_EXTENSION);
779 if (empty($extension)) {
780 $extension = 'jpg';
781 }
782
783 $local_file = $this->cache_dir . "{$tab}/images/{$filename}.{$extension}";
784
785 // Skip if already cached and recent
786 if (file_exists($local_file) && (time() - filemtime($local_file)) < DAY_IN_SECONDS) {
787 return $local_file;
788 }
789
790 // Ensure the directory exists before trying to write
791 $image_dir = dirname($local_file);
792 if (!file_exists($image_dir)) {
793 wp_mkdir_p($image_dir);
794 }
795
796 $response = wp_remote_get($image_url, [
797 'timeout' => 30
798 ]);
799
800 if (is_wp_error($response)) {
801 return false;
802 }
803
804 $image_data = wp_remote_retrieve_body($response);
805
806 if ($this->fs_put_contents($local_file, $image_data)) {
807 return $local_file;
808 }
809
810 return false;
811 }
812
813 /**
814 * Update templates cache (scheduled event)
815 */
816 public function update_templates_cache()
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
825 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
826 $btm = Background_Task_Manager::get_instance();
827
828 foreach ($template_types as $tab) {
829 try {
830 $btm->execute_with_retry(function () use ($tab) {
831 // Update templates
832 $this->get_cached_templates($tab, true);
833 // Update categories
834 $this->get_cached_categories($tab, true);
835 // Update keywords
836 $this->get_cached_keywords($tab, true);
837 }, 3, "templates_cache_sync_{$tab}");
838 } catch (\Exception $e) {
839 // Individual tab failure logged by execute_with_retry; continue with others
840 }
841 }
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
853 // Clean up old cache files
854 $this->cleanup_old_cache();
855
856 // Update last cache time
857 set_transient('jltma_templates_last_cache_update', time(), DAY_IN_SECONDS);
858 }
859
860 /**
861 * Check if cache is valid
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
878 private function is_cache_valid($meta_file)
879 {
880 if (!$this->local_cache_enabled()) {
881 return false;
882 }
883
884 if (!file_exists($meta_file)) {
885 return false;
886 }
887
888 $meta = json_decode(file_get_contents($meta_file), true);
889 if (!$meta || !isset($meta['timestamp'])) {
890 return false;
891 }
892
893 return (time() - $meta['timestamp']) < $this->cache_expiry;
894 }
895
896 /**
897 * Read cache file with priority tracking
898 */
899 private function read_cache_file($file_path)
900 {
901 if (!file_exists($file_path)) {
902 return false;
903 }
904
905 // Track access for priority system
906 $this->track_cache_access($file_path);
907
908 $content = file_get_contents($file_path);
909 if ($content === false) {
910 return false;
911 }
912
913 $data = json_decode($content, true);
914 return json_last_error() === JSON_ERROR_NONE ? $data : false;
915 }
916
917 /**
918 * Write a file through the WP_Filesystem API.
919 *
920 * @param string $file
921 * @param string $contents
922 * @return bool
923 */
924 private function fs_put_contents($file, $contents)
925 {
926 if (!$this->local_cache_enabled()) {
927 return false;
928 }
929
930 global $wp_filesystem;
931 if (empty($wp_filesystem)) {
932 require_once ABSPATH . 'wp-admin/includes/file.php';
933 WP_Filesystem();
934 }
935 if (empty($wp_filesystem)) {
936 return false;
937 }
938 return $wp_filesystem->put_contents($file, $contents, FS_CHMOD_FILE);
939 }
940
941 /**
942 * Write cache file
943 */
944 private function write_cache_file($file_path, $data)
945 {
946 $dir = dirname($file_path);
947 if (!file_exists($dir)) {
948 wp_mkdir_p($dir);
949 }
950
951 $json = wp_json_encode($data, JSON_PRETTY_PRINT);
952 return $this->fs_put_contents($file_path, $json) !== false;
953 }
954
955 /**
956 * Write cache metadata
957 */
958 private function write_cache_meta($meta_file)
959 {
960 $meta = [
961 'timestamp' => time(),
962 'version' => JLTMA_VER,
963 'expiry' => $this->cache_expiry
964 ];
965
966 return $this->fs_put_contents($meta_file, wp_json_encode($meta)) !== false;
967 }
968
969 /**
970 * Clear all template cache
971 */
972 public function clear_cache()
973 {
974 $cleared = false;
975
976 // Clear file cache if available
977 if (file_exists($this->cache_dir)) {
978 $this->delete_directory_contents($this->cache_dir);
979 $this->ensure_cache_directory();
980 $cleared = true;
981 }
982
983 // Always clear transient cache
984 $this->clear_transient_cache();
985
986 // Clear related transients (legacy support)
987 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
988 foreach ($template_types as $tab) {
989 delete_transient("master_addons_templates_master-api_{$tab}");
990 delete_transient("master_addons_categories_master-api_{$tab}");
991 delete_transient("master_addons_keywords_master-api_{$tab}");
992 }
993
994 delete_transient('jltma_templates_last_cache_update');
995
996 return true;
997 }
998
999 /**
1000 * Refresh cache by clearing and fetching fresh data from API
1001 */
1002 public function refresh_cache()
1003 {
1004 // Clear all existing cache
1005 $this->clear_cache();
1006
1007 // Force fetch fresh data from API for all template types
1008 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1009 $refreshed_data = [];
1010
1011 foreach ($template_types as $tab) {
1012 // Force refresh templates
1013 $templates = $this->get_cached_templates($tab, true);
1014
1015 // Force refresh categories
1016 $categories = $this->get_cached_categories($tab, true);
1017
1018 // Force refresh keywords
1019 $keywords = $this->get_cached_keywords($tab, true);
1020
1021 $refreshed_data[$tab] = [
1022 'templates' => is_array($templates) ? count($templates) : 0,
1023 'categories' => is_array($categories) ? count($categories) : 0,
1024 'keywords' => is_array($keywords) ? count($keywords) : 0
1025 ];
1026 }
1027
1028 // Update last cache refresh time
1029 set_transient('jltma_templates_last_cache_update', time(), DAY_IN_SECONDS);
1030
1031 // Log successful refresh
1032
1033 return $refreshed_data;
1034 }
1035
1036 /**
1037 * Clean up old cache files with priority system
1038 */
1039 private function cleanup_old_cache()
1040 {
1041 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1042
1043 // Cache priority ages (high priority files are kept longer)
1044 $priority_ages = [
1045 'high' => 14 * DAY_IN_SECONDS, // 14 days for high priority
1046 'medium' => 7 * DAY_IN_SECONDS, // 7 days for medium priority
1047 'low' => 3 * DAY_IN_SECONDS // 3 days for low priority
1048 ];
1049
1050 foreach ($template_types as $tab) {
1051 $tab_dir = $this->cache_dir . $tab . '/';
1052 if (!file_exists($tab_dir)) {
1053 continue;
1054 }
1055
1056 $subdirs = ['categories', 'keywords', 'templates', 'images'];
1057 foreach ($subdirs as $subdir) {
1058 $full_dir = $tab_dir . $subdir . '/';
1059 if (!file_exists($full_dir)) {
1060 continue;
1061 }
1062
1063 $files = glob($full_dir . '*');
1064 foreach ($files as $file) {
1065 if (is_file($file)) {
1066 $file_age = time() - filemtime($file);
1067 $priority = $this->get_file_cache_priority($file, $tab);
1068 $max_age = $priority_ages[$priority] ?? $priority_ages['low'];
1069
1070 if ($file_age > $max_age) {
1071 wp_delete_file($file);
1072 }
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 /**
1080 * Determine cache file priority based on usage patterns
1081 */
1082 private function get_file_cache_priority($file_path, $tab)
1083 {
1084 $filename = basename($file_path);
1085 $access_count = $this->get_file_access_count($file_path);
1086 $recent_access = $this->get_recent_access_time($file_path);
1087
1088 // High priority: Frequently accessed files (>10 times) or recently accessed (within 2 days)
1089 if ($access_count > 10 || (time() - $recent_access) < (2 * DAY_IN_SECONDS)) {
1090 return 'high';
1091 }
1092
1093 // Medium priority: Moderately accessed files (3-10 times) or accessed within a week
1094 if ($access_count >= 3 || (time() - $recent_access) < (7 * DAY_IN_SECONDS)) {
1095 return 'medium';
1096 }
1097
1098 // Low priority: Everything else
1099 return 'low';
1100 }
1101
1102 /**
1103 * Get file access count from usage tracking
1104 */
1105 private function get_file_access_count($file_path)
1106 {
1107 $access_data = get_transient('jltma_cache_access_' . md5($file_path));
1108 return $access_data ? (int) $access_data['count'] : 0;
1109 }
1110
1111 /**
1112 * Get recent access time for file
1113 */
1114 private function get_recent_access_time($file_path)
1115 {
1116 $access_data = get_transient('jltma_cache_access_' . md5($file_path));
1117 return $access_data ? (int) $access_data['last_access'] : filemtime($file_path);
1118 }
1119
1120 /**
1121 * Track cache file access for priority system
1122 */
1123 private function track_cache_access($file_path)
1124 {
1125 $access_key = 'jltma_cache_access_' . md5($file_path);
1126 $access_data = get_transient($access_key) ?: ['count' => 0, 'last_access' => 0];
1127
1128 $access_data['count']++;
1129 $access_data['last_access'] = time();
1130
1131 set_transient($access_key, $access_data, 30 * DAY_IN_SECONDS);
1132 }
1133
1134 /**
1135 * Delete directory contents recursively
1136 */
1137 private function delete_directory_contents($dir)
1138 {
1139 if (!file_exists($dir)) {
1140 return;
1141 }
1142
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 );
1151 foreach ($files as $file) {
1152 if (is_dir($file)) {
1153 $this->delete_directory_contents($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
1157 } else {
1158 wp_delete_file($file);
1159 }
1160 }
1161 }
1162
1163 /**
1164 * Handle cache clearing from admin
1165 */
1166 public function maybe_clear_cache()
1167 {
1168 if (isset($_GET['jltma_clear_templates_cache']) &&
1169 isset($_GET['_wpnonce']) &&
1170 wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'jltma_clear_templates_cache') &&
1171 current_user_can('manage_options')) {
1172
1173 $this->clear_cache();
1174
1175 wp_safe_redirect(add_query_arg([
1176 'jltma_templates_cache_cleared' => '1'
1177 ], remove_query_arg(['jltma_clear_templates_cache', '_wpnonce'])));
1178 exit;
1179 }
1180
1181 // Handle cache refresh from admin
1182 if (isset($_GET['jltma_refresh_templates_cache']) &&
1183 isset($_GET['_wpnonce']) &&
1184 wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'jltma_refresh_templates_cache') &&
1185 current_user_can('manage_options')) {
1186
1187 $this->refresh_cache();
1188
1189 wp_safe_redirect(add_query_arg([
1190 'jltma_templates_cache_refreshed' => '1'
1191 ], remove_query_arg(['jltma_refresh_templates_cache', '_wpnonce'])));
1192 exit;
1193 }
1194 }
1195
1196 /**
1197 * Get cached image URL
1198 */
1199 public function get_cached_image_url($original_url, $tab, $filename)
1200 {
1201 $extension = pathinfo($original_url, PATHINFO_EXTENSION) ?: 'jpg';
1202 $local_file = $this->cache_dir . "{$tab}/images/{$filename}.{$extension}";
1203
1204 if (file_exists($local_file)) {
1205 $upload_dir = wp_upload_dir();
1206 $relative_path = str_replace($upload_dir['basedir'], '', $local_file);
1207 return $upload_dir['baseurl'] . $relative_path;
1208 }
1209
1210 return $original_url;
1211 }
1212
1213 /**
1214 * Get template kit thumbnail from cache or generate fallback URL
1215 */
1216 public function get_kit_thumbnail_url($kit_name, $template_name = 'home', $original_url = null)
1217 {
1218 // Normalize kit name for filename
1219 $kit_slug = sanitize_title($kit_name);
1220 $template_slug = sanitize_title($template_name);
1221
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 ];
1232
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 }
1240 }
1241 }
1242
1243 // If original URL provided, return it
1244 if ($original_url) {
1245 return $original_url;
1246 }
1247
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
1257 $kit_version = $this->get_kit_version($kit_name);
1258 return "https://master-addons.com/templates-kit/{$kit_slug}{$kit_version}/{$template_slug}.jpg";
1259 }
1260
1261 /**
1262 * Get kit version suffix for URL generation
1263 */
1264 private function get_kit_version($kit_name)
1265 {
1266 // Common version patterns for kits
1267 $version_patterns = [
1268 'business-agency' => '-v1',
1269 'restaurant' => '-v2',
1270 'portfolio' => '-v1',
1271 'ecommerce' => '-v3'
1272 ];
1273
1274 $kit_slug = sanitize_title($kit_name);
1275 return $version_patterns[$kit_slug] ?? '-v1';
1276 }
1277
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 /**
1286 * Get cache statistics
1287 */
1288 public function get_cache_stats()
1289 {
1290 $stats = [
1291 'cache_dir_exists' => file_exists($this->cache_dir),
1292 'cache_size' => $this->get_directory_size($this->cache_dir),
1293 'last_update' => get_transient('jltma_templates_last_cache_update'),
1294 'template_types' => [],
1295 'next_scheduled_update' => wp_next_scheduled('jltma_templates_cache_update'),
1296 'total_kits' => 0,
1297 'total_templates' => 0
1298 ];
1299
1300 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1301 $total_templates = 0;
1302
1303 foreach ($template_types as $type) {
1304 $template_count = 0;
1305 $image_count = 0;
1306
1307 // Count file cache if available
1308 if ($this->is_file_cache_available()) {
1309 $template_count = count(glob($this->cache_dir . "{$type}/templates/template-*.json"));
1310 $image_count = count(glob($this->cache_dir . "{$type}/images/*"));
1311 } else {
1312 // Count transient cache
1313 $cached_templates = get_transient("jltma_templates_{$type}");
1314 if ($cached_templates && is_array($cached_templates)) {
1315 $template_count = count($cached_templates);
1316 }
1317 }
1318
1319 $stats['template_types'][$type] = [
1320 'templates' => $template_count,
1321 'images' => $image_count
1322 ];
1323
1324 $total_templates += $template_count;
1325 }
1326
1327 // For template kits, we'll count unique kits from cached data
1328 $stats['total_kits'] = $this->count_cached_kits();
1329 $stats['total_templates'] = $total_templates;
1330
1331 return $stats;
1332 }
1333
1334 /**
1335 * Count cached template kits
1336 */
1337 private function count_cached_kits()
1338 {
1339 $kit_count = 0;
1340
1341 // If using file cache, look for kit manifest files
1342 if ($this->is_file_cache_available()) {
1343 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1344 foreach ($template_types as $type) {
1345 $templates_file = $this->cache_dir . "{$type}/templates/templates.json";
1346 if (file_exists($templates_file)) {
1347 $templates_data = $this->read_cache_file($templates_file);
1348 if ($templates_data && is_array($templates_data)) {
1349 $kit_count += count($templates_data);
1350 }
1351 }
1352 }
1353 } else {
1354 // Count from transients
1355 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1356 foreach ($template_types as $type) {
1357 $cached_templates = get_transient("jltma_templates_{$type}");
1358 if ($cached_templates && is_array($cached_templates)) {
1359 $kit_count += count($cached_templates);
1360 }
1361 }
1362 }
1363
1364 return $kit_count;
1365 }
1366
1367 /**
1368 * Get directory size in bytes
1369 */
1370 private function get_directory_size($dir)
1371 {
1372 $size = 0;
1373 if (file_exists($dir)) {
1374 foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
1375 if ($file->isFile()) {
1376 $size += $file->getSize();
1377 }
1378 }
1379 }
1380 return $size;
1381 }
1382
1383 /**
1384 * Check if file cache is available
1385 */
1386 private function is_file_cache_available()
1387 {
1388 $this->maybe_bootstrap_cache_dir();
1389
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();
1414 }
1415
1416 /**
1417 * Get cached templates using transients (fallback method)
1418 */
1419 private function get_transient_cached_templates($tab, $force_refresh = false)
1420 {
1421 $transient_key = "jltma_templates_{$tab}";
1422 $meta_transient_key = "jltma_templates_{$tab}_meta";
1423
1424 // Check if cache exists and is valid
1425 if (!$force_refresh) {
1426 $cached_meta = get_transient($meta_transient_key);
1427 if ($cached_meta && (time() - $cached_meta['timestamp']) < $this->cache_expiry) {
1428 $cached_data = get_transient($transient_key);
1429 if ($cached_data !== false) {
1430 // Update thumbnail URLs to use cache folder first for transient cached templates
1431 foreach ($cached_data as &$template) {
1432 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
1433 if ($cached_thumbnail) {
1434 $template['thumbnail'] = $cached_thumbnail;
1435 }
1436 }
1437 return $cached_data;
1438 }
1439 }
1440 }
1441
1442 // Fetch fresh data from remote API
1443 $fresh_data = $this->fetch_remote_templates($tab);
1444
1445 if ($fresh_data !== false) {
1446 // Update thumbnail URLs to use cache folder first for fresh transient templates
1447 foreach ($fresh_data as &$template) {
1448 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
1449 if ($cached_thumbnail) {
1450 $template['thumbnail'] = $cached_thumbnail;
1451 }
1452 }
1453
1454 // Cache the data using transients
1455 set_transient($transient_key, $fresh_data, $this->cache_expiry);
1456 set_transient($meta_transient_key, ['timestamp' => time()], $this->cache_expiry);
1457
1458 return $fresh_data;
1459 }
1460
1461 // Return cached data even if expired
1462 $fallback_transient_data = get_transient($transient_key);
1463 if ($fallback_transient_data !== false) {
1464 // Update thumbnail URLs to use cache folder first for expired transient templates
1465 foreach ($fallback_transient_data as &$template) {
1466 $cached_thumbnail = $this->get_kit_thumbnail_url('', $template['title'], $template['thumbnail']);
1467 if ($cached_thumbnail) {
1468 $template['thumbnail'] = $cached_thumbnail;
1469 }
1470 }
1471 }
1472 return $fallback_transient_data;
1473 }
1474
1475 /**
1476 * Get cached categories using transients (fallback method)
1477 */
1478 private function get_transient_cached_categories($tab, $force_refresh = false)
1479 {
1480 $transient_key = "jltma_categories_{$tab}";
1481 $meta_transient_key = "jltma_categories_{$tab}_meta";
1482
1483 if (!$force_refresh) {
1484 $cached_meta = get_transient($meta_transient_key);
1485 if ($cached_meta && (time() - $cached_meta['timestamp']) < $this->cache_expiry) {
1486 $cached_data = get_transient($transient_key);
1487 if ($cached_data !== false) {
1488 return $cached_data;
1489 }
1490 }
1491 }
1492
1493 $fresh_data = $this->fetch_remote_categories($tab);
1494
1495 if ($fresh_data !== false) {
1496 set_transient($transient_key, $fresh_data, $this->cache_expiry);
1497 set_transient($meta_transient_key, ['timestamp' => time()], $this->cache_expiry);
1498 return $fresh_data;
1499 }
1500
1501 return get_transient($transient_key);
1502 }
1503
1504 /**
1505 * Get cached keywords using transients (fallback method)
1506 */
1507 private function get_transient_cached_keywords($tab, $force_refresh = false)
1508 {
1509 $transient_key = "jltma_keywords_{$tab}";
1510 $meta_transient_key = "jltma_keywords_{$tab}_meta";
1511
1512 if (!$force_refresh) {
1513 $cached_meta = get_transient($meta_transient_key);
1514 if ($cached_meta && (time() - $cached_meta['timestamp']) < $this->cache_expiry) {
1515 $cached_data = get_transient($transient_key);
1516 if ($cached_data !== false) {
1517 return $cached_data;
1518 }
1519 }
1520 }
1521
1522 $fresh_data = $this->fetch_remote_keywords($tab);
1523
1524 if ($fresh_data !== false) {
1525 set_transient($transient_key, $fresh_data, $this->cache_expiry);
1526 set_transient($meta_transient_key, ['timestamp' => time()], $this->cache_expiry);
1527 return $fresh_data;
1528 }
1529
1530 return get_transient($transient_key);
1531 }
1532
1533 /**
1534 * Clear transient cache (fallback method)
1535 */
1536 private function clear_transient_cache()
1537 {
1538 $template_types = ['master_section', 'master_pages', 'master_popups', 'master_headers', 'master_footers'];
1539
1540 foreach ($template_types as $tab) {
1541 delete_transient("jltma_templates_{$tab}");
1542 delete_transient("jltma_templates_{$tab}_meta");
1543 delete_transient("jltma_categories_{$tab}");
1544 delete_transient("jltma_categories_{$tab}_meta");
1545 delete_transient("jltma_keywords_{$tab}");
1546 delete_transient("jltma_keywords_{$tab}_meta");
1547 }
1548 }
1549
1550 /**
1551 * Preload popular templates in background
1552 */
1553 public function preload_popular_templates()
1554 {
1555 $popular_tabs = ['master_section', 'master_headers'];
1556
1557 foreach ($popular_tabs as $tab) {
1558 if (!get_transient("jltma_preload_{$tab}")) {
1559 wp_schedule_single_event(time() + 60, 'jltma_background_preload', [$tab]);
1560 set_transient("jltma_preload_{$tab}", true, HOUR_IN_SECONDS);
1561 }
1562 }
1563 }
1564
1565 /**
1566 * Background preload handler (callback for jltma_background_preload action)
1567 *
1568 * @param string $tab Template tab to preload
1569 */
1570 public function do_background_preload($tab)
1571 {
1572 if (!empty($tab)) {
1573 $this->get_cached_templates($tab, true);
1574 }
1575 }
1576
1577 /**
1578 * AJAX handler for cache preloading
1579 */
1580 public function preload_cache_ajax()
1581 {
1582 if (!current_user_can('manage_options')) {
1583 wp_die(-1);
1584 }
1585
1586 if (!check_ajax_referer('jltma_preload_cache_nonce', 'security', false)) {
1587 wp_die(-1);
1588 }
1589
1590 $tab = sanitize_text_field( wp_unslash( $_POST['tab'] ?? '' ) );
1591
1592 if (empty($tab)) {
1593 wp_send_json_error('Invalid tab');
1594 }
1595
1596 // Preload in background
1597 $this->get_cached_templates($tab, true);
1598
1599 wp_send_json_success('Cache preloaded for ' . $tab);
1600 }
1601
1602 /**
1603 * Get singleton instance
1604 */
1605 public static function get_instance()
1606 {
1607 if (self::$instance === null) {
1608 self::$instance = new self();
1609 }
1610 return self::$instance;
1611 }
1612 }
1613
1614 // Initialize templates cache manager
1615 Template_Library_Cache::get_instance();
1616