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
← All changes | includes/class-patternswp-api.php +1624 -261 1.0.61.1.0 View file →
@@ -1,299 +1,1505 @@
1 1 <?php
2 +// Exit if accessed directly
3 +if (!defined('ABSPATH')) {
4 + exit;
5 +}
6 +
2 7 class PatternsWP_API_Section {
3 8
4 9 public $api_url;
5 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 +
6 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 + /**
7 30 * Constructor
8 31 */
9 - public function __construct() {
32 + private function __construct() {
10 33 add_action( 'admin_init', array( $this, 'register_patterns_endpoint') );
11 - add_action('patternswp_hourly_transient_load', array( $this, 'patternswp_save_transient_if_not_ajax' ) );
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' ) );
12 36
13 - // API URL
14 37 $this->api_url = 'https://pwp4.thepatternswp.com/';
15 38 }
16 39
17 40 /**
18 - * Register patterns endpoint
41 + * Register patterns from the site-wide library cache.
19 42 */
20 43 public function register_patterns_endpoint() {
44 + $library = $this->get_library( false );
21 45
22 - $license_key = $licensestatus = '';
23 - $chunk_size = 50;
24 - $transient_expiry = DAY_IN_SECONDS;
25 - $transient_base = 'patterns_cache_';
26 -
27 - // Check for existing transients and fetch patterns if they exist
28 - $cached_patterns = array();
29 - for ($i = 0; $i < 50; $i++) { // Assuming a maximum of 10 transients (adjust as needed)
30 - $transient_data = get_transient( $transient_base . $i );
31 - if ($transient_data !== false) {
32 - $cached_patterns = array_merge($cached_patterns, $transient_data);
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();
33 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 ) ) {
34 534 break;
35 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++;
36 545 }
37 -
38 - // If cached patterns are found, register patterns and categories
39 - if (!empty( $cached_patterns ) ) {
40 - $this->register_patterns_and_categories($cached_patterns);
41 - return;
42 - }else{
43 -
44 - // Define the API URL for fetching patterns.
45 - $patterns_api_url = $this->api_url . 'wp-json/patternswps_wp/v1/patterns_wp';
46 - $get_lc_data = $this->get_license_data();
47 - if (!empty($get_lc_data)) {
48 - // Get license data
49 - $license_key = $get_lc_data['license_key'];
50 - $licensestatus = $get_lc_data['licensestatus'];
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;
51 565 }
52 -
53 - // Get API token
54 - $api_token = $this->get_api_token();
55 - // Set the request arguments including pagination parameters.
56 - $request_args = array(
57 - 'timeout' => 10,
58 - 'method' => 'GET',
59 - 'body' => array(
60 - 'license_key' => $license_key,
61 - 'licensestatus' => $licensestatus,
62 - 'api_token' => $api_token,
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,
63 1024 ),
1025 + $this->api_url . 'wp-json/patternswps_wp/v1/patterns_wp'
64 1026 );
65 -
66 - // Perform the GET request to fetch patterns.
67 - $response = wp_remote_get($patterns_api_url, $request_args);
68 -
69 - // Check for request errors.
70 - if (is_wp_error($response)) {
71 - // error_log('Failed to fetch patterns: ' . $response->get_error_message());
72 - return array();
1027 +
1028 + $batch = $this->normalize_patterns_list( $this->remote_get_json( $url, 25 ) );
1029 + if ( ! empty( $batch ) ) {
1030 + return $batch;
73 1031 }
74 -
75 - // Retrieve and decode the response body.
76 - $response_body = wp_remote_retrieve_body($response);
77 - $patterns = json_decode($response_body, true);
78 -
79 - // Check if the response body was successfully decoded.
80 - if (json_last_error() !== JSON_ERROR_NONE) {
81 - // error_log('Failed to decode JSON response: ' . json_last_error_msg());
82 - return array();
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;
83 1085 }
84 -
85 - // Return empty array if patterns are not found.
86 - if (empty($patterns)) {
87 - // error_log('Received empty response for patterns.');
88 - return array();
1086 + $key = $this->get_pattern_library_key( $pattern );
1087 + if ( '' === $key ) {
1088 + return;
89 1089 }
90 -
91 - // Cache patterns in transients with chunking
92 - $chunks = array_chunk($patterns, $chunk_size);
93 - foreach ($chunks as $index => $chunk) {
94 - set_transient( $transient_base . $index, $chunk, $transient_expiry );
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;
95 1100 }
96 -
97 - // Register patterns and categories
98 - $this->register_patterns_and_categories($patterns);
1101 +
1102 + $pattern['type'] = $incoming_type;
1103 + $merged[ $key ] = $pattern;
1104 + };
1105 +
1106 + foreach ( $typed_patterns as $pattern ) {
1107 + $add( $pattern );
99 1108 }
1109 + foreach ( $bulk_patterns as $pattern ) {
1110 + $add( $pattern );
1111 + }
1112 +
1113 + return array_values( $merged );
100 1114 }
101 1115
102 1116 /**
103 - * Get API token
1117 + * @param array $pattern Pattern payload.
1118 + * @return string
104 1119 */
105 - public function get_api_token() {
1120 + private function get_pattern_library_key( array $pattern ) {
1121 + return strtolower( trim( (string) ( $pattern['title'] ?? '' ) ) );
1122 + }
106 1123
107 - $app_token = 'patternswp_app_token';
108 - $timestamp = time(); // Get current timestamp
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 + }
109 1131
110 - // Concatenate the value and timestamp
111 - $unique_key = $app_token . $timestamp;
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 + }
112 1139
113 - // Generate a hash of the concatenated value
114 - $hashed_key = md5( $timestamp );
115 -
116 - $patterns_api_token_url = $this->api_url . 'wp-json/patternswps_token/v1/token';
117 - $request_args = array(
118 - 'timeout' => '10',
119 - 'method' => 'GET',
120 - 'body' => array(
121 - 'timestamp' => $timestamp,
122 - 'unique_key' => $hashed_key
123 - )
124 - );
125 -
126 - $response = wp_remote_get( $patterns_api_token_url, $request_args );
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 + }
127 1149
128 - if (is_wp_error($response)) {
129 - return false;
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 + }
130 1170 }
131 -
132 - $token = json_decode( wp_remote_retrieve_body( $response ), true );
133 - return $token;
1171 +
1172 + return $this->get_legacy_cached_patterns();
134 1173 }
135 -
1174 +
136 1175 /**
137 - * Get patterns category type
1176 + * Legacy chunked cache from earlier plugin versions.
1177 + *
1178 + * @return array
138 1179 */
139 - public function get_patternswp_category_type( $manually = true ) {
140 - // Define the transient key and timeout.
141 - $transient_key = 'patternswp_category_type';
142 - $transient_timeout = HOUR_IN_SECONDS;
143 -
144 - // Check if the transient exists.
145 - $categories = get_transient($transient_key);
146 -
147 - // If the transient does not exist, fetch the data.
148 - if ( $categories === false ) {
1180 + private function get_legacy_cached_patterns() {
1181 + $cached_patterns = array();
1182 + $misses = 0;
149 1183
150 - // Fetch pattern categories from the new API.
151 - $categories_api_url = $this->api_url . 'wp-json/patternswp_pattens_category_types/v1/patterns';
152 - $categories_response = wp_remote_get($categories_api_url);
153 - $categories_body = wp_remote_retrieve_body($categories_response);
154 -
155 - // Decode JSON response.
156 - $categories = json_decode($categories_body, true);
157 -
158 - // If categories were successfully fetched, add them to the localized data.
159 - if ( !is_wp_error( $categories_response ) && !empty( $categories ) ) {
160 - if ( $manually ) {
161 - foreach ( $categories as $category ) {
162 - if (isset($category['name'])) {
163 - register_block_pattern_category($category['name'], array('label' => $category['name']));
164 - }
165 - }
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;
166 1190 }
167 -
168 - // Save categories data to transient for 1 hour.
169 - set_transient($transient_key, $categories, $transient_timeout);
170 - // } else {
171 - // error_log('Failed to fetch pattern categories or received an empty response.');
1191 + continue;
172 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 + }
173 1199 }
174 -
175 - return $categories;
1200 +
1201 + return $this->normalize_patterns_list( $cached_patterns );
176 1202 }
177 1203
178 1204 /**
179 - * Get patterns from the API
1205 + * @param string $scope License scope.
1206 + * @param array $patterns Pattern list.
180 1207 */
181 - public function get_patternswp_pattern( $page = 1, $p_per_page = 15, $search = '', $category = '' ) {
182 - $license_key = $licensestatus = '';
183 -
184 - // Validate parameters.
185 - $page = intval($page) > 0 ? intval($page) : 1;
186 - $p_per_page = intval( $p_per_page ) > 0 ? intval( $p_per_page ) : 15;
187 -
188 - // Define the API URL for fetching patterns.
189 - $patterns_api_url = $this->api_url . 'wp-json/patternswps/v1/patterns';
190 - $get_lc_data = $this->get_license_data();
191 - if( !empty( $get_lc_data ) ){
192 - //get license data
193 - $license_key = $get_lc_data['license_key'];
194 - $licensestatus = $get_lc_data['licensestatus'];
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 );
195 1219 }
196 1220
197 - //Get site url OR plugin Version OR Get API Token
198 - $site_url = get_site_url();
199 - $plugin_version = PWP_P_VERSION;
200 - $api_token = $this->get_api_token();
1221 + set_transient( $prefix . 'count', count( $chunks ), DAY_IN_SECONDS );
201 1222
202 - // Set the request arguments including pagination parameters.
203 - $request_args = array(
204 - 'timeout' => 10,
205 - 'method' => 'GET',
206 - 'body' => array(
207 - 'page' => $page,
208 - 'patternsPerPage' => $p_per_page,
209 - 'search' => $search,
210 - 'category' => $category,
211 - 'site_url' => $site_url,
212 - 'license_key' => $license_key,
213 - 'licensestatus' => $licensestatus,
214 - 'api_token' => $api_token,
215 - 'plugin_version' => $plugin_version
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 ),
216 1348 ),
1349 + $this->api_url . 'wp-json/patternswps_token/v1/token'
217 1350 );
218 -
219 - // Perform the GET request to fetch patterns.
220 - $response = wp_remote_get( $patterns_api_url, $request_args );
221 -
222 - // Check for request errors.
223 - if (is_wp_error($response)) {
224 - // error_log('Failed to fetch patterns: ' . $response->get_error_message());
225 - return array();
1351 +
1352 + $token = $this->remote_get_json( $url, 10 );
1353 + if ( is_array( $token ) ) {
1354 + $token = isset( $token['token'] ) ? $token['token'] : reset( $token );
226 1355 }
227 -
228 - // Retrieve and decode the response body.
229 - $response_body = wp_remote_retrieve_body( $response );
230 - $patterns = json_decode($response_body, true);
231 -
232 - // Check if the response body was successfully decoded.
233 - if (json_last_error() !== JSON_ERROR_NONE) {
234 - // error_log('Failed to decode JSON response: ' . json_last_error_msg());
235 - return array();
1356 + if ( is_string( $token ) ) {
1357 + $token = trim( $token, "\" \t\n\r\0\x0B" );
236 1358 }
237 -
238 - // Return the patterns if not empty.
239 - if (!empty($patterns)) {
240 - return $patterns;
241 - } else {
242 - // error_log('Received empty response for patterns.');
243 - return array();
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();
244 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 + );
245 1387 }
246 1388
247 1389 /**
248 - * Get license data
1390 + * @param array $patterns Pattern list.
1391 + * @param string $category Category slug.
1392 + * @return array
249 1393 */
250 - public function get_license_data(){
251 - $license_key = $licensestatus = '';
252 - $get_license_data = get_option( 'patternswp_plugin_license_data' );
253 - if( is_array( $get_license_data ) && isset( $get_license_data['license_key'] ) && !empty( $get_license_data['license_key'] ) && $get_license_data['activated'] === true ){
254 - $license_key = $get_license_data['license_key']->key;
255 - $licensestatus = isset( $get_license_data['activated'] ) ? $get_license_data['activated'] : false ;
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();
256 1398 }
257 1399
258 - return array(
259 - 'license_key' => $license_key,
260 - 'licensestatus' => $licensestatus
1400 + $wanted = array(
1401 + $category,
1402 + sanitize_title( $category ),
261 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 + );
262 1433 }
263 1434
264 1435 /**
265 - * Register patterns and categories
1436 + * Register patterns and categories for the native Gutenberg inserter.
1437 + * Free sites only register free patterns so Pro content stays gated.
266 1438 */
267 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();
268 1449 $registered_categories = array();
269 -
270 - foreach ($patterns as $pattern) {
271 - $categories = $pattern['categories'];
272 - $pattern_title = $pattern['title'];
273 - $pattern_content = $pattern['content'];
274 -
275 - // Convert comma-separated categories to an array
276 - if (is_string($categories)) {
277 - $categories = array_map('trim', explode(',', $categories));
1450 + $registry = \WP_Block_Patterns_Registry::get_instance();
1451 +
1452 + foreach ( $patterns as $pattern ) {
1453 + if ( ! is_array( $pattern ) ) {
1454 + continue;
278 1455 }
279 -
280 - foreach ($categories as $category) {
281 - // Register block pattern category if not already registered
282 - if (!in_array($category, $registered_categories)) {
283 - $category_label = $this->get_category_label( $category ); // Define this method to get the category label
284 - register_block_pattern_category($category, array('label' => $category_label));
285 - $registered_categories[] = $category;
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;
286 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 + }
287 1489 }
288 -
289 - // Register block pattern
1490 +
1491 + $pattern_name = 'patternswp-gutenberg-block-patterns/' . sanitize_title( $pattern_title );
1492 + if ( $registry->is_registered( $pattern_name ) ) {
1493 + continue;
1494 + }
1495 +
290 1496 register_block_pattern(
291 - 'patternswp-gutenberg-block-patterns/' . sanitize_title( $pattern_title ),
1497 + $pattern_name,
292 1498 array(
293 1499 'title' => $pattern_title,
294 1500 'content' => $pattern_content,
295 - 'categories' => $categories, // This is now an array of categories
1501 + 'categories' => $category_slugs,
296 1502 )
297 1503 );
298 1504 }
299 1505 }
@@ -298,61 +1504,218 @@
298 1504 }
299 1505 }
300 1506
301 1507 /**
302 - * Get the category label
1508 + * @param mixed $data Cache payload.
1509 + * @return mixed
303 1510 */
304 - private function get_category_label($category) {
305 - // Define how to get the category label based on your needs
306 - // This is just a placeholder implementation
307 - return ucfirst($category); // Example: Capitalize the category name for the label
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 + );
308 1525 }
309 1526
310 1527 /**
311 - * Save transient if not ajax
1528 + * @param mixed $payload Stored cache payload.
1529 + * @return array
312 1530 */
313 - public function patternswp_save_transient_if_not_ajax() {
314 - $this->patternswp_save_transient_wise_category();
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();
315 1546 }
316 1547
317 1548 /**
318 - * Save transient wise category
1549 + * @param string $scope License scope.
1550 + * @return string
319 1551 */
320 - public function patternswp_save_transient_wise_category(){
321 - $get_patternswps_all_cats = $this->get_patternswp_category_type( false );
322 - $patterns_per_page = 15;
323 - $search = '';
324 - if ( ! empty( $get_patternswps_all_cats ) ) {
325 - array_unshift( $get_patternswps_all_cats, [ 'name' => '' ] );
326 - foreach ( $get_patternswps_all_cats as $patternwp_cat ) {
327 - $cat_name = sanitize_text_field( $patternwp_cat['name'] );
328 - if ( $cat_name === '' ) {
329 - for ( $page = 1; $page <= 7; $page++ ) {
330 - $cache_key = 'patternswp_' . md5($page . '-' . $patterns_per_page . '-' . $search . '-' . $cat_name);
331 - $check_is_exist = get_transient( $cache_key );
332 -
333 - if ( ! $check_is_exist ) {
334 - $cache_patterns = $this->get_patternswp_pattern( $page, $patterns_per_page, $search, $cat_name );
335 -
336 - if ( ! empty( $cache_patterns ) ) {
337 - set_transient( $cache_key, $cache_patterns, 3600 );
338 - }
339 - }
340 - }
341 - } else {
342 - $page = 1;
343 - $cache_key = 'patternswp_' . md5($page . '-' . $patterns_per_page . '-' . $search . '-' . $cat_name);
344 - $check_is_exist = get_transient( $cache_key );
345 - if ( ! $check_is_exist ) {
346 - $cache_patterns = $this->get_patternswp_pattern( $page, $patterns_per_page, $search, $cat_name );
347 -
348 - if ( ! empty( $cache_patterns ) ) {
349 - set_transient( $cache_key, $cache_patterns, 3600 );
350 - }
351 - }
352 - }
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;
353 1613 }
354 1614 }
1615 +
1616 + $data = json_decode( $raw, true );
1617 + return $this->normalize_patterns_list( $data );
355 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 + }
356 1719 }
357 1720
358 -$patternswp_api_section = new PatternsWP_API_Section();
1721 +PatternsWP_API_Section::get_instance();