PluginProbe
PatternsWP – Gutenberg Block Patterns & Page Templates Library / 1.1.0
PatternsWP – Gutenberg Block Patterns & Page Templates Library v1.1.0
1.1.0 trunk 1.0.0 1.0.1 1.0.10 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9
patternswp / includes / class-patternswp-api.php

class-patternswp-api.php in PatternsWP – Gutenberg Block Patterns & Page Templates Library 1.1.0, at includes/class-patternswp-api.php

1,722 lines 56.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // Exit if accessed directly
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 class PatternsWP_API_Section {
8
9 public $api_url;
10
11 /** @var PatternsWP_API_Section|null */
12 private static $instance = null;
13
14 /** In-request library memo, keyed by license scope. */
15 private $library_runtime = array();
16
17 /**
18 * Get singleton instance.
19 *
20 * @return PatternsWP_API_Section
21 */
22 public static function get_instance() {
23 if ( null === self::$instance ) {
24 self::$instance = new self();
25 }
26 return self::$instance;
27 }
28
29 /**
30 * Constructor
31 */
32 private function __construct() {
33 add_action( 'admin_init', array( $this, 'register_patterns_endpoint') );
34 add_action( 'patternswp_daily_transient_load', array( $this, 'patternswp_save_transient_if_not_ajax' ) );
35 add_action( 'patternswp_library_warm', array( $this, 'warm_library' ) );
36
37 $this->api_url = 'https://pwp4.thepatternswp.com/';
38 }
39
40 /**
41 * Register patterns from the site-wide library cache.
42 */
43 public function register_patterns_endpoint() {
44 $library = $this->get_library( false );
45
46 if ( empty( $library ) ) {
47 if ( ! wp_doing_cron() && ! wp_next_scheduled( 'patternswp_daily_transient_load' ) ) {
48 wp_schedule_single_event( time() + 5, 'patternswp_daily_transient_load' );
49 }
50 return;
51 }
52
53 try {
54 $this->register_patterns_and_categories( $library );
55 } catch ( \Throwable $e ) {
56 return;
57 }
58 }
59
60 /**
61 * Get patterns for the library modal.
62 *
63 * Serves from a site-wide cache and filters locally so category clicks,
64 * search, and pagination never hit the remote API.
65 *
66 * @param int $page Page number.
67 * @param int $p_per_page Patterns per page.
68 * @param string $search Search query.
69 * @param string $category Category slug, or empty for all.
70 * @return array
71 */
72 public function get_patternswp_pattern( $page = 1, $p_per_page = 15, $search = '', $category = '' ) {
73 $page = max( 1, intval( $page ) );
74 $p_per_page = max( 1, intval( $p_per_page ) );
75 $search = is_string( $search ) ? $search : '';
76 $category = is_string( $category ) ? $category : '';
77
78 $patterns = $this->get_library( false );
79 if ( empty( $patterns ) ) {
80 $this->schedule_library_refresh();
81 $seeded = $this->seed_library_for_request( $page, $p_per_page, $search, $category );
82 $seeded = $this->filter_patterns_by_category( $seeded, $category );
83 $seeded = $this->filter_patterns_by_search( $seeded, $search );
84 $seeded = apply_filters( 'patternswp_patterns', $seeded );
85 $seeded = $this->normalize_patterns_list( $seeded );
86
87 if ( '' === $search ) {
88 return array_values( array_slice( $seeded, 0, $p_per_page ) );
89 }
90
91 $offset = ( $page - 1 ) * $p_per_page;
92 return array_values( array_slice( $seeded, $offset, $p_per_page ) );
93 } elseif ( '' !== $category && ! $this->is_library_complete() ) {
94 $in_category = $this->filter_patterns_by_category( $patterns, $category );
95 if ( empty( $in_category ) ) {
96 $this->schedule_library_refresh();
97 $extra = $this->fetch_paginated_page( 1, 20, $category );
98 if ( ! empty( $extra ) ) {
99 $patterns = $this->merge_pattern_libraries( $extra, $patterns );
100 $this->store_library_cache( $this->get_library_scope(), $patterns );
101 $this->library_runtime[ $this->get_library_scope() ] = $patterns;
102 }
103 }
104 }
105
106 if ( empty( $patterns ) ) {
107 return array();
108 }
109
110 $filtered = $this->filter_patterns_by_category( $patterns, $category );
111 $filtered = $this->filter_patterns_by_search( $filtered, $search );
112 $filtered = apply_filters( 'patternswp_patterns', $filtered );
113 $filtered = $this->normalize_patterns_list( $filtered );
114
115 $offset = ( $page - 1 ) * $p_per_page;
116 $slice = array_values( array_slice( $filtered, $offset, $p_per_page ) );
117
118 if ( empty( $slice ) && ! $this->is_library_complete() && '' === $search ) {
119 $this->schedule_library_refresh();
120 $remote = $this->fetch_paginated_page( $page, min( 20, max( 15, $p_per_page ) ), $category );
121 if ( ! empty( $remote ) ) {
122 $scope = $this->get_library_scope();
123 $patterns = $this->merge_pattern_libraries( $remote, $patterns );
124 $this->store_library_cache( $scope, $patterns );
125 $this->library_runtime[ $scope ] = $patterns;
126
127 $filtered = $this->filter_patterns_by_category( $patterns, $category );
128 $filtered = apply_filters( 'patternswp_patterns', $filtered );
129 $filtered = $this->normalize_patterns_list( $filtered );
130 $slice = array_values( array_slice( $filtered, $offset, $p_per_page ) );
131
132 if ( empty( $slice ) ) {
133 return array_values( array_slice( $remote, 0, $p_per_page ) );
134 }
135 }
136 }
137
138 return $slice;
139 }
140
141 /**
142 * Get patterns category type
143 */
144 public function get_patternswp_category_type( $manually = true ) {
145 $transient_key = 'patternswp_category_type';
146 $categories = get_transient( $transient_key );
147
148 if ( false === $categories ) {
149 if ( ! wp_doing_ajax() && ! wp_doing_cron() ) {
150 $this->schedule_library_refresh();
151 $categories = $this->get_fallback_categories();
152 } else {
153 $categories = $this->remote_get_json(
154 $this->api_url . 'wp-json/patternswp_pattens_category_types/v1/patterns',
155 8
156 );
157
158 if ( ! is_array( $categories ) || empty( $categories ) ) {
159 $this->schedule_library_refresh();
160 return $this->get_fallback_categories();
161 }
162
163 set_transient( $transient_key, $categories, DAY_IN_SECONDS );
164 }
165 }
166
167 if ( $manually && is_array( $categories ) ) {
168 foreach ( $categories as $category ) {
169 if ( ! is_array( $category ) || empty( $category['name'] ) || ! is_string( $category['name'] ) ) {
170 continue;
171 }
172 register_block_pattern_category(
173 $category['name'],
174 array( 'label' => $this->get_category_label( $category['name'] ) )
175 );
176 }
177 }
178
179 return is_array( $categories ) ? $categories : array();
180 }
181
182 /**
183 * Get license data
184 */
185 public function get_license_data() {
186 $license_key = '';
187 $get_license_data = get_option( 'patternswp_plugin_license_data' );
188 $stored_option = get_option( 'patternswp_license_key', array() );
189
190 if ( is_array( $stored_option ) && ! empty( $stored_option['patternswp_pro_license_key'] ) ) {
191 $license_key = (string) $stored_option['patternswp_pro_license_key'];
192 } elseif ( is_string( $stored_option ) ) {
193 $license_key = $stored_option;
194 }
195
196 if ( is_array( $get_license_data ) && ! empty( $get_license_data['license_key'] ) && '' === $license_key ) {
197 $stored = $get_license_data['license_key'];
198 if ( is_object( $stored ) && isset( $stored->key ) ) {
199 $license_key = (string) $stored->key;
200 } elseif ( is_array( $stored ) && isset( $stored['key'] ) ) {
201 $license_key = (string) $stored['key'];
202 } elseif ( is_string( $stored ) ) {
203 $license_key = $stored;
204 }
205 }
206
207 $activated = is_array( $get_license_data ) && isset( $get_license_data['activated'] )
208 ? $get_license_data['activated']
209 : false;
210
211 return array(
212 'license_key' => $license_key,
213 'licensestatus' => $this->is_activated_flag( $activated ) && '' !== trim( $license_key ),
214 );
215 }
216
217 /**
218 * Whether Pro is unlocked for this site.
219 *
220 * Requires a stored license key AND a verified activation flag.
221 *
222 * @return bool
223 */
224 public function is_license_active() {
225 $license = $this->get_license_data();
226 return ! empty( $license['licensestatus'] );
227 }
228
229 /**
230 * @param mixed $value Raw `activated` flag from license storage.
231 * @return bool
232 */
233 private function is_activated_flag( $value ) {
234 if ( true === $value || 1 === $value || '1' === $value ) {
235 return true;
236 }
237
238 return is_string( $value ) && 'true' === strtolower( $value );
239 }
240
241 /**
242 * Daily cache warm (forced).
243 */
244 public function patternswp_save_transient_if_not_ajax() {
245 $this->refresh_library( true );
246 $this->get_patternswp_category_type( false );
247 update_option( 'patternswp_lib_checked_at', time(), false );
248 }
249
250 /**
251 * Resume a time-budgeted library download.
252 */
253 public function warm_library() {
254 $this->refresh_library( false, wp_doing_ajax() ? 10 : 18 );
255 $this->get_patternswp_category_type( false );
256 }
257
258 /**
259 * Refresh the shared library once. Category pages are served locally.
260 */
261 public function patternswp_save_transient_wise_category() {
262 $this->refresh_library( true );
263 $this->get_patternswp_category_type( false );
264 }
265
266 /**
267 * Drop cached catalogs and queue a background refill.
268 */
269 public function purge_library_caches() {
270 $this->delete_library_cache( 'free' );
271 $this->delete_library_cache( 'pro' );
272 $this->delete_legacy_pattern_cache();
273 $this->delete_library_file( 'free' );
274 $this->delete_library_file( 'pro' );
275 delete_transient( 'patternswp_category_type' );
276 delete_transient( 'patternswp_library_lock' );
277 delete_option( 'patternswp_lib_state' );
278 delete_option( 'patternswp_lib_checked_at' );
279 $this->library_runtime = array();
280 }
281
282 /**
283 * Queue WP-Cron to finish downloading the catalog without blocking admin AJAX.
284 *
285 * @param int $delay Seconds to wait before the first warm run.
286 */
287 public function schedule_library_refresh( $delay = 1 ) {
288 $hook = 'patternswp_library_warm';
289 if ( ! wp_next_scheduled( $hook ) ) {
290 wp_schedule_single_event( time() + max( 0, (int) $delay ), $hook );
291 if ( ! defined( 'DISABLE_WP_CRON' ) || ! DISABLE_WP_CRON ) {
292 spawn_cron();
293 }
294 }
295
296 if ( ! wp_next_scheduled( 'patternswp_daily_transient_load' ) ) {
297 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'patternswp_daily_transient_load' );
298 }
299 }
300
301 /**
302 * Start a background download when this site has no catalog yet.
303 */
304 public function maybe_schedule_warm() {
305 if ( empty( $this->get_library( false ) ) || ! $this->is_library_complete() ) {
306 $this->schedule_library_refresh( 1 );
307 }
308 }
309
310 /**
311 * Whether the cached catalog is complete enough to serve locally.
312 *
313 * @return bool
314 */
315 public function is_catalog_ready() {
316 return $this->is_library_complete();
317 }
318
319 /**
320 * Progress payload for the editor warmer.
321 *
322 * @param bool $has_updates Whether new remote patterns were merged.
323 * @return array
324 */
325 public function get_library_status( $has_updates = false ) {
326 $patterns = $this->get_library( false );
327
328 return array(
329 'total' => count( $patterns ),
330 'complete' => $this->is_library_complete(),
331 'categories' => $this->get_patternswp_category_type( false ),
332 'has_updates' => (bool) $has_updates,
333 );
334 }
335
336 /**
337 * Cheap check for newly published remote patterns.
338 *
339 * @param bool $force Ignore the 6-hour throttle.
340 * @return array
341 */
342 public function maybe_pull_remote_updates( $force = false ) {
343 $last = (int) get_option( 'patternswp_lib_checked_at', 0 );
344 $complete = $this->is_library_complete();
345
346 if ( ! $force && $complete && $last && ( time() - $last ) < ( 6 * HOUR_IN_SECONDS ) ) {
347 return $this->get_library_status();
348 }
349
350 $before = count( $this->get_library( false ) );
351 $batch = $this->fetch_paginated_page( 1, 20, '' );
352 $pro = $this->fetch_paginated_page( 1, 20, '', 'pro' );
353 update_option( 'patternswp_lib_checked_at', time(), false );
354
355 $has_updates = false;
356 $scope = $this->get_library_scope();
357 $merged = $this->get_library( false );
358
359 if ( ! empty( $batch ) ) {
360 $merged = $this->merge_pattern_libraries( $batch, $merged );
361 }
362 if ( ! empty( $pro ) ) {
363 $merged = $this->merge_pattern_libraries( $pro, $merged );
364 }
365
366 if ( ! empty( $batch ) || ! empty( $pro ) ) {
367 $this->store_library_cache( $scope, $merged );
368 $this->library_runtime[ $scope ] = $merged;
369
370 if ( count( $merged ) > $before ) {
371 $has_updates = true;
372 $state = $this->get_refresh_state();
373 $state['complete'] = false;
374 $state['phase'] = 'paginated';
375 $state['empty_streak'] = 0;
376 $per = max( 1, (int) $state['per_page'] );
377 $state['page'] = max( 2, (int) ceil( count( $merged ) / $per ) );
378 $this->set_refresh_state( $state );
379 }
380 }
381
382 if ( $force || false === get_transient( 'patternswp_category_type' ) ) {
383 delete_transient( 'patternswp_category_type' );
384 $this->get_patternswp_category_type( false );
385 }
386
387 return $this->get_library_status( $has_updates );
388 }
389
390 /**
391 * One short catalog burst for editor AJAX. Safe on hosts with 30s limits.
392 *
393 * @return array
394 */
395 public function warm_library_for_request() {
396 $this->maybe_classify_pro_patterns();
397 $this->refresh_library( false, 8 );
398 $this->get_patternswp_category_type( false );
399 return $this->get_library_status();
400 }
401
402 /**
403 * Paged library payload for the modal.
404 *
405 * @param int $page Page number.
406 * @param int $p_per_page Patterns per page.
407 * @param string $search Search query.
408 * @param string $category Category slug.
409 * @return array
410 */
411 public function query_library_page( $page = 1, $p_per_page = 15, $search = '', $category = '' ) {
412 $this->maybe_classify_pro_patterns();
413
414 $items = $this->get_patternswp_pattern( $page, $p_per_page, $search, $category );
415
416 $all = $this->get_library( false );
417 $filtered = $this->filter_patterns_by_category( $all, $category );
418 $filtered = $this->filter_patterns_by_search( $filtered, $search );
419 $filtered = apply_filters( 'patternswp_patterns', $filtered );
420 $filtered = $this->normalize_patterns_list( $filtered );
421
422 return array(
423 'patterns' => $this->prepare_patterns_for_client( is_array( $items ) ? $items : array() ),
424 'total' => count( $filtered ),
425 'complete' => $this->is_library_complete(),
426 'categories' => $this->get_cached_categories(),
427 'licenseActive' => $this->is_license_active(),
428 );
429 }
430
431 /**
432 * Categories for the modal without a blocking remote request.
433 *
434 * @return array
435 */
436 private function get_cached_categories() {
437 $categories = get_transient( 'patternswp_category_type' );
438 if ( is_array( $categories ) && ! empty( $categories ) ) {
439 return $categories;
440 }
441
442 return $this->get_fallback_categories();
443 }
444
445 /**
446 * Mark Pro patterns as locked for unlicensed sites.
447 * Markup stays in the payload so free users can see the design and convert.
448 *
449 * @param array $patterns Pattern list.
450 * @return array
451 */
452 private function prepare_patterns_for_client( array $patterns ) {
453 $licensed = $this->is_license_active();
454 $prepared = array();
455
456 foreach ( $patterns as $pattern ) {
457 if ( ! is_array( $pattern ) ) {
458 continue;
459 }
460
461 $is_pro = $this->is_pro_pattern( $pattern );
462 $pattern['type'] = $is_pro ? 'pro' : 'free';
463 $pattern['locked'] = ( $is_pro && ! $licensed );
464 $prepared[] = $pattern;
465 }
466
467 return array_values( $prepared );
468 }
469
470 /**
471 * Stamp Pro types onto a cache that was stored as all-free.
472 */
473 private function maybe_classify_pro_patterns() {
474 if ( $this->library_has_pro_patterns() ) {
475 return;
476 }
477
478 if ( get_transient( 'patternswp_pro_classify_empty' ) ) {
479 return;
480 }
481
482 if ( get_transient( 'patternswp_pro_classify_lock' ) ) {
483 return;
484 }
485
486 set_transient( 'patternswp_pro_classify_lock', 1, MINUTE_IN_SECONDS );
487 $this->classify_pro_patterns( 6 );
488 }
489
490 /**
491 * @return bool
492 */
493 private function library_has_pro_patterns() {
494 foreach ( $this->get_library( false ) as $pattern ) {
495 if ( $this->is_pro_pattern( $pattern ) ) {
496 return true;
497 }
498 }
499
500 return false;
501 }
502
503 /**
504 * Download the Pro catalog stream and mark matching local patterns as Pro.
505 *
506 * @param int $budget Seconds to spend.
507 */
508 private function classify_pro_patterns( $budget = 6 ) {
509 $deadline = time() + max( 1, (int) $budget );
510 $scope = $this->get_library_scope();
511 $patterns = $this->get_library( false );
512 $page = 1;
513 $found = 0;
514
515 $pro_first = $this->fetch_paginated_page( 1, 20, '', 'pro' );
516 $free_first = $this->fetch_paginated_page( 1, 20, '', 'free' );
517 if ( empty( $pro_first ) ) {
518 set_transient( 'patternswp_pro_classify_empty', 1, 6 * HOUR_IN_SECONDS );
519 return;
520 }
521
522 if ( ! empty( $free_first ) && $this->pattern_key_set( $pro_first ) === $this->pattern_key_set( $free_first ) ) {
523 set_transient( 'patternswp_pro_classify_empty', 1, 6 * HOUR_IN_SECONDS );
524 return;
525 }
526
527 $found += count( $pro_first );
528 $patterns = $this->merge_pattern_libraries( $pro_first, $patterns );
529 $page = 2;
530
531 while ( time() < $deadline && $page <= 50 ) {
532 $batch = $this->fetch_paginated_page( $page, 20, '', 'pro' );
533 if ( empty( $batch ) ) {
534 break;
535 }
536
537 $found += count( $batch );
538 $patterns = $this->merge_pattern_libraries( $batch, $patterns );
539
540 if ( count( $batch ) < 20 ) {
541 break;
542 }
543
544 $page++;
545 }
546
547 if ( $found > 0 ) {
548 $this->store_library_cache( $scope, $patterns );
549 $this->library_runtime[ $scope ] = $patterns;
550 delete_transient( 'patternswp_pro_classify_empty' );
551 } else {
552 set_transient( 'patternswp_pro_classify_empty', 1, 6 * HOUR_IN_SECONDS );
553 }
554 }
555
556 /**
557 * @param array $patterns Pattern list.
558 * @return string
559 */
560 private function pattern_key_set( array $patterns ) {
561 $keys = array();
562 foreach ( $patterns as $pattern ) {
563 if ( ! is_array( $pattern ) ) {
564 continue;
565 }
566 $key = $this->get_pattern_library_key( $pattern );
567 if ( '' !== $key ) {
568 $keys[] = $key;
569 }
570 }
571 sort( $keys );
572 return implode( '|', $keys );
573 }
574
575 /**
576 * @param array $pattern Pattern payload.
577 * @return bool
578 */
579 private function is_pro_pattern( array $pattern ) {
580 return 'pro' === $this->resolve_pattern_type( $pattern );
581 }
582
583 /**
584 * @param array $pattern Pattern payload.
585 * @return string `pro` or `free`.
586 */
587 private function resolve_pattern_type( array $pattern ) {
588 foreach ( array( 'is_pro', 'isPro', 'pro', 'premium', 'is_premium' ) as $flag ) {
589 if ( ! array_key_exists( $flag, $pattern ) ) {
590 continue;
591 }
592 $value = $pattern[ $flag ];
593 if ( true === $value || 1 === $value || '1' === $value ) {
594 return 'pro';
595 }
596 if ( is_string( $value ) && in_array( strtolower( $value ), array( 'pro', 'premium', 'paid', 'true' ), true ) ) {
597 return 'pro';
598 }
599 }
600
601 foreach ( array( 'type', 'Type', 'pattern_type', 'patternType', 'plan', 'tier' ) as $key ) {
602 if ( ! isset( $pattern[ $key ] ) ) {
603 continue;
604 }
605
606 $raw = $pattern[ $key ];
607 if ( true === $raw || 1 === $raw ) {
608 return 'pro';
609 }
610
611 $value = strtolower( trim( (string) $raw ) );
612 if ( in_array( $value, array( 'pro', 'premium', 'paid', 'paid-pro', '1', 'true' ), true ) ) {
613 return 'pro';
614 }
615 if ( in_array( $value, array( 'free', '0', 'false' ), true ) ) {
616 return 'free';
617 }
618 }
619
620 return 'free';
621 }
622
623 /**
624 * Return the full library for the current license, from cache when possible.
625 *
626 * @param bool $refresh_if_empty Fetch remotely when the cache is cold.
627 * @return array
628 */
629 private function get_library( $refresh_if_empty = false ) {
630 $scope = $this->get_library_scope();
631
632 if ( isset( $this->library_runtime[ $scope ] ) && is_array( $this->library_runtime[ $scope ] ) ) {
633 return $this->library_runtime[ $scope ];
634 }
635
636 $cached = $this->read_library_cache( $scope );
637 if ( empty( $cached ) ) {
638 $other = ( 'pro' === $scope ) ? 'free' : 'pro';
639 $cached = $this->read_library_cache( $other );
640 }
641 if ( ! empty( $cached ) ) {
642 $this->library_runtime[ $scope ] = $cached;
643 return $cached;
644 }
645
646 if ( ! $refresh_if_empty && ! wp_doing_cron() ) {
647 return array();
648 }
649
650 $refreshed = $this->refresh_library( false );
651 $this->library_runtime[ $scope ] = $refreshed;
652 return $refreshed;
653 }
654
655 /**
656 * Fetch one remote page so the modal can render on a cold cache.
657 * Full catalog download continues in WP-Cron so hosts do not kill AJAX.
658 *
659 * @param int $page Requested page.
660 * @param int $p_per_page Page size.
661 * @param string $search Search query.
662 * @param string $category Category slug.
663 * @return array
664 */
665 private function seed_library_for_request( $page, $p_per_page, $search, $category ) {
666 $scope = $this->get_library_scope();
667 $per_page = min( 20, max( 15, (int) $p_per_page ) );
668 $remote_page = ( '' === $search ) ? max( 1, (int) $page ) : 1;
669
670 $batch = $this->fetch_paginated_page( $remote_page, $per_page, $category );
671 if ( empty( $batch ) && $per_page !== 15 ) {
672 $batch = $this->fetch_paginated_page( $remote_page, 15, $category );
673 }
674
675 if ( ! empty( $batch ) ) {
676 $existing = $this->read_library_cache( $scope );
677 $merged = $this->merge_pattern_libraries( $batch, is_array( $existing ) ? $existing : array() );
678 $this->store_library_cache( $scope, $merged );
679 $this->library_runtime[ $scope ] = $merged;
680 return $merged;
681 }
682
683 return array();
684 }
685
686 /**
687 * Fetch and store the catalog in short, resumable bursts.
688 *
689 * Hosts commonly kill 30–60s AJAX with an HTML error page. Saving after
690 * each page keeps the library usable if PHP is stopped mid-run.
691 *
692 * @param bool $force Restart from page 1.
693 * @param int $budget Seconds to spend, or 0 for the default.
694 * @return array
695 */
696 private function refresh_library( $force = false, $budget = 0 ) {
697 $scope = $this->get_library_scope();
698 $lock = 'patternswp_library_lock';
699
700 if ( ! $force && $this->is_library_complete() ) {
701 $cached = $this->read_library_cache( $scope );
702 if ( ! empty( $cached ) ) {
703 return $cached;
704 }
705 }
706
707 if ( ! $this->acquire_refresh_lock( $lock ) ) {
708 if ( wp_doing_ajax() && ! $force ) {
709 $this->schedule_library_refresh( 2 );
710 return $this->read_library_cache( $scope );
711 }
712
713 $waited = $this->wait_for_library_cache( $scope, wp_doing_ajax() ? 2 : 6 );
714 if ( ! empty( $waited ) && ! $force ) {
715 return $waited;
716 }
717 if ( ! $force ) {
718 $this->schedule_library_refresh( 3 );
719 return $this->read_library_cache( $scope );
720 }
721 set_transient( $lock, 1, 5 * MINUTE_IN_SECONDS );
722 }
723
724 if ( function_exists( 'ignore_user_abort' ) ) {
725 ignore_user_abort( true );
726 }
727
728 $deadline = time() + ( $budget > 0 ? (int) $budget : ( wp_doing_ajax() ? 10 : 18 ) );
729 $state = $force ? $this->default_refresh_state() : $this->get_refresh_state();
730 $patterns = $this->read_library_cache( $scope );
731
732 if ( count( $patterns ) < 200 ) {
733 $state['complete'] = false;
734 if ( in_array( $state['phase'], array( 'done', 'bulk' ), true ) ) {
735 $state['phase'] = 'paginated';
736 }
737 $per = (int) $state['per_page'];
738 if ( $per > 0 && count( $patterns ) > 0 ) {
739 $state['page'] = max( (int) $state['page'], (int) ceil( count( $patterns ) / $per ) + 1 );
740 }
741 }
742
743 if ( empty( $state['per_page'] ) ) {
744 foreach ( array( 20, 15 ) as $size ) {
745 $first = $this->fetch_paginated_page( 1, $size, '' );
746 if ( ! empty( $first ) ) {
747 $state['per_page'] = $size;
748 $state['page'] = 2;
749 $patterns = $this->merge_pattern_libraries( $first, $patterns );
750 $this->store_library_cache( $scope, $patterns );
751 break;
752 }
753 }
754 if ( empty( $state['per_page'] ) ) {
755 delete_transient( $lock );
756 $this->schedule_library_refresh( 30 );
757 return $patterns;
758 }
759 }
760
761 while ( 'paginated' === $state['phase'] && time() < $deadline && $state['page'] <= 50 ) {
762 $batch = $this->fetch_paginated_page( (int) $state['page'], (int) $state['per_page'], '' );
763 if ( empty( $batch ) ) {
764 $state['empty_streak'] = (int) $state['empty_streak'] + 1;
765 /*
766 * Empty responses are usually timeouts/token misses, not EOF.
767 * Only leave pagination after several failures AND a sizable catalog.
768 */
769 if ( $state['empty_streak'] >= 5 && count( $patterns ) >= 200 ) {
770 $state['phase'] = 'backfill';
771 $this->set_refresh_state( $state );
772 break;
773 }
774 $this->set_refresh_state( $state );
775 break;
776 }
777
778 $state['empty_streak'] = 0;
779 $patterns = $this->merge_pattern_libraries( $batch, $patterns );
780 $this->store_library_cache( $scope, $patterns );
781
782 if ( count( $batch ) < (int) $state['per_page'] ) {
783 $state['phase'] = 'backfill';
784 break;
785 }
786
787 $state['page']++;
788 $this->set_refresh_state( $state );
789 }
790
791 if ( 'paginated' === $state['phase'] && (int) $state['page'] > 50 ) {
792 $state['phase'] = 'backfill';
793 }
794
795 if ( 'backfill' === $state['phase'] && time() < $deadline ) {
796 $before = count( $patterns );
797 $patterns = $this->backfill_missing_categories( $patterns, $deadline );
798 if ( count( $patterns ) !== $before ) {
799 $this->store_library_cache( $scope, $patterns );
800 }
801 if ( time() < $deadline ) {
802 $state['phase'] = 'bulk';
803 }
804 }
805
806 if ( 'bulk' === $state['phase'] && time() < $deadline ) {
807 $bulk = $this->fetch_bulk_patterns();
808 $patterns = $this->merge_pattern_libraries( $patterns, $bulk );
809 $this->store_library_cache( $scope, $patterns );
810 $state['phase'] = 'classify';
811 } elseif ( 'bulk' === $state['phase'] && count( $patterns ) >= 200 ) {
812 $state['phase'] = 'classify';
813 }
814
815 if ( 'classify' === $state['phase'] && time() < $deadline ) {
816 $remaining = max( 1, $deadline - time() );
817 $this->library_runtime[ $scope ] = $patterns;
818 $this->classify_pro_patterns( $remaining );
819 $patterns = $this->get_library( false );
820 $state['phase'] = 'done';
821 $state['complete'] = count( $patterns ) >= 200;
822 }
823
824 $this->library_runtime[ $scope ] = $patterns;
825 $this->set_refresh_state( $state );
826 delete_transient( $lock );
827
828 if ( empty( $state['complete'] ) ) {
829 $this->schedule_library_refresh( 2 );
830 }
831
832 return is_array( $patterns ) ? $patterns : array();
833 }
834
835 /**
836 * Download every pattern page and merge typed + bulk sources.
837 *
838 * Categories that the unfiltered stream missed (Page Templates live later
839 * in the catalog and often ride on flaky/large responses) are backfilled
840 * with a direct category query during this one refresh.
841 *
842 * @return array
843 */
844 private function fetch_full_catalog() {
845 $typed = $this->fetch_all_paginated_patterns( '' );
846 $typed = $this->backfill_missing_categories( $typed );
847 $bulk = $this->fetch_bulk_patterns();
848
849 return $this->merge_pattern_libraries( $typed, $bulk );
850 }
851
852 /**
853 * Fetch categories that did not appear in the unfiltered catalog.
854 *
855 * @param array $patterns Patterns already downloaded.
856 * @param int $deadline Unix timestamp to stop, or 0 for no limit.
857 * @return array
858 */
859 private function backfill_missing_categories( array $patterns, $deadline = 0 ) {
860 $categories = $this->get_patternswp_category_type( false );
861 if ( empty( $categories ) || ! is_array( $categories ) ) {
862 return $patterns;
863 }
864
865 foreach ( $categories as $category ) {
866 if ( $deadline && time() >= $deadline ) {
867 return $patterns;
868 }
869
870 $name = ( is_array( $category ) && isset( $category['name'] ) ) ? (string) $category['name'] : '';
871 if ( '' === $name ) {
872 continue;
873 }
874
875 $existing = $this->filter_patterns_by_category( $patterns, $name );
876 if ( ! empty( $existing ) ) {
877 continue;
878 }
879
880 $extra = $this->fetch_all_paginated_patterns( $name, $deadline );
881 $patterns = $this->merge_pattern_libraries( $patterns, $extra );
882 usleep( 150000 );
883 }
884
885 return $patterns;
886 }
887
888 /**
889 * Fetch every page from the modal API (includes free/pro type).
890 *
891 * The remote API rejects large page sizes and consumes tokens per request,
892 * so we page at 15–20 with a fresh token each time.
893 *
894 * @param string $category Optional category slug.
895 * @param int $deadline Unix timestamp to stop, or 0 for no limit.
896 * @return array
897 */
898 private function fetch_all_paginated_patterns( $category = '', $deadline = 0 ) {
899 $per_page = 0;
900 $first = array();
901
902 foreach ( array( 20, 15 ) as $size ) {
903 if ( $deadline && time() >= $deadline ) {
904 return array();
905 }
906 $first = $this->fetch_paginated_page( 1, $size, $category );
907 if ( ! empty( $first ) ) {
908 $per_page = $size;
909 break;
910 }
911 }
912
913 if ( empty( $first ) || $per_page < 1 ) {
914 return array();
915 }
916
917 $all = $first;
918 $page = 2;
919 $empty_streak = 0;
920
921 while ( $page <= 50 ) {
922 if ( $deadline && time() >= $deadline ) {
923 break;
924 }
925
926 $batch = $this->fetch_paginated_page( $page, $per_page, $category );
927 if ( empty( $batch ) ) {
928 $empty_streak++;
929 if ( $empty_streak >= 3 ) {
930 break;
931 }
932 $page++;
933 continue;
934 }
935
936 $empty_streak = 0;
937 $all = array_merge( $all, $batch );
938
939 if ( count( $batch ) < $per_page ) {
940 break;
941 }
942
943 $page++;
944 }
945
946 return $this->normalize_patterns_list( $all );
947 }
948
949 /**
950 * Fetch one paginated catalog page with a fresh token and retries.
951 *
952 * @param int $page Page number.
953 * @param int $per_page Page size the remote API actually honours.
954 * @param string $category Optional category slug.
955 * @return array
956 */
957 private function fetch_paginated_page( $page, $per_page, $category = '', $type = '' ) {
958 $license = $this->get_license_data();
959
960 for ( $try = 0; $try < 2; $try++ ) {
961 $token = $this->get_api_token();
962 if ( empty( $token ) ) {
963 usleep( 200000 );
964 continue;
965 }
966
967 $query = array(
968 'page' => $page,
969 'patternsPerPage' => $per_page,
970 'search' => '',
971 'category' => $category,
972 'site_url' => get_site_url(),
973 'license_key' => ! empty( $license['licensestatus'] ) ? $license['license_key'] : '',
974 'licensestatus' => ! empty( $license['licensestatus'] ) ? '1' : '',
975 'api_token' => $token,
976 'plugin_version' => PWP_P_VERSION,
977 );
978 if ( '' !== $type ) {
979 $query['type'] = $type;
980 }
981
982 $url = add_query_arg(
983 $query,
984 $this->api_url . 'wp-json/patternswps/v1/patterns'
985 );
986
987 $batch = $this->normalize_patterns_list( $this->remote_get_json( $url, 20 ) );
988 if ( ! empty( $batch ) ) {
989 if ( 'pro' === $type ) {
990 foreach ( $batch as &$pattern ) {
991 $pattern['type'] = 'pro';
992 }
993 unset( $pattern );
994 }
995 return $batch;
996 }
997
998 usleep( 250000 );
999 }
1000
1001 return array();
1002 }
1003
1004 /**
1005 * Fetch the bulk download used for Gutenberg registration.
1006 *
1007 * @return array
1008 */
1009 private function fetch_bulk_patterns() {
1010 $license = $this->get_license_data();
1011
1012 for ( $try = 0; $try < 3; $try++ ) {
1013 $token = $this->get_api_token();
1014 if ( empty( $token ) ) {
1015 usleep( 250000 );
1016 continue;
1017 }
1018
1019 $url = add_query_arg(
1020 array(
1021 'license_key' => ! empty( $license['licensestatus'] ) ? $license['license_key'] : '',
1022 'licensestatus' => ! empty( $license['licensestatus'] ) ? '1' : '',
1023 'api_token' => $token,
1024 ),
1025 $this->api_url . 'wp-json/patternswps_wp/v1/patterns_wp'
1026 );
1027
1028 $batch = $this->normalize_patterns_list( $this->remote_get_json( $url, 25 ) );
1029 if ( ! empty( $batch ) ) {
1030 return $batch;
1031 }
1032
1033 usleep( 350000 );
1034 }
1035
1036 return array();
1037 }
1038
1039 /**
1040 * @param string $url Request URL.
1041 * @param int $timeout Timeout in seconds.
1042 * @return mixed
1043 */
1044 private function remote_get_json( $url, $timeout = 20 ) {
1045 $args = array(
1046 'timeout' => $timeout,
1047 'method' => 'GET',
1048 'sslverify' => true,
1049 'user-agent' => 'PatternsWP/' . PWP_P_VERSION . '; ' . home_url( '/' ),
1050 );
1051
1052 $response = wp_remote_get( $url, $args );
1053
1054 if ( is_wp_error( $response ) ) {
1055 $args['sslverify'] = false;
1056 $response = wp_remote_get( $url, $args );
1057 }
1058
1059 if ( is_wp_error( $response ) ) {
1060 return null;
1061 }
1062
1063 $code = (int) wp_remote_retrieve_response_code( $response );
1064 if ( $code < 200 || $code >= 300 ) {
1065 return null;
1066 }
1067
1068 $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
1069 return ( JSON_ERROR_NONE === json_last_error() ) ? $decoded : null;
1070 }
1071
1072 /**
1073 * Prefer typed (paginated) records so Pro badges survive a bulk merge.
1074 *
1075 * @param array $typed_patterns Patterns from the paginated API.
1076 * @param array $bulk_patterns Patterns from the bulk API.
1077 * @return array
1078 */
1079 private function merge_pattern_libraries( array $typed_patterns, array $bulk_patterns ) {
1080 $merged = array();
1081
1082 $add = function ( $pattern ) use ( &$merged ) {
1083 if ( ! is_array( $pattern ) ) {
1084 return;
1085 }
1086 $key = $this->get_pattern_library_key( $pattern );
1087 if ( '' === $key ) {
1088 return;
1089 }
1090
1091 $incoming_type = $this->resolve_pattern_type( $pattern );
1092 if ( isset( $merged[ $key ] ) ) {
1093 if ( 'pro' === $incoming_type ) {
1094 $merged[ $key ]['type'] = 'pro';
1095 }
1096 if ( empty( $merged[ $key ]['categories'] ) && ! empty( $pattern['categories'] ) ) {
1097 $merged[ $key ]['categories'] = $pattern['categories'];
1098 }
1099 return;
1100 }
1101
1102 $pattern['type'] = $incoming_type;
1103 $merged[ $key ] = $pattern;
1104 };
1105
1106 foreach ( $typed_patterns as $pattern ) {
1107 $add( $pattern );
1108 }
1109 foreach ( $bulk_patterns as $pattern ) {
1110 $add( $pattern );
1111 }
1112
1113 return array_values( $merged );
1114 }
1115
1116 /**
1117 * @param array $pattern Pattern payload.
1118 * @return string
1119 */
1120 private function get_pattern_library_key( array $pattern ) {
1121 return strtolower( trim( (string) ( $pattern['title'] ?? '' ) ) );
1122 }
1123
1124 /**
1125 * @return string
1126 */
1127 private function get_library_scope() {
1128 $license = $this->get_license_data();
1129 return ! empty( $license['licensestatus'] ) ? 'pro' : 'free';
1130 }
1131
1132 /**
1133 * @param string $scope License scope.
1134 * @return string
1135 */
1136 private function get_library_prefix( $scope ) {
1137 return 'patternswp_lib_' . sanitize_key( $scope ) . '_';
1138 }
1139
1140 /**
1141 * @param string $scope License scope.
1142 * @return array
1143 */
1144 private function read_library_cache( $scope ) {
1145 $file = $this->read_library_file( $scope );
1146 if ( ! empty( $file ) ) {
1147 return $file;
1148 }
1149
1150 $prefix = $this->get_library_prefix( $scope );
1151 $chunk_count = (int) get_transient( $prefix . 'count' );
1152 $patterns = array();
1153
1154 if ( $chunk_count > 0 ) {
1155 for ( $i = 0; $i < $chunk_count && $i < 200; $i++ ) {
1156 $chunk = get_transient( $prefix . $i );
1157 if ( false === $chunk ) {
1158 continue;
1159 }
1160 $chunk = $this->unpack_cache_payload( $chunk );
1161 if ( is_array( $chunk ) ) {
1162 $patterns = array_merge( $patterns, $chunk );
1163 }
1164 }
1165
1166 $patterns = $this->normalize_patterns_list( $patterns );
1167 if ( ! empty( $patterns ) ) {
1168 return $patterns;
1169 }
1170 }
1171
1172 return $this->get_legacy_cached_patterns();
1173 }
1174
1175 /**
1176 * Legacy chunked cache from earlier plugin versions.
1177 *
1178 * @return array
1179 */
1180 private function get_legacy_cached_patterns() {
1181 $cached_patterns = array();
1182 $misses = 0;
1183
1184 for ( $i = 0; $i < 200; $i++ ) {
1185 $transient_data = get_transient( 'patterns_cache_' . $i );
1186 if ( false === $transient_data ) {
1187 $misses++;
1188 if ( $misses >= 3 ) {
1189 break;
1190 }
1191 continue;
1192 }
1193
1194 $misses = 0;
1195 $transient_data = $this->unpack_cache_payload( $transient_data );
1196 if ( is_array( $transient_data ) ) {
1197 $cached_patterns = array_merge( $cached_patterns, $transient_data );
1198 }
1199 }
1200
1201 return $this->normalize_patterns_list( $cached_patterns );
1202 }
1203
1204 /**
1205 * @param string $scope License scope.
1206 * @param array $patterns Pattern list.
1207 */
1208 private function store_library_cache( $scope, array $patterns ) {
1209 $patterns = $this->normalize_patterns_list( $patterns );
1210 $prefix = $this->get_library_prefix( $scope );
1211 $chunk_size = 4;
1212 $chunks = array_chunk( $patterns, $chunk_size );
1213
1214 $this->write_library_file( $scope, $patterns );
1215 $this->delete_library_cache( $scope );
1216
1217 foreach ( $chunks as $index => $chunk ) {
1218 set_transient( $prefix . $index, $this->pack_cache_payload( $chunk ), DAY_IN_SECONDS );
1219 }
1220
1221 set_transient( $prefix . 'count', count( $chunks ), DAY_IN_SECONDS );
1222
1223 $this->delete_legacy_pattern_cache();
1224 foreach ( $chunks as $index => $chunk ) {
1225 set_transient( 'patterns_cache_' . $index, $this->pack_cache_payload( $chunk ), DAY_IN_SECONDS );
1226 }
1227 }
1228
1229 /**
1230 * @param string $scope License scope.
1231 */
1232 private function delete_library_cache( $scope ) {
1233 $prefix = $this->get_library_prefix( $scope );
1234 $count = (int) get_transient( $prefix . 'count' );
1235
1236 for ( $i = 0; $i < max( $count, 200 ); $i++ ) {
1237 delete_transient( $prefix . $i );
1238 }
1239
1240 delete_transient( $prefix . 'count' );
1241 }
1242
1243 /**
1244 * Remove legacy bulk chunks.
1245 */
1246 private function delete_legacy_pattern_cache() {
1247 for ( $i = 0; $i < 200; $i++ ) {
1248 delete_transient( 'patterns_cache_' . $i );
1249 }
1250 }
1251
1252 /**
1253 * @param string $lock_key Lock transient.
1254 * @return bool
1255 */
1256 private function acquire_refresh_lock( $lock_key ) {
1257 if ( false !== get_transient( $lock_key ) ) {
1258 return false;
1259 }
1260
1261 set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );
1262 return true;
1263 }
1264
1265 /**
1266 * @param string $scope License scope.
1267 * @param int $tries Poll attempts.
1268 * @return array
1269 */
1270 private function wait_for_library_cache( $scope, $tries = 8 ) {
1271 for ( $i = 0; $i < $tries; $i++ ) {
1272 usleep( 250000 );
1273 $cached = $this->read_library_cache( $scope );
1274 if ( ! empty( $cached ) ) {
1275 return $cached;
1276 }
1277 }
1278
1279 return array();
1280 }
1281
1282 /**
1283 * Normalize API/cache payloads into a list of pattern arrays.
1284 *
1285 * @param mixed $patterns Raw patterns payload.
1286 * @return array
1287 */
1288 private function normalize_patterns_list( $patterns ) {
1289 if ( is_string( $patterns ) ) {
1290 $decoded = json_decode( $patterns, true );
1291 $patterns = ( JSON_ERROR_NONE === json_last_error() ) ? $decoded : array();
1292 }
1293
1294 if ( ! is_array( $patterns ) ) {
1295 return array();
1296 }
1297
1298 foreach ( array( 'patterns', 'data', 'items', 'results' ) as $key ) {
1299 if ( isset( $patterns[ $key ] ) && is_array( $patterns[ $key ] ) ) {
1300 $patterns = $patterns[ $key ];
1301 break;
1302 }
1303 }
1304
1305 $normalized = array();
1306 foreach ( $patterns as $pattern ) {
1307 if ( ! is_array( $pattern ) ) {
1308 continue;
1309 }
1310
1311 $title = isset( $pattern['title'] ) ? (string) $pattern['title'] : '';
1312 $content = isset( $pattern['content'] ) ? (string) $pattern['content'] : '';
1313 if ( '' === $title || '' === $content ) {
1314 continue;
1315 }
1316
1317 $categories = isset( $pattern['categories'] ) ? $pattern['categories'] : array();
1318 if ( is_string( $categories ) ) {
1319 $categories = array_filter( array_map( 'trim', explode( ',', $categories ) ) );
1320 }
1321 if ( ! is_array( $categories ) ) {
1322 $categories = array();
1323 }
1324
1325 $type = $this->resolve_pattern_type( $pattern );
1326
1327 $pattern['title'] = $title;
1328 $pattern['content'] = $content;
1329 $pattern['categories'] = array_values( array_filter( $categories, 'is_string' ) );
1330 $pattern['type'] = $type;
1331 $normalized[] = $pattern;
1332 }
1333
1334 return $normalized;
1335 }
1336
1337 /**
1338 * Request a one-time API token. The remote API invalidates reused tokens.
1339 *
1340 * @return string|false
1341 */
1342 public function get_api_token() {
1343 $timestamp = time();
1344 $url = add_query_arg(
1345 array(
1346 'timestamp' => $timestamp,
1347 'unique_key' => md5( (string) $timestamp ),
1348 ),
1349 $this->api_url . 'wp-json/patternswps_token/v1/token'
1350 );
1351
1352 $token = $this->remote_get_json( $url, 10 );
1353 if ( is_array( $token ) ) {
1354 $token = isset( $token['token'] ) ? $token['token'] : reset( $token );
1355 }
1356 if ( is_string( $token ) ) {
1357 $token = trim( $token, "\" \t\n\r\0\x0B" );
1358 }
1359
1360 return ( is_string( $token ) && '' !== $token ) ? $token : false;
1361 }
1362
1363 /**
1364 * @param array $patterns Pattern list.
1365 * @param string $search Search query.
1366 * @return array
1367 */
1368 private function filter_patterns_by_search( $patterns, $search ) {
1369 $search = trim( (string) $search );
1370 if ( '' === $search || empty( $patterns ) ) {
1371 return is_array( $patterns ) ? $patterns : array();
1372 }
1373
1374 $needle = strtolower( $search );
1375 return array_values(
1376 array_filter(
1377 $patterns,
1378 function ( $pattern ) use ( $needle ) {
1379 if ( ! is_array( $pattern ) ) {
1380 return false;
1381 }
1382 $title = isset( $pattern['title'] ) ? strtolower( (string) $pattern['title'] ) : '';
1383 return false !== strpos( $title, $needle );
1384 }
1385 )
1386 );
1387 }
1388
1389 /**
1390 * @param array $patterns Pattern list.
1391 * @param string $category Category slug.
1392 * @return array
1393 */
1394 private function filter_patterns_by_category( $patterns, $category ) {
1395 $category = trim( (string) $category );
1396 if ( '' === $category || empty( $patterns ) ) {
1397 return is_array( $patterns ) ? $patterns : array();
1398 }
1399
1400 $wanted = array(
1401 $category,
1402 sanitize_title( $category ),
1403 );
1404
1405 return array_values(
1406 array_filter(
1407 $patterns,
1408 function ( $pattern ) use ( $wanted ) {
1409 if ( ! is_array( $pattern ) ) {
1410 return false;
1411 }
1412
1413 $categories = isset( $pattern['categories'] ) && is_array( $pattern['categories'] )
1414 ? $pattern['categories']
1415 : array();
1416
1417 foreach ( $categories as $assigned ) {
1418 if ( ! is_string( $assigned ) ) {
1419 continue;
1420 }
1421 if ( in_array( $assigned, $wanted, true ) ) {
1422 return true;
1423 }
1424 if ( in_array( sanitize_title( $assigned ), $wanted, true ) ) {
1425 return true;
1426 }
1427 }
1428
1429 return false;
1430 }
1431 )
1432 );
1433 }
1434
1435 /**
1436 * Register patterns and categories for the native Gutenberg inserter.
1437 * Free sites only register free patterns so Pro content stays gated.
1438 */
1439 private function register_patterns_and_categories( $patterns ) {
1440 $patterns = $this->normalize_patterns_list( $patterns );
1441 $patterns = apply_filters( 'patternswp_patterns', $patterns );
1442 $patterns = $this->normalize_patterns_list( $patterns );
1443
1444 if ( empty( $patterns ) ) {
1445 return;
1446 }
1447
1448 $licensed = $this->is_license_active();
1449 $registered_categories = array();
1450 $registry = \WP_Block_Patterns_Registry::get_instance();
1451
1452 foreach ( $patterns as $pattern ) {
1453 if ( ! is_array( $pattern ) ) {
1454 continue;
1455 }
1456
1457 if ( ! $licensed && $this->is_pro_pattern( $pattern ) ) {
1458 continue;
1459 }
1460
1461 $categories = isset( $pattern['categories'] ) && is_array( $pattern['categories'] ) ? $pattern['categories'] : array();
1462 $pattern_title = isset( $pattern['title'] ) ? (string) $pattern['title'] : '';
1463 $pattern_content = isset( $pattern['content'] ) ? (string) $pattern['content'] : '';
1464
1465 if ( '' === $pattern_title || '' === $pattern_content ) {
1466 continue;
1467 }
1468
1469 $category_slugs = array();
1470 foreach ( $categories as $category ) {
1471 if ( ! is_string( $category ) || '' === trim( $category ) ) {
1472 continue;
1473 }
1474
1475 $category_slug = sanitize_title( $category );
1476 if ( '' === $category_slug ) {
1477 continue;
1478 }
1479
1480 $category_slugs[] = $category_slug;
1481
1482 if ( ! in_array( $category_slug, $registered_categories, true ) ) {
1483 register_block_pattern_category(
1484 $category_slug,
1485 array( 'label' => $this->get_category_label( $category ) )
1486 );
1487 $registered_categories[] = $category_slug;
1488 }
1489 }
1490
1491 $pattern_name = 'patternswp-gutenberg-block-patterns/' . sanitize_title( $pattern_title );
1492 if ( $registry->is_registered( $pattern_name ) ) {
1493 continue;
1494 }
1495
1496 register_block_pattern(
1497 $pattern_name,
1498 array(
1499 'title' => $pattern_title,
1500 'content' => $pattern_content,
1501 'categories' => $category_slugs,
1502 )
1503 );
1504 }
1505 }
1506
1507 /**
1508 * @param mixed $data Cache payload.
1509 * @return mixed
1510 */
1511 private function pack_cache_payload( $data ) {
1512 if ( ! function_exists( 'gzcompress' ) ) {
1513 return $data;
1514 }
1515
1516 $json = wp_json_encode( $data );
1517 if ( ! is_string( $json ) || '' === $json ) {
1518 return $data;
1519 }
1520
1521 return array(
1522 '_pwp' => 1,
1523 'z' => base64_encode( gzcompress( $json, 6 ) ),
1524 );
1525 }
1526
1527 /**
1528 * @param mixed $payload Stored cache payload.
1529 * @return array
1530 */
1531 private function unpack_cache_payload( $payload ) {
1532 if ( is_array( $payload ) && ! empty( $payload['_pwp'] ) && ! empty( $payload['z'] ) && is_string( $payload['z'] ) ) {
1533 if ( ! function_exists( 'gzuncompress' ) ) {
1534 return array();
1535 }
1536 $raw = base64_decode( $payload['z'], true );
1537 if ( false === $raw ) {
1538 return array();
1539 }
1540 $json = @gzuncompress( $raw );
1541 $data = is_string( $json ) ? json_decode( $json, true ) : null;
1542 return is_array( $data ) ? $data : array();
1543 }
1544
1545 return is_array( $payload ) ? $payload : array();
1546 }
1547
1548 /**
1549 * @param string $scope License scope.
1550 * @return string
1551 */
1552 private function get_library_file_path( $scope ) {
1553 $uploads = wp_upload_dir();
1554 if ( ! empty( $uploads['error'] ) ) {
1555 return '';
1556 }
1557
1558 $dir = trailingslashit( $uploads['basedir'] ) . 'patternswp';
1559 return $dir . '/library-' . sanitize_key( $scope ) . '.json.gz';
1560 }
1561
1562 /**
1563 * @param string $scope License scope.
1564 * @param array $patterns Pattern list.
1565 */
1566 private function write_library_file( $scope, array $patterns ) {
1567 $path = $this->get_library_file_path( $scope );
1568 if ( '' === $path ) {
1569 return;
1570 }
1571
1572 $dir = dirname( $path );
1573 if ( ! is_dir( $dir ) ) {
1574 wp_mkdir_p( $dir );
1575 }
1576
1577 if ( is_dir( $dir ) && ! file_exists( $dir . '/index.php' ) ) {
1578 file_put_contents( $dir . '/index.php', "<?php\n// Silence is golden.\n" );
1579 }
1580
1581 $json = wp_json_encode( array_values( $patterns ) );
1582 if ( ! is_string( $json ) ) {
1583 return;
1584 }
1585
1586 if ( function_exists( 'gzencode' ) ) {
1587 file_put_contents( $path, gzencode( $json, 6 ) );
1588 return;
1589 }
1590
1591 file_put_contents( $path, $json );
1592 }
1593
1594 /**
1595 * @param string $scope License scope.
1596 * @return array
1597 */
1598 private function read_library_file( $scope ) {
1599 $path = $this->get_library_file_path( $scope );
1600 if ( '' === $path || ! is_readable( $path ) ) {
1601 return array();
1602 }
1603
1604 $raw = file_get_contents( $path );
1605 if ( ! is_string( $raw ) || '' === $raw ) {
1606 return array();
1607 }
1608
1609 if ( function_exists( 'gzdecode' ) ) {
1610 $decoded = @gzdecode( $raw );
1611 if ( is_string( $decoded ) && '' !== $decoded ) {
1612 $raw = $decoded;
1613 }
1614 }
1615
1616 $data = json_decode( $raw, true );
1617 return $this->normalize_patterns_list( $data );
1618 }
1619
1620 /**
1621 * @param string $scope License scope.
1622 */
1623 private function delete_library_file( $scope ) {
1624 $path = $this->get_library_file_path( $scope );
1625 if ( '' !== $path && file_exists( $path ) ) {
1626 wp_delete_file( $path );
1627 }
1628 }
1629
1630 /**
1631 * @return array
1632 */
1633 private function default_refresh_state() {
1634 return array(
1635 'phase' => 'paginated',
1636 'page' => 1,
1637 'per_page' => 0,
1638 'empty_streak' => 0,
1639 'complete' => false,
1640 );
1641 }
1642
1643 /**
1644 * @return array
1645 */
1646 private function get_refresh_state() {
1647 $state = get_option( 'patternswp_lib_state', array() );
1648 if ( ! is_array( $state ) ) {
1649 $state = array();
1650 }
1651
1652 return array_merge( $this->default_refresh_state(), $state );
1653 }
1654
1655 /**
1656 * @param array $state Refresh progress.
1657 */
1658 private function set_refresh_state( array $state ) {
1659 update_option( 'patternswp_lib_state', $state, false );
1660 }
1661
1662 /**
1663 * @return bool
1664 */
1665 private function is_library_complete() {
1666 $state = $this->get_refresh_state();
1667 if ( empty( $state['complete'] ) ) {
1668 return false;
1669 }
1670
1671 $cached = $this->read_library_cache( $this->get_library_scope() );
1672 return count( $cached ) >= 200;
1673 }
1674
1675 /**
1676 * Sidebar categories when the remote taxonomy request is blocked or slow.
1677 *
1678 * @return array
1679 */
1680 private function get_fallback_categories() {
1681 $names = array(
1682 'patternswp-blog',
1683 'patternswp-contact',
1684 'patternswp-cta',
1685 'patternswp-customer',
1686 'patternswp-faq',
1687 'patternswp-features',
1688 'patternswp-footer',
1689 'patternswp-gallery',
1690 'patternswp-header',
1691 'patternswp-hero',
1692 'patternswp-link-in-bio',
1693 'patternswp-page-templates',
1694 'patternswp-pricing',
1695 'patternswp-statistics',
1696 'patternswp-team',
1697 'patternswp-testimonials',
1698 'patternswp-utility',
1699 );
1700
1701 $categories = array();
1702 foreach ( $names as $name ) {
1703 $categories[] = array( 'name' => $name );
1704 }
1705
1706 return $categories;
1707 }
1708
1709 /**
1710 * @param string $category Category slug or label.
1711 * @return string
1712 */
1713 private function get_category_label( $category ) {
1714 $label = (string) $category;
1715 $label = preg_replace( '/^patternswp[-_]/i', '', $label );
1716 $label = str_replace( array( '-', '_' ), ' ', $label );
1717 return ucwords( $label );
1718 }
1719 }
1720
1721 PatternsWP_API_Section::get_instance();
1722