PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.6
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.6
4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / library / controllers / class-request-controller.php
superb-blocks / src / library / controllers Last commit date
class-favorites-controller.php 3 weeks ago class-library-controller.php 3 weeks ago class-request-controller.php 3 weeks ago
class-request-controller.php
766 lines
1 <?php
2
3 namespace SuperbAddons\Library\Controllers;
4
5 defined('ABSPATH') || exit();
6
7 use Exception;
8 use WP_Error;
9 use WP_REST_Server;
10 use SuperbAddons\Config\Capabilities;
11 use SuperbAddons\Data\Controllers\CacheController;
12 use SuperbAddons\Data\Controllers\DomainShiftController;
13 use SuperbAddons\Data\Controllers\KeyController;
14 use SuperbAddons\Data\Controllers\OptionController;
15 use SuperbAddons\Data\Controllers\RestController;
16 use SuperbAddons\Data\Utils\CacheException;
17 use SuperbAddons\Data\Utils\CacheTypes;
18 use SuperbAddons\Data\Utils\ElementorCache;
19 use SuperbAddons\Data\Utils\ChunkLoading;
20 use SuperbAddons\Data\Utils\Engagement;
21 use SuperbAddons\Data\Utils\GutenbergCache;
22 use SuperbAddons\Data\Utils\RequestException;
23 use SuperbAddons\Elementor\Controllers\ElementorController;
24 use SuperbAddons\Gutenberg\Controllers\GutenbergController;
25
26
27 class LibraryRequestController
28 {
29 const ELEMENTOR_LIST_ROUTE = '/elementor-list';
30 const ELEMENTOR_INSERT_ROUTE = '/elementor-insert';
31
32 // Gutenberg v2 routes (unified)
33 const GUTENBERG_V2_LIST_ROUTE = '/gutenberg-v2-list';
34 const GUTENBERG_V2_LIST_CHUNK_ROUTE = '/gutenberg-v2-list-chunk';
35 const GUTENBERG_V2_WARM_CACHE_ROUTE = '/gutenberg-v2-warm-cache';
36 const GUTENBERG_V2_INSERT_ROUTE = '/gutenberg-v2-insert';
37
38 // Gutenberg v2 insert type params
39 const GUTENBERG_TYPE_PATTERN = 'pattern';
40 const GUTENBERG_TYPE_PAGE = 'page';
41
42 const ELEMENTOR_ENDPOINT_BASE = 'elementor-library/';
43 const GUTENBERG_V2_ENDPOINT = 'gutenberg-library/v2/library';
44
45 const PLUGIN_NAMES = array(
46 'woocommerce/woocommerce.php' => 'WooCommerce',
47 );
48
49 public function __construct()
50 {
51 RestController::AddRoute(self::ELEMENTOR_LIST_ROUTE, array(
52 'methods' => WP_REST_Server::READABLE,
53 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
54 'callback' => array($this, 'ElementorListCallback'),
55 ));
56 RestController::AddRoute(self::ELEMENTOR_INSERT_ROUTE, array(
57 'methods' => WP_REST_Server::READABLE,
58 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
59 'callback' => array($this, 'ElementorInsertCallback'),
60 ));
61
62 // v2 Gutenberg routes
63 RestController::AddRoute(self::GUTENBERG_V2_LIST_ROUTE, array(
64 'methods' => WP_REST_Server::READABLE,
65 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
66 'callback' => array($this, 'GutenbergV2ListCallback'),
67 ));
68 RestController::AddRoute(self::GUTENBERG_V2_LIST_CHUNK_ROUTE, array(
69 'methods' => WP_REST_Server::READABLE,
70 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
71 'callback' => array($this, 'GutenbergV2ListChunkCallback'),
72 ));
73 RestController::AddRoute(self::GUTENBERG_V2_WARM_CACHE_ROUTE, array(
74 'methods' => WP_REST_Server::READABLE,
75 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
76 'callback' => array($this, 'GutenbergV2WarmCacheCallback'),
77 ));
78 RestController::AddRoute(self::GUTENBERG_V2_INSERT_ROUTE, array(
79 'methods' => WP_REST_Server::READABLE,
80 'permission_callback' => array($this, 'LibraryCallbackPermissionCheck'),
81 'callback' => array($this, 'GutenbergV2InsertCallback'),
82 ));
83 }
84
85 public function LibraryCallbackPermissionCheck()
86 {
87 // Restrict endpoint to only users who have the proper capability.
88 if (!current_user_can(Capabilities::CONTRIBUTOR)) {
89 return new WP_Error('rest_forbidden', esc_html__('Unauthorized. Please check user permissions.', "superb-blocks"), array('status' => 401));
90 }
91
92 return true;
93 }
94
95 // ─── Elementor (unchanged) ──────────────────────────────────────────
96
97 public function ElementorListCallback()
98 {
99 try {
100 $section_cache = CacheController::GetCache(ElementorCache::SECTIONS, CacheTypes::ELEMENTOR);
101 if (!!$section_cache) {
102 // Local cache accepted
103 $section_cache->premium = KeyController::HasValidPremiumKey();
104 return rest_ensure_response($section_cache);
105 }
106
107 return $this->ElementorListHandler();
108 } catch (CacheException $cex) {
109 return new \WP_Error('internal_error_cache', 'Internal Cache Error: ' . esc_html($cex->getMessage()), array('status' => 500));
110 } catch (Exception $ex) {
111 LogController::HandleException($ex);
112 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
113 }
114 }
115
116 private function ElementorListHandler()
117 {
118 // Fetch data cache from service
119 $options_controller = new OptionController();
120 $license_key = $options_controller->GetKey();
121
122 $response = DomainShiftController::RemoteGet(self::ELEMENTOR_ENDPOINT_BASE . 'sections?action=list&key=' . $license_key);
123 ///
124 if (!is_array($response) || is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
125 return new \WP_Error('service_unavailable', 'Plugin Service Unavailable', array('status' => 503));
126 }
127 ///
128 $data = json_decode($response['body']);
129 if (isset($data->code) && isset($data->data) && isset($data->message)) {
130 $status = isset($data->data->status) ? $data->data->status : 500;
131 return new \WP_Error($data->code, $data->message, array('status' => $status));
132 }
133 if (isset($data->level)) {
134 KeyController::UpdateKeyType($data->level, $data->active, $data->expired, $data->exceeded);
135 }
136
137 // Sort items
138 if (isset($data->items) && is_array($data->items) && !empty($data->items)) {
139 usort($data->items, function ($a, $b) {
140 if (!isset($a->title) || !isset($b->title)) {
141 return 0;
142 }
143 return strnatcmp($a->title, $b->title);
144 });
145 }
146
147 // Cache data
148 CacheController::SetCache(ElementorCache::SECTIONS, $data);
149
150 $data->premium = KeyController::HasValidPremiumKey();
151 //
152 return rest_ensure_response($data);
153 }
154
155 public function ElementorInsertCallback($request)
156 {
157 return $this->ElementorInsertHandler($request);
158 }
159
160 // ─── Gutenberg v2 (unified) ─────────────────────────────────────────
161
162 public function GutenbergV2ListCallback()
163 {
164 try {
165 $data = $this->FetchGutenbergLibraryData();
166
167 // _retry response: another request is loading, JS should poll again
168 if (isset($data->_retry) && $data->_retry) {
169 return rest_ensure_response($data);
170 }
171
172 if (isset($data->patterns->items)) {
173 $this->UpdatePatternRequirementStatus($data->patterns);
174 }
175 if (isset($data->pages->items)) {
176 $this->UpdatePatternRequirementStatus($data->pages);
177 }
178 $data->premium = KeyController::HasValidPremiumKey();
179 return rest_ensure_response($data);
180 } catch (CacheException $cex) {
181 return new \WP_Error('internal_error_cache', 'Internal Cache Error: ' . esc_html($cex->getMessage()), array('status' => 500));
182 } catch (RequestException $rex) {
183 return new \WP_Error('internal_error_request', 'Internal Request Error: ' . esc_html($rex->getMessage()), array('status' => $rex->getCode()));
184 } catch (Exception $ex) {
185 LogController::HandleException($ex);
186 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
187 }
188 }
189
190 public function GutenbergV2InsertCallback($request)
191 {
192 try {
193 $type = isset($request['type']) ? $request['type'] : '';
194 if ($type !== self::GUTENBERG_TYPE_PATTERN && $type !== self::GUTENBERG_TYPE_PAGE) {
195 return new \WP_Error('invalid_type', 'Invalid type parameter', array('status' => 400));
196 }
197
198 $data = self::GetInsertDataV2($request, $type);
199
200 if (isset($data['access_failed'])) {
201 return rest_ensure_response($data);
202 }
203
204 $data = GutenbergController::GutenbergDataImportAction($data);
205 Engagement::MarkUsed(Engagement::FEATURE_PATTERN);
206 return rest_ensure_response(array("content" => $data['content'], "name" => esc_html(isset($data['title']) ? $data['title'] : '')));
207 } catch (RequestException $rex) {
208 return new \WP_Error('internal_error_request', 'Internal Request Error: ' . esc_html($rex->getMessage()), array('status' => $rex->getCode()));
209 } catch (Exception $ex) {
210 LogController::HandleException($ex);
211 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
212 }
213 }
214
215 /**
216 * Fetches unified Gutenberg library data from the v2 API.
217 * Warm cache: returns immediately.
218 * Cold cache: uses paginated proxy requests. Returns first page with _loading flag,
219 * JS drives remaining chunk loading via the chunk endpoint.
220 */
221 private function FetchGutenbergLibraryData()
222 {
223 // 1. Warm cache — instant return
224 $cache = CacheController::GetCache(GutenbergCache::LIBRARY, CacheTypes::GUTENBERG);
225 if (!!$cache) {
226 return $cache;
227 }
228
229 // 2. Check for resumable partial cache
230 $partial = CacheController::GetDataCacheDirect(GutenbergCache::LIBRARY_PARTIAL);
231 if ($partial !== false && isset($partial->_pagination)) {
232 // Return partial data so JS can resume chunk loading
233 $partial->_loading = true;
234 return $partial;
235 }
236
237 // 3. Stampede prevention: check if another request is already loading
238 $loading_lock = get_transient(ChunkLoading::LOADING_TRANSIENT);
239 if ($loading_lock !== false) {
240 // Check for stale lock: if partial cache exists but hasn't been updated in >60s, override
241 $partial_raw = CacheController::GetDataCacheRaw(GutenbergCache::LIBRARY_PARTIAL);
242 if ($partial_raw !== false && isset($partial_raw['last_update'])) {
243 if (time() - $partial_raw['last_update'] > 60) {
244 // Stale lock — allow override
245 delete_transient(ChunkLoading::LOADING_TRANSIENT);
246 } else {
247 // Another request is actively loading — serve partial if available
248 if (isset($partial_raw['data']) && is_object($partial_raw['data']) && isset($partial_raw['data']->_pagination)) {
249 $partial_raw['data']->_loading = true;
250 return $partial_raw['data'];
251 }
252 // No partial yet — tell JS to retry
253 $retry = new \stdClass();
254 $retry->_retry = true;
255 $retry->_retry_after = 3;
256 return $retry;
257 }
258 } else if ($loading_lock !== false) {
259 // Lock exists but no partial cache yet — tell JS to retry
260 $retry = new \stdClass();
261 $retry->_retry = true;
262 $retry->_retry_after = 3;
263 return $retry;
264 }
265 }
266
267 // 4. Set loading lock (2 min TTL)
268 set_transient(ChunkLoading::LOADING_TRANSIENT, true, 120);
269
270 // 5. Fetch page 1 from proxy
271 $options_controller = new OptionController();
272 $license_key = $options_controller->GetKey();
273
274 $response = DomainShiftController::RemoteGet(self::GUTENBERG_V2_ENDPOINT . '?action=list&page=1&perPage=100&key=' . $license_key);
275 if (!is_array($response) || is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
276 delete_transient(ChunkLoading::LOADING_TRANSIENT);
277 throw new RequestException('Plugin Service Unavailable', 503);
278 }
279
280 $body = wp_remote_retrieve_body($response);
281 $data = json_decode($body);
282
283 if (!is_object($data)) {
284 delete_transient(ChunkLoading::LOADING_TRANSIENT);
285 throw new RequestException('Invalid response from library service', 502);
286 }
287 if (isset($data->code) && isset($data->data) && isset($data->message)) {
288 delete_transient(ChunkLoading::LOADING_TRANSIENT);
289 $status = isset($data->data->status) ? $data->data->status : 500;
290 throw new RequestException(esc_html($data->message), intval($status));
291 }
292 if (!isset($data->patterns) || !isset($data->pages)) {
293 delete_transient(ChunkLoading::LOADING_TRANSIENT);
294 throw new RequestException('Unexpected response format from library service', 502);
295 }
296 if (isset($data->level)) {
297 KeyController::UpdateKeyType($data->level, $data->active, $data->expired, $data->exceeded);
298 }
299
300 // Sort categories and industries by sort_order
301 if (isset($data->patterns->categories)) {
302 self::SortByOrder($data->patterns->categories);
303 }
304 if (isset($data->pages->categories)) {
305 self::SortByOrder($data->pages->categories);
306 }
307 if (isset($data->industries)) {
308 self::SortByOrder($data->industries);
309 }
310
311 // Sort items alphabetically by name
312 if (isset($data->patterns->items)) {
313 self::SortItemsByName($data->patterns->items);
314 }
315 if (isset($data->pages->items)) {
316 self::SortItemsByName($data->pages->items);
317 }
318
319 // Check if all data fits in page 1 (no more chunks needed)
320 $pagination = isset($data->pagination) ? $data->pagination : null;
321 $patterns_total = ($pagination && isset($pagination->patterns->totalPages)) ? $pagination->patterns->totalPages : 1;
322 $pages_total = ($pagination && isset($pagination->pages->totalPages)) ? $pagination->pages->totalPages : 1;
323
324 if ($patterns_total <= 1 && $pages_total <= 1) {
325 // Everything fits in one page — cache as full and return without _loading
326 CacheController::SetCache(GutenbergCache::LIBRARY, $data);
327 delete_transient(ChunkLoading::LOADING_TRANSIENT);
328
329 // Clean up legacy v1 caches
330 CacheController::ClearCache(GutenbergCache::PATTERNS);
331 CacheController::ClearCache(GutenbergCache::PAGES);
332
333 return $data;
334 }
335
336 // Store pagination metadata and loaded pages on the data object
337 $data->_pagination = $pagination;
338 $loaded_pages = new \stdClass();
339 $loaded_pages->patterns = array(1);
340 $loaded_pages->pages = array(1);
341 $data->_pagination->loaded_pages = $loaded_pages;
342 $data->_loading = true;
343
344 // Store as partial cache
345 CacheController::SetCache(GutenbergCache::LIBRARY_PARTIAL, $data);
346
347 // Clean up legacy v1 caches
348 CacheController::ClearCache(GutenbergCache::PATTERNS);
349 CacheController::ClearCache(GutenbergCache::PAGES);
350
351 return $data;
352 }
353
354 /**
355 * Chunk endpoint: fetches subsequent pages of library data.
356 */
357 public function GutenbergV2ListChunkCallback($request)
358 {
359 try {
360 $page = isset($request['page']) ? absint($request['page']) : 0;
361 if ($page < 2) {
362 return new \WP_Error('invalid_page', 'Page parameter must be >= 2', array('status' => 400));
363 }
364
365 // Rate limiting: prevent chunk request floods per user
366 $user_id = get_current_user_id();
367 $rate_key = 'spb_chunk_last_' . $user_id;
368 $last_chunk_time = get_transient($rate_key);
369 if ($last_chunk_time !== false && (microtime(true) - floatval($last_chunk_time)) < 0.5) {
370 return new \WP_Error('too_many_requests', 'Please slow down', array('status' => 429));
371 }
372 set_transient($rate_key, microtime(true), 10);
373
374 // Guard: if full cache is already warm, serve from it directly
375 $full_cache = CacheController::GetCache(GutenbergCache::LIBRARY, CacheTypes::GUTENBERG);
376 if (!!$full_cache) {
377 $result = new \stdClass();
378 $result->_complete = true;
379 return rest_ensure_response($result);
380 }
381
382 $chunk = $this->FetchAndMergeChunk($page);
383 return rest_ensure_response($chunk);
384 } catch (CacheException $cex) {
385 return new \WP_Error('internal_error_cache', 'Internal Cache Error: ' . esc_html($cex->getMessage()), array('status' => 500));
386 } catch (RequestException $rex) {
387 return new \WP_Error('internal_error_request', 'Internal Request Error: ' . esc_html($rex->getMessage()), array('status' => $rex->getCode()));
388 } catch (Exception $ex) {
389 LogController::HandleException($ex);
390 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
391 }
392 }
393
394 /**
395 * Warm-cache endpoint: drives sequential chunk loading until the full LIBRARY cache
396 * is assembled. Each call fetches at most one chunk and returns a status snapshot
397 * so a client interstitial can poll to completion.
398 *
399 * Response: { status: 'ready' | 'loading' | 'error', progress?: {...} }
400 */
401 public function GutenbergV2WarmCacheCallback($request)
402 {
403 try {
404 // 1. Fast path — full cache warm
405 $full_cache = CacheController::GetCache(GutenbergCache::LIBRARY, CacheTypes::GUTENBERG);
406 if (!!$full_cache) {
407 return rest_ensure_response(array('status' => 'ready'));
408 }
409
410 // 2. Cold cache — trigger page 1 load (handles stampede via LOADING_TRANSIENT)
411 $partial = CacheController::GetDataCacheDirect(GutenbergCache::LIBRARY_PARTIAL);
412 if ($partial === false || !isset($partial->_pagination)) {
413 $data = $this->FetchGutenbergLibraryData();
414 if (isset($data->_retry) && $data->_retry) {
415 return rest_ensure_response(array('status' => 'loading', 'progress' => null));
416 }
417 $full_cache = CacheController::GetCache(GutenbergCache::LIBRARY, CacheTypes::GUTENBERG);
418 if (!!$full_cache) {
419 return rest_ensure_response(array('status' => 'ready'));
420 }
421 $partial = CacheController::GetDataCacheDirect(GutenbergCache::LIBRARY_PARTIAL);
422 if ($partial === false || !isset($partial->_pagination)) {
423 return rest_ensure_response(array('status' => 'loading', 'progress' => null));
424 }
425 }
426
427 // 3. Warm loop — fetch the next missing chunk
428 $next_page = self::PickNextChunkPage($partial);
429 if ($next_page !== null) {
430 $this->FetchAndMergeChunk($next_page);
431 }
432
433 // 4. Re-read state after potential merge/promotion
434 $full_cache = CacheController::GetCache(GutenbergCache::LIBRARY, CacheTypes::GUTENBERG);
435 if (!!$full_cache) {
436 return rest_ensure_response(array('status' => 'ready'));
437 }
438 $partial = CacheController::GetDataCacheDirect(GutenbergCache::LIBRARY_PARTIAL);
439 return rest_ensure_response(array('status' => 'loading', 'progress' => self::BuildProgress($partial)));
440 } catch (CacheException $cex) {
441 return new \WP_Error('internal_error_cache', 'Internal Cache Error: ' . esc_html($cex->getMessage()), array('status' => 500));
442 } catch (RequestException $rex) {
443 return new \WP_Error('internal_error_request', 'Internal Request Error: ' . esc_html($rex->getMessage()), array('status' => $rex->getCode()));
444 } catch (Exception $ex) {
445 LogController::HandleException($ex);
446 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
447 }
448 }
449
450 /**
451 * Fetches a single page from the library proxy, merges items into the partial cache,
452 * and promotes to full cache when all pages are loaded. Returns the chunk object
453 * (with _complete set when promotion happens).
454 */
455 private function FetchAndMergeChunk($page)
456 {
457 // Race guard: if this page's needed sections are already loaded, skip the remote fetch.
458 $partial_raw = CacheController::GetDataCacheRaw(GutenbergCache::LIBRARY_PARTIAL);
459 if ($partial_raw !== false && isset($partial_raw['data']) && is_object($partial_raw['data']) && isset($partial_raw['data']->_pagination)) {
460 $pag = $partial_raw['data']->_pagination;
461 $patterns_total_pages = isset($pag->patterns->totalPages) ? intval($pag->patterns->totalPages) : 1;
462 $pages_total_pages = isset($pag->pages->totalPages) ? intval($pag->pages->totalPages) : 1;
463 $loaded_pattern_pages = (isset($pag->loaded_pages->patterns) && is_array($pag->loaded_pages->patterns)) ? $pag->loaded_pages->patterns : array();
464 $loaded_page_pages = (isset($pag->loaded_pages->pages) && is_array($pag->loaded_pages->pages)) ? $pag->loaded_pages->pages : array();
465 $needs_patterns = ($page <= $patterns_total_pages) && !in_array($page, $loaded_pattern_pages, true);
466 $needs_pages = ($page <= $pages_total_pages) && !in_array($page, $loaded_page_pages, true);
467 if (!$needs_patterns && !$needs_pages) {
468 return new \stdClass();
469 }
470 }
471
472 $options_controller = new OptionController();
473 $license_key = $options_controller->GetKey();
474
475 $response = DomainShiftController::RemoteGet(self::GUTENBERG_V2_ENDPOINT . '?action=list&page=' . $page . '&perPage=100&key=' . $license_key);
476 if (!is_array($response) || is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
477 throw new RequestException('Plugin Service Unavailable', 503);
478 }
479
480 $body = wp_remote_retrieve_body($response);
481 $chunk = json_decode($body);
482
483 if (!is_object($chunk)) {
484 throw new RequestException('Invalid chunk response from library service', 502);
485 }
486
487 // Apply requirement status checks to chunk items
488 if (isset($chunk->patterns->items)) {
489 $this->UpdatePatternRequirementStatus($chunk->patterns);
490 }
491 if (isset($chunk->pages->items)) {
492 $this->UpdatePatternRequirementStatus($chunk->pages);
493 }
494
495 // Re-read partial to get latest state after possible concurrent merge
496 $partial_raw = CacheController::GetDataCacheRaw(GutenbergCache::LIBRARY_PARTIAL);
497 if ($partial_raw !== false && isset($partial_raw['data']) && is_object($partial_raw['data'])) {
498 $partial = $partial_raw['data'];
499
500 // Merge pattern items (guarded against double-append if another request beat us here)
501 if (isset($chunk->patterns->items) && is_array($chunk->patterns->items)) {
502 if (!isset($partial->patterns->items) || !is_array($partial->patterns->items)) {
503 $partial->patterns->items = array();
504 }
505 $already_loaded = isset($partial->_pagination->loaded_pages->patterns) && in_array($page, $partial->_pagination->loaded_pages->patterns, true);
506 if (!$already_loaded) {
507 $partial->patterns->items = array_merge($partial->patterns->items, $chunk->patterns->items);
508 if (isset($partial->_pagination->loaded_pages->patterns)) {
509 $partial->_pagination->loaded_pages->patterns[] = $page;
510 }
511 }
512 }
513
514 // Merge page items
515 if (isset($chunk->pages->items) && is_array($chunk->pages->items)) {
516 if (!isset($partial->pages->items) || !is_array($partial->pages->items)) {
517 $partial->pages->items = array();
518 }
519 $already_loaded = isset($partial->_pagination->loaded_pages->pages) && in_array($page, $partial->_pagination->loaded_pages->pages, true);
520 if (!$already_loaded) {
521 $partial->pages->items = array_merge($partial->pages->items, $chunk->pages->items);
522 if (isset($partial->_pagination->loaded_pages->pages)) {
523 $partial->_pagination->loaded_pages->pages[] = $page;
524 }
525 }
526 }
527
528 // Check if all pages are now loaded
529 $patterns_total = isset($partial->_pagination->patterns->totalPages) ? $partial->_pagination->patterns->totalPages : 1;
530 $pages_total = isset($partial->_pagination->pages->totalPages) ? $partial->_pagination->pages->totalPages : 1;
531 $patterns_loaded = isset($partial->_pagination->loaded_pages->patterns) ? count($partial->_pagination->loaded_pages->patterns) : 0;
532 $pages_loaded = isset($partial->_pagination->loaded_pages->pages) ? count($partial->_pagination->loaded_pages->pages) : 0;
533
534 $all_complete = ($patterns_loaded >= $patterns_total) && ($pages_loaded >= $pages_total);
535
536 if ($all_complete) {
537 // Sort assembled data before promoting to full cache
538 if (isset($partial->patterns->items)) {
539 self::SortItemsByName($partial->patterns->items);
540 }
541 if (isset($partial->pages->items)) {
542 self::SortItemsByName($partial->pages->items);
543 }
544 if (isset($partial->patterns->categories)) {
545 self::SortByOrder($partial->patterns->categories);
546 }
547 if (isset($partial->pages->categories)) {
548 self::SortByOrder($partial->pages->categories);
549 }
550 if (isset($partial->industries)) {
551 self::SortByOrder($partial->industries);
552 }
553
554 // Remove loading metadata before promoting
555 unset($partial->_pagination);
556 unset($partial->_loading);
557
558 // Promote partial -> full cache
559 CacheController::SetCache(GutenbergCache::LIBRARY, $partial);
560 CacheController::ClearCache(GutenbergCache::LIBRARY_PARTIAL);
561 delete_transient(ChunkLoading::LOADING_TRANSIENT);
562
563 $chunk->_complete = true;
564 } else {
565 // Update partial cache with merged data
566 CacheController::SetCache(GutenbergCache::LIBRARY_PARTIAL, $partial);
567 }
568 }
569
570 return $chunk;
571 }
572
573 /**
574 * Picks the next page number needed from the partial cache pagination metadata.
575 * Mirrors the library JS chunk loader's selection logic. Returns null when done.
576 */
577 private static function PickNextChunkPage($partial)
578 {
579 if (!isset($partial->_pagination)) {
580 return null;
581 }
582 $pag = $partial->_pagination;
583 $patterns_total_pages = isset($pag->patterns->totalPages) ? intval($pag->patterns->totalPages) : 1;
584 $pages_total_pages = isset($pag->pages->totalPages) ? intval($pag->pages->totalPages) : 1;
585 $max_pages = max($patterns_total_pages, $pages_total_pages);
586
587 $loaded_pattern_pages = (isset($pag->loaded_pages->patterns) && is_array($pag->loaded_pages->patterns)) ? $pag->loaded_pages->patterns : array(1);
588 $loaded_page_pages = (isset($pag->loaded_pages->pages) && is_array($pag->loaded_pages->pages)) ? $pag->loaded_pages->pages : array(1);
589
590 for ($p = 2; $p <= $max_pages; $p++) {
591 $needs_patterns = ($p <= $patterns_total_pages) && !in_array($p, $loaded_pattern_pages, true);
592 $needs_pages = ($p <= $pages_total_pages) && !in_array($p, $loaded_page_pages, true);
593 if ($needs_patterns || $needs_pages) {
594 return $p;
595 }
596 }
597 return null;
598 }
599
600 /**
601 * Builds the progress payload returned by the warm-cache endpoint.
602 */
603 private static function BuildProgress($partial)
604 {
605 if (!is_object($partial) || !isset($partial->_pagination)) {
606 return null;
607 }
608 $pag = $partial->_pagination;
609 return array(
610 'patterns' => array(
611 'loaded' => isset($pag->loaded_pages->patterns) ? count($pag->loaded_pages->patterns) : 0,
612 'total' => isset($pag->patterns->totalPages) ? intval($pag->patterns->totalPages) : 1,
613 ),
614 'pages' => array(
615 'loaded' => isset($pag->loaded_pages->pages) ? count($pag->loaded_pages->pages) : 0,
616 'total' => isset($pag->pages->totalPages) ? intval($pag->pages->totalPages) : 1,
617 ),
618 );
619 }
620
621 /**
622 * Sort array of objects by sort_order, then remove the sort_order property.
623 */
624 private static function SortByOrder(&$items)
625 {
626 if (!is_array($items) || empty($items)) {
627 return;
628 }
629 usort($items, function ($a, $b) {
630 $a_order = isset($a->sort_order) ? $a->sort_order : 0;
631 $b_order = isset($b->sort_order) ? $b->sort_order : 0;
632 return $a_order - $b_order;
633 });
634 foreach ($items as $item) {
635 if (isset($item->sort_order)) {
636 unset($item->sort_order);
637 }
638 }
639 }
640
641 private static function SortItemsByName(&$items)
642 {
643 if (!is_array($items) || empty($items)) {
644 return;
645 }
646 usort($items, function ($a, $b) {
647 if (!isset($a->name) || !isset($b->name)) {
648 return 0;
649 }
650 return strnatcmp($a->name, $b->name);
651 });
652 }
653
654 /**
655 * v2 insert: calls the unified v2 endpoint with a type parameter.
656 */
657 public static function GetInsertDataV2($request, $type)
658 {
659 $options_controller = new OptionController();
660 $license_key = $options_controller->GetKey();
661 if (!isset($request['id']) || !isset($request['package']) || $request['package'] === "premium" && !$license_key) {
662 throw new RequestException("Forbidden", 403);
663 }
664
665 $stamp = $options_controller->GetStamp();
666 $package = $request['package'] === 'premium' ? 'premium' : 'free';
667 $id = urlencode(sanitize_text_field($request['id']));
668 $response = DomainShiftController::RemoteGet(self::GUTENBERG_V2_ENDPOINT . '?action=insert&type=' . $type . '&id=' . $id . '&package=' . $package . '&key=' . urlencode($license_key) . '&stamp=' . absint($stamp));
669 ///
670 if (!is_array($response) || is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
671 throw new RequestException('Plugin Service Unavailable', 503);
672 }
673 ///
674 $data = json_decode($response['body'], true);
675 if (isset($data['code']) && isset($data['data']) && isset($data['message'])) {
676 $status = isset($data['data']['status']) ? $data['data']['status'] : 500;
677 throw new RequestException(esc_html($data['message']), intval($status));
678 }
679 if (isset($data['level'])) {
680 KeyController::UpdateKeyType($data['level'], $data['active'], $data['expired'], $data['exceeded']);
681 }
682 if (!isset($data['verified']) || !$data['verified']) {
683 KeyController::VerificationFailed();
684 }
685
686 $data['premium'] = KeyController::HasValidPremiumKey();
687
688 return $data;
689 }
690
691 // ─── Shared ─────────────────────────────────────────────────────────
692
693 private function UpdatePatternRequirementStatus(&$data)
694 {
695 if (!function_exists('is_plugin_active')) {
696 require_once(ABSPATH . 'wp-admin/includes/plugin.php');
697 }
698 // Check plugin and library version requirements
699 foreach ($data->items as $item) {
700 $item->external_plugin_required = false;
701 $item->plugin_update_required = false;
702 if (isset($item->required_plugins)) {
703 foreach ($item->required_plugins as $required_plugin) {
704 if (!is_plugin_active($required_plugin)) {
705 $item->external_plugin_required = true;
706 $item->required_plugin_names = isset($item->required_plugin_names) ? $item->required_plugin_names : array();
707 if (isset(self::PLUGIN_NAMES[$required_plugin])) {
708 $item->required_plugin_names[] = self::PLUGIN_NAMES[$required_plugin];
709 } else {
710 $item->required_plugin_names[] = esc_attr(explode('/', $required_plugin)[0]);
711 }
712 }
713 }
714 }
715 if (isset($item->required_library) && version_compare($item->required_library, SUPERBADDONS_LIBRARY_VERSION, '>')) {
716 $item->plugin_update_required = true;
717 }
718 }
719 }
720
721 private function ElementorInsertHandler($request)
722 {
723 try {
724 $options_controller = new OptionController();
725 $license_key = $options_controller->GetKey();
726 if (!isset($request['id']) || !isset($request['package']) || $request['package'] === "premium" && !$license_key) {
727 throw new RequestException("Forbidden", 403);
728 }
729
730 $stamp = $options_controller->GetStamp();
731 $collection = $request['package'] === 'premium' ? "premium" : "free";
732 $id = urlencode(sanitize_text_field($request['id']));
733 $response = DomainShiftController::RemoteGet(self::ELEMENTOR_ENDPOINT_BASE . 'sections?action=insert&id=' . $id . '&collection=' . $collection . '&key=' . urlencode($license_key) . '&stamp=' . absint($stamp));
734 if (!is_array($response) || is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
735 throw new RequestException('Plugin Service Unavailable', 503);
736 }
737
738 $data = json_decode($response['body'], true);
739 if (isset($data['code']) && isset($data['data']) && isset($data['message'])) {
740 $status = isset($data['data']['status']) ? $data['data']['status'] : 500;
741 throw new RequestException(esc_html($data['message']), intval($status));
742 }
743 if (isset($data['level'])) {
744 KeyController::UpdateKeyType($data['level'], $data['active'], $data['expired'], $data['exceeded']);
745 }
746 if (!isset($data['verified']) || !$data['verified']) {
747 KeyController::VerificationFailed();
748 }
749
750 $data['premium'] = KeyController::HasValidPremiumKey();
751
752 if (isset($data['access_failed'])) {
753 return rest_ensure_response($data);
754 }
755
756 $data = ElementorController::ElementorDataImportAction($data);
757 return rest_ensure_response($data['content']);
758 } catch (RequestException $rex) {
759 return new \WP_Error('internal_error_request', 'Internal Request Error: ' . esc_html($rex->getMessage()), array('status' => $rex->getCode()));
760 } catch (Exception $ex) {
761 LogController::HandleException($ex);
762 return new \WP_Error('internal_error_plugin', 'Internal Plugin Error', array('status' => 500));
763 }
764 }
765 }
766