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