| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\BlockPatterns; |
| 4 |
|
| 5 |
use Templately\Utils\Base; |
| 6 |
use Templately\Utils\Database; |
| 7 |
use Templately\Utils\Helper; |
| 8 |
use Templately\Utils\Http; |
| 9 |
use Templately\Utils\Options; |
| 10 |
|
| 11 |
/** |
| 12 |
* Catalog sync: plan-keyed list cache (transient freshness signal + shadow |
| 13 |
* option for stale-while-revalidate) and inert content files for lazy |
| 14 |
* filePath registration. All remote work happens in cron events — an |
| 15 |
* editor-facing read NEVER fetches inline (spec 051 FR-001; research D4/D5). |
| 16 |
*/ |
| 17 |
class PatternSync extends Base { |
| 18 |
|
| 19 |
const EVENT_SYNC_LIST = 'templately_block_patterns_sync_list'; |
| 20 |
const EVENT_SYNC_CONTENT = 'templately_block_patterns_sync_content'; |
| 21 |
|
| 22 |
const LIST_KEY_PREFIX = 'block_patterns_'; |
| 23 |
const SHADOW_KEY_PREFIX = '_templately_block_patterns_shadow_'; |
| 24 |
|
| 25 |
/** |
| 26 |
* Which user's connection this site syncs with. |
| 27 |
* |
| 28 |
* Needed because `Options` resolves the API key through the CURRENT user |
| 29 |
* (`Options::user_id()` → `get_current_user_id()` for a non-global login), and |
| 30 |
* WP-Cron runs with no user at all. So on a locally-connected site the cron |
| 31 |
* sync read an empty key, `plan_key()` returned null, and `sync_list()` |
| 32 |
* returned at its first line — silently, no fetch, no log, forever. Observed |
| 33 |
* live: the event stayed scheduled, fired in 0.04s, and the inserter stayed |
| 34 |
* empty while an admin page load kept rescheduling it. |
| 35 |
*/ |
| 36 |
const OWNER_OPTION = '_templately_block_patterns_owner'; |
| 37 |
|
| 38 |
/** Guards against N editor loads each starting their own catalog fetch. */ |
| 39 |
const LOCK_KEY = 'block_patterns_sync_lock'; |
| 40 |
const LOCK_TTL = 5 * MINUTE_IN_SECONDS; |
| 41 |
|
| 42 |
/** |
| 43 |
* Ids the current user has seen in a LIVE SEARCH, per user. |
| 44 |
* |
| 45 |
* `block-patterns/content` uses the cached catalog as its allow-list, which is |
| 46 |
* exactly where plan gating lives (FR-003) — the native inserter has no |
| 47 |
* insert-time hook, so an item that is not in the cache must not be fetchable. |
| 48 |
* A live-searched item is by definition not in that cache, so it needs its own |
| 49 |
* allow-list: short-lived, per user, and populated only by a search that has |
| 50 |
* ALREADY applied the same plan filter. |
| 51 |
*/ |
| 52 |
const SEARCHED_KEY_PREFIX = 'block_patterns_searched_'; |
| 53 |
const SEARCHED_TTL = 30 * MINUTE_IN_SECONDS; |
| 54 |
const SEARCHED_MAX = 300; |
| 55 |
|
| 56 |
/** |
| 57 |
* Plan cache keys, ordered LOWEST entitlement first. The order is load-bearing: |
| 58 |
* `get_list()` walks it downwards to borrow a lower tier's cache when the |
| 59 |
* current tier has never synced (see lower_tier_list()). |
| 60 |
*/ |
| 61 |
const PLAN_KEYS = [ 'free', 'starter', 'pro' ]; |
| 62 |
const CONTENT_BATCH_SIZE = 10; |
| 63 |
const RESYNC_INTERVAL = 12 * HOUR_IN_SECONDS; |
| 64 |
|
| 65 |
/** Hard stop on pagination, so a bad total_page cannot loop forever. */ |
| 66 |
const MAX_PAGES = 10; |
| 67 |
|
| 68 |
/** |
| 69 |
* How many sections to carry per template type (`templately_block_patterns_per_type`). |
| 70 |
* |
| 71 |
* The catalog is composed BY TYPE rather than as one popularity-ranked run, |
| 72 |
* because the inserter browses by category: a flat top-N fills whichever types |
| 73 |
* happen to be popular and leaves the rest of the sidebar empty. Ten per type |
| 74 |
* gives every category something to show. |
| 75 |
*/ |
| 76 |
const DEFAULT_PER_TYPE = 20; |
| 77 |
|
| 78 |
/** |
| 79 |
* The two full pages the catalog always carries: newest, and most downloaded. |
| 80 |
* |
| 81 |
* Page templates feed the native new-page chooser, which shows EVERY |
| 82 |
* page-kind pattern it is offered — so this stays deliberately tiny. Two |
| 83 |
* curated entries is a chooser someone reads; a per-type fan-out is a wall. |
| 84 |
*/ |
| 85 |
const FEATURED_PAGE_SORTS = [ 'latest', 'download' ]; |
| 86 |
|
| 87 |
/** |
| 88 |
* Runaway cap only — no longer the thing that decides catalog size. |
| 89 |
* |
| 90 |
* It used to be 100 because every registered pattern was inlined into every |
| 91 |
* editor page load (~87KB of `__experimentalAdditionalBlockPatterns`). Since |
| 92 |
* registration moved to `init` nothing inlines them, and the cost is one |
| 93 |
* cached REST response the editor fetches only when it wants patterns. So the |
| 94 |
* composition below decides the size and this is just a backstop against a |
| 95 |
* cloud response nobody expected. |
| 96 |
*/ |
| 97 |
const DEFAULT_CEILING = 1000; |
| 98 |
|
| 99 |
/** Template-type axis cache. Changes rarely; a failed read falls back, never empties. */ |
| 100 |
const TYPES_KEY = 'block_patterns_types'; |
| 101 |
const TYPES_TTL = 24 * HOUR_IN_SECONDS; |
| 102 |
|
| 103 |
/** |
| 104 |
* Seconds one composition run may spend fetching (`templately_block_patterns_sync_budget`). |
| 105 |
* |
| 106 |
* Ten, against PHP's default 30s `max_execution_time`, because this can run |
| 107 |
* inside a request the editor makes and the run still has to publish, write |
| 108 |
* an option and return afterwards. |
| 109 |
*/ |
| 110 |
const SYNC_BUDGET = 10.0; |
| 111 |
|
| 112 |
/** Where a partly-composed catalog parks between runs. Autoloaded off. */ |
| 113 |
const PROGRESS_KEY_PREFIX = '_templately_block_patterns_progress_'; |
| 114 |
|
| 115 |
/** |
| 116 |
* Plan cache key for the connected account, or null when not connected. |
| 117 |
* |
| 118 |
* The cloud returns PRODUCT names, not tiers — a live account reported |
| 119 |
* `lifetime-five-hundred-site`. Matching against an allow-list of tier names |
| 120 |
* therefore downgraded every paying customer to the free catalog. Follow the |
| 121 |
* plugin's own convention instead (see `Admin::is_free_user`): `free` is |
| 122 |
* free, anything else is paid. |
| 123 |
*/ |
| 124 |
public function plan_key(): ?string { |
| 125 |
$api_key = Options::get_instance()->get( 'api_key' ); |
| 126 |
if ( empty( $api_key ) ) { |
| 127 |
return null; |
| 128 |
} |
| 129 |
|
| 130 |
$user = Options::get_instance()->get( 'user', [] ); |
| 131 |
$plan = is_array( $user ) && ! empty( $user['plan'] ) ? strtolower( trim( (string) $user['plan'] ) ) : 'free'; |
| 132 |
|
| 133 |
if ( '' === $plan || 'free' === $plan ) { |
| 134 |
return 'free'; |
| 135 |
} |
| 136 |
|
| 137 |
return 'starter' === $plan ? 'starter' : 'pro'; |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Record whose connection this site syncs with. Called from contexts that |
| 142 |
* HAVE a user (admin_init, the connect hook, the editor's sync request), so |
| 143 |
* the userless cron run has someone to borrow. |
| 144 |
*/ |
| 145 |
public function remember_owner(): void { |
| 146 |
$user_id = get_current_user_id(); |
| 147 |
if ( $user_id < 1 ) { |
| 148 |
return; |
| 149 |
} |
| 150 |
if ( empty( Options::get_instance()->get( 'api_key', '', $user_id ) ) ) { |
| 151 |
return; |
| 152 |
} |
| 153 |
if ( (int) get_option( self::OWNER_OPTION ) === $user_id ) { |
| 154 |
return; |
| 155 |
} |
| 156 |
|
| 157 |
update_option( self::OWNER_OPTION, $user_id, false ); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* The user whose connection to sync with, or 0 when there is none. |
| 162 |
* |
| 163 |
* Global login first: that option IS the site-wide answer, and it is what |
| 164 |
* `Options::user_id()` itself falls back to. Then the remembered owner, whose |
| 165 |
* key is re-checked — a user can disconnect or be deleted without anything |
| 166 |
* clearing this option. |
| 167 |
*/ |
| 168 |
public function owner_id(): int { |
| 169 |
$global = (int) get_option( '_templately_global_login', 0 ); |
| 170 |
if ( $global > 0 && ! empty( Options::get_instance()->get( 'api_key', '', $global ) ) ) { |
| 171 |
return $global; |
| 172 |
} |
| 173 |
|
| 174 |
$owner = (int) get_option( self::OWNER_OPTION, 0 ); |
| 175 |
if ( $owner > 0 && ! empty( Options::get_instance()->get( 'api_key', '', $owner ) ) ) { |
| 176 |
return $owner; |
| 177 |
} |
| 178 |
|
| 179 |
return 0; |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Adopt the owner's identity when the current context has no connection. |
| 184 |
* |
| 185 |
* Returns the user id to restore, or null when nothing was changed. ONLY for |
| 186 |
* the sync entry points: `Http` reads the key through the current user too, |
| 187 |
* so passing an id down to `plan_key()` alone would fix the gate and still |
| 188 |
* send an unauthenticated fetch. |
| 189 |
*/ |
| 190 |
private function assume_owner(): ?int { |
| 191 |
if ( null !== $this->plan_key() ) { |
| 192 |
return null; // Already running as someone connected. |
| 193 |
} |
| 194 |
|
| 195 |
$owner = $this->owner_id(); |
| 196 |
if ( $owner < 1 ) { |
| 197 |
return null; |
| 198 |
} |
| 199 |
|
| 200 |
$previous = get_current_user_id(); |
| 201 |
wp_set_current_user( $owner ); |
| 202 |
|
| 203 |
return $previous; |
| 204 |
} |
| 205 |
|
| 206 |
private function restore_user( ?int $previous ): void { |
| 207 |
if ( null !== $previous ) { |
| 208 |
wp_set_current_user( $previous ); |
| 209 |
} |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* Sync inline when the cache is missing or stale, unless another request is |
| 214 |
* already doing it. Safe to call from a user-facing request — it is what the |
| 215 |
* editor calls on load so the library does not depend on cron firing. |
| 216 |
* |
| 217 |
* @return array{status:string, count:int, plan_key:?string} |
| 218 |
*/ |
| 219 |
public function sync_if_stale(): array { |
| 220 |
$this->remember_owner(); |
| 221 |
|
| 222 |
$previous = $this->assume_owner(); |
| 223 |
|
| 224 |
try { |
| 225 |
$plan_key = $this->plan_key(); |
| 226 |
if ( null === $plan_key ) { |
| 227 |
return [ 'status' => 'not_connected', 'count' => 0, 'plan_key' => null ]; |
| 228 |
} |
| 229 |
|
| 230 |
// The transient IS the freshness signal (it expires at RESYNC_INTERVAL); |
| 231 |
// the shadow option outlives it deliberately, so read the transient here |
| 232 |
// rather than get_list(), which would report a stale shadow as a hit. |
| 233 |
$fresh = Database::get_transient( self::LIST_KEY_PREFIX . $plan_key ); |
| 234 |
|
| 235 |
// A PARKED COMPOSITION IS NOT FRESH, however recently it was written. |
| 236 |
// Each batch publishes what it has, so the transient exists and looks |
| 237 |
// current while most of the catalog is still unfetched — reporting that |
| 238 |
// as fresh would strand the remaining types until cron happened to |
| 239 |
// fire, which is the single point of failure this endpoint exists to |
| 240 |
// remove. The lock below still collapses concurrent editors onto one. |
| 241 |
$parked = 0 !== $this->composition_progress( $plan_key )['cursor']; |
| 242 |
|
| 243 |
if ( ! $parked && is_array( $fresh ) && ! empty( $fresh['items'] ) ) { |
| 244 |
return [ 'status' => 'fresh', 'count' => count( $fresh['items'] ), 'plan_key' => $plan_key ]; |
| 245 |
} |
| 246 |
|
| 247 |
if ( Database::get_transient( self::LOCK_KEY ) ) { |
| 248 |
return [ 'status' => 'syncing', 'count' => 0, 'plan_key' => $plan_key ]; |
| 249 |
} |
| 250 |
|
| 251 |
Database::set_transient( self::LOCK_KEY, time(), self::LOCK_TTL ); |
| 252 |
|
| 253 |
try { |
| 254 |
$this->sync_list(); |
| 255 |
} finally { |
| 256 |
Database::delete_transient( self::LOCK_KEY ); |
| 257 |
} |
| 258 |
|
| 259 |
$list = $this->get_list(); |
| 260 |
|
| 261 |
return [ |
| 262 |
'status' => is_array( $list ) && ! empty( $list['items'] ) ? 'synced' : 'failed', |
| 263 |
'count' => is_array( $list ) ? count( $list['items'] ) : 0, |
| 264 |
'plan_key' => $plan_key, |
| 265 |
]; |
| 266 |
} finally { |
| 267 |
$this->restore_user( $previous ); |
| 268 |
} |
| 269 |
} |
| 270 |
|
| 271 |
/** |
| 272 |
* Fetch + cache the curated list for the current plan. |
| 273 |
* |
| 274 |
* Runs in cron AND, via {@see sync_if_stale()}, in the editor's own request. |
| 275 |
*/ |
| 276 |
public function sync_list(): void { |
| 277 |
$previous = $this->assume_owner(); |
| 278 |
|
| 279 |
try { |
| 280 |
$this->do_sync_list(); |
| 281 |
} finally { |
| 282 |
$this->restore_user( $previous ); |
| 283 |
} |
| 284 |
} |
| 285 |
|
| 286 |
private function do_sync_list(): void { |
| 287 |
$plan_key = $this->plan_key(); |
| 288 |
if ( null === $plan_key ) { |
| 289 |
return; // v1: connected sites only (spec 051 Clarifications). |
| 290 |
} |
| 291 |
|
| 292 |
// Engagement marker: sync traffic must be separable from user imports |
| 293 |
// in the cloud's access logs (spec 051 FR-010 / spec 006 FR-016). |
| 294 |
add_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 295 |
|
| 296 |
// TWO endpoints, and they answer different questions. The blocks endpoint |
| 297 |
// returns only sections, so querying it alone leaves the native new-page |
| 298 |
// chooser with nothing; the pages endpoint feeds only that chooser. |
| 299 |
// |
| 300 |
// The composition is by TEMPLATE TYPE, not one popularity-ranked run. A |
| 301 |
// flat top-N fills whichever types happen to be popular and leaves the |
| 302 |
// rest of the inserter's category sidebar empty, which is the thing that |
| 303 |
// made a 100-item catalog feel small — not the count itself. |
| 304 |
// |
| 305 |
// IT IS ALSO TIME-BOXED AND RESUMABLE, and that is not optional. Composing |
| 306 |
// by type means one cloud request PER TYPE — measured on dev, 22 types plus |
| 307 |
// the two featured pages and the axis query took 49 SECONDS end to end, at |
| 308 |
// roughly 2s a request. `sync_if_stale()` runs this inside a request the |
| 309 |
// editor makes, and PHP's default `max_execution_time` is 30s, so a |
| 310 |
// single-shot composition would be killed halfway on a stock host and |
| 311 |
// leave nothing behind. Each run therefore does as many types as fit in |
| 312 |
// the budget, PUBLISHES what it has, and schedules the rest. |
| 313 |
// |
| 314 |
// Publishing every batch (rather than only the complete set) is what makes |
| 315 |
// a half-finished catalog harmless: the inserter fills in over the next few |
| 316 |
// loads instead of showing nothing until the whole composition lands. |
| 317 |
$progress = $this->composition_progress( $plan_key ); |
| 318 |
|
| 319 |
$items = $progress['items']; |
| 320 |
|
| 321 |
if ( 0 === $progress['cursor'] ) { |
| 322 |
$items = array_merge( $items, $this->fetch_featured_pages( $plan_key ) ); |
| 323 |
} |
| 324 |
|
| 325 |
$types = $this->section_template_types(); |
| 326 |
$per_type = (int) apply_filters( 'templately_block_patterns_per_type', self::DEFAULT_PER_TYPE ); |
| 327 |
$deadline = microtime( true ) + (float) apply_filters( 'templately_block_patterns_sync_budget', self::SYNC_BUDGET ); |
| 328 |
$cursor = $progress['cursor']; |
| 329 |
|
| 330 |
if ( empty( $types ) ) { |
| 331 |
// A failed axis query must not empty the catalog. Fall back to the flat |
| 332 |
// popularity run this replaced, sized to roughly what the composition |
| 333 |
// would have produced, and treat it as complete. |
| 334 |
Helper::log( 'block-patterns: no template types resolved — falling back to a flat popular fetch.' ); |
| 335 |
|
| 336 |
$items = array_merge( $items, $this->fetch_type( 'items', 'section', max( 1, $per_type * 10 ), $plan_key ) ); |
| 337 |
$cursor = 0; |
| 338 |
} else { |
| 339 |
while ( $cursor < count( $types ) ) { |
| 340 |
$items = array_merge( |
| 341 |
$items, |
| 342 |
$this->fetch_type( 'items', 'section', $per_type, $plan_key, [ |
| 343 |
'template_type_id' => (int) $types[ $cursor ]['id'], |
| 344 |
] ) |
| 345 |
); |
| 346 |
|
| 347 |
$cursor++; |
| 348 |
|
| 349 |
// Checked AFTER a type completes, never mid-type: a partially |
| 350 |
// fetched type would be published as if it were all that exists. |
| 351 |
if ( microtime( true ) >= $deadline ) { |
| 352 |
break; |
| 353 |
} |
| 354 |
} |
| 355 |
|
| 356 |
$cursor = $cursor >= count( $types ) ? 0 : $cursor; |
| 357 |
} |
| 358 |
|
| 359 |
remove_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 360 |
|
| 361 |
if ( empty( $items ) ) { |
| 362 |
return; // FR-011: failed sync degrades to the existing cache, never errors. |
| 363 |
} |
| 364 |
|
| 365 |
// One design can be returned by more than one query — the newest full page |
| 366 |
// may also be the most downloaded one, and a section can carry two types. |
| 367 |
$items = $this->unique_by_id( $items ); |
| 368 |
|
| 369 |
$this->save_composition_progress( $plan_key, $cursor, $items ); |
| 370 |
|
| 371 |
$ceiling = (int) apply_filters( 'templately_block_patterns_ceiling', self::DEFAULT_CEILING ); |
| 372 |
|
| 373 |
if ( $ceiling > 0 && count( $items ) > $ceiling ) { |
| 374 |
// Never truncate silently: a catalog that quietly stops at the cap |
| 375 |
// reads as "this is everything the cloud has" when it is not. |
| 376 |
Helper::log( sprintf( |
| 377 |
'block-patterns: composed %d designs, ceiling %d — %d dropped.', |
| 378 |
count( $items ), |
| 379 |
$ceiling, |
| 380 |
count( $items ) - $ceiling |
| 381 |
) ); |
| 382 |
|
| 383 |
$items = $this->trim_evenly( $items, $ceiling ); |
| 384 |
} |
| 385 |
|
| 386 |
$list = [ |
| 387 |
'items' => $items, |
| 388 |
'fetched_at' => time(), |
| 389 |
'plan_key' => $plan_key, |
| 390 |
]; |
| 391 |
|
| 392 |
Database::set_transient( self::LIST_KEY_PREFIX . $plan_key, $list, self::RESYNC_INTERVAL ); |
| 393 |
update_option( self::SHADOW_KEY_PREFIX . $plan_key, $list, false ); |
| 394 |
|
| 395 |
// Only prefetch markup when patterns register EAGERLY. In lazy mode the |
| 396 |
// content is fetched the moment someone inserts a pattern, so warming all |
| 397 |
// ~100 files would be ~6.7MB of downloads and disk for markup that mostly |
| 398 |
// never gets used. |
| 399 |
if ( ! PatternRegistrar::lazy_mode() && $this->missing_content_ids() ) { |
| 400 |
$this->schedule_once( self::EVENT_SYNC_CONTENT ); |
| 401 |
} |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Live catalog search — the one path that is allowed to hit the cloud in |
| 406 |
* response to something the user typed. |
| 407 |
* |
| 408 |
* Deliberately NOT cached in the plan-keyed list: this is a transient answer |
| 409 |
* to a query, not the curated set the inserter registers. What it does write |
| 410 |
* is the per-user allow-list, so the ids it returned become fetchable by |
| 411 |
* `block-patterns/content` — plan gating is applied HERE, at the same |
| 412 |
* boundary, using the same rule as the sync (a free plan never sees a pro |
| 413 |
* item). |
| 414 |
* |
| 415 |
* @return array<int, array> Catalog Items, already plan-filtered. |
| 416 |
*/ |
| 417 |
public function search( string $term, int $limit = 24 ): array { |
| 418 |
$term = trim( $term ); |
| 419 |
if ( '' === $term ) { |
| 420 |
return []; |
| 421 |
} |
| 422 |
|
| 423 |
$previous = $this->assume_owner(); |
| 424 |
|
| 425 |
try { |
| 426 |
$plan_key = $this->plan_key(); |
| 427 |
if ( null === $plan_key ) { |
| 428 |
return []; |
| 429 |
} |
| 430 |
|
| 431 |
add_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 432 |
|
| 433 |
$total = 1; |
| 434 |
$items = array_merge( |
| 435 |
$this->fetch_page( 'items', 'section', $limit, $plan_key, 1, $total, $term ), |
| 436 |
$this->fetch_page( 'pages', 'page', $limit, $plan_key, 1, $total, $term ) |
| 437 |
); |
| 438 |
|
| 439 |
remove_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 440 |
|
| 441 |
$items = array_slice( $items, 0, $limit ); |
| 442 |
|
| 443 |
$this->remember_searched( wp_list_pluck( $items, 'id' ) ); |
| 444 |
|
| 445 |
return $items; |
| 446 |
} finally { |
| 447 |
$this->restore_user( $previous ); |
| 448 |
} |
| 449 |
} |
| 450 |
|
| 451 |
/** |
| 452 |
* Add ids to the acting user's search allow-list. |
| 453 |
* |
| 454 |
* Newest first and capped: the list only has to cover ids the user can still |
| 455 |
* plausibly click, and an uncapped one would grow with every keystroke's |
| 456 |
* results until it expired. |
| 457 |
* |
| 458 |
* @param int[] $ids |
| 459 |
*/ |
| 460 |
private function remember_searched( array $ids ): void { |
| 461 |
$ids = array_values( array_filter( array_map( 'absint', $ids ) ) ); |
| 462 |
if ( empty( $ids ) ) { |
| 463 |
return; |
| 464 |
} |
| 465 |
|
| 466 |
$key = self::SEARCHED_KEY_PREFIX . get_current_user_id(); |
| 467 |
$existing = Database::get_transient( $key ); |
| 468 |
$existing = is_array( $existing ) ? array_map( 'absint', $existing ) : []; |
| 469 |
|
| 470 |
$merged = array_slice( array_values( array_unique( array_merge( $ids, $existing ) ) ), 0, self::SEARCHED_MAX ); |
| 471 |
|
| 472 |
Database::set_transient( $key, $merged, self::SEARCHED_TTL ); |
| 473 |
} |
| 474 |
|
| 475 |
/** |
| 476 |
* Whether this user may fetch the markup for an id that is not in the cached |
| 477 |
* catalog — i.e. one their own search returned. |
| 478 |
*/ |
| 479 |
public function was_searched( int $id ): bool { |
| 480 |
$stored = Database::get_transient( self::SEARCHED_KEY_PREFIX . get_current_user_id() ); |
| 481 |
|
| 482 |
return is_array( $stored ) && in_array( absint( $id ), array_map( 'absint', $stored ), true ); |
| 483 |
} |
| 484 |
|
| 485 |
public function source_marker(): string { |
| 486 |
return 'pattern-sync'; |
| 487 |
} |
| 488 |
|
| 489 |
/** |
| 490 |
* Read the cached list: fresh transient first, then the shadow copy, then a |
| 491 |
* LOWER tier's cache (and schedule an async refresh). Never fetches. |
| 492 |
* Null = nothing has ever synced for this account at any tier. |
| 493 |
*/ |
| 494 |
public function get_list(): ?array { |
| 495 |
$plan_key = $this->plan_key(); |
| 496 |
if ( null === $plan_key ) { |
| 497 |
return null; |
| 498 |
} |
| 499 |
|
| 500 |
$list = Database::get_transient( self::LIST_KEY_PREFIX . $plan_key ); |
| 501 |
if ( is_array( $list ) && isset( $list['items'] ) ) { |
| 502 |
return $list; |
| 503 |
} |
| 504 |
|
| 505 |
$shadow = get_option( self::SHADOW_KEY_PREFIX . $plan_key ); |
| 506 |
if ( is_array( $shadow ) && isset( $shadow['items'] ) ) { |
| 507 |
$this->schedule_once( self::EVENT_SYNC_LIST ); |
| 508 |
|
| 509 |
return $shadow; |
| 510 |
} |
| 511 |
|
| 512 |
return $this->lower_tier_list( $plan_key ); |
| 513 |
} |
| 514 |
|
| 515 |
/** |
| 516 |
* The best cache from a LOWER plan tier, or null when there is none. |
| 517 |
* |
| 518 |
* A plan change switches the cache key to one that has never synced, and the |
| 519 |
* refresh only ever happens in cron — so between the two the account had NO |
| 520 |
* cache at its own key and the entire library vanished from the inserter with |
| 521 |
* no error and no log. That is up to a full resync interval on a healthy site |
| 522 |
* and unbounded on one where cron does not fire (a stalled loopback request is |
| 523 |
* enough). Borrowing the tier below closes that window: entitlement grows |
| 524 |
* monotonically, so anything cached for a lower tier is by definition usable by |
| 525 |
* a higher one, while a refresh for the real tier is scheduled. |
| 526 |
* |
| 527 |
* DOWNWARD ONLY. The reverse would hand a free account the paid catalog and |
| 528 |
* defeat the plan gate that deliberately lives at this cache boundary (FR-003) |
| 529 |
* — the native inserter has no insert-time hook to catch it later. |
| 530 |
* |
| 531 |
* The borrowed list is capped at the ceiling: it was written under another key |
| 532 |
* and may predate the current limit (a real site carried a 3430-item free |
| 533 |
* shadow from an ad-hoc sync), and registering that many patterns is exactly |
| 534 |
* the editor-payload blowup the ceiling exists to prevent. |
| 535 |
*/ |
| 536 |
private function lower_tier_list( string $plan_key ): ?array { |
| 537 |
$rank = array_search( $plan_key, self::PLAN_KEYS, true ); |
| 538 |
if ( ! is_int( $rank ) || $rank < 1 ) { |
| 539 |
return null; // unknown key, or already the lowest tier — nothing below. |
| 540 |
} |
| 541 |
|
| 542 |
// Nearest tier first: closer to the real entitlement than anything under it. |
| 543 |
$lower_keys = array_reverse( array_slice( self::PLAN_KEYS, 0, $rank ) ); |
| 544 |
|
| 545 |
foreach ( $lower_keys as $lower ) { |
| 546 |
$cached = Database::get_transient( self::LIST_KEY_PREFIX . $lower ); |
| 547 |
if ( ! is_array( $cached ) || ! isset( $cached['items'] ) ) { |
| 548 |
$cached = get_option( self::SHADOW_KEY_PREFIX . $lower ); |
| 549 |
} |
| 550 |
if ( ! is_array( $cached ) || ! isset( $cached['items'] ) ) { |
| 551 |
continue; |
| 552 |
} |
| 553 |
|
| 554 |
$this->schedule_once( self::EVENT_SYNC_LIST ); |
| 555 |
|
| 556 |
$ceiling = (int) apply_filters( 'templately_block_patterns_ceiling', 100 ); |
| 557 |
|
| 558 |
$cached['items'] = array_slice( (array) $cached['items'], 0, max( 1, $ceiling ) ); |
| 559 |
$cached['borrowed_from'] = $lower; |
| 560 |
|
| 561 |
return $cached; |
| 562 |
} |
| 563 |
|
| 564 |
return null; |
| 565 |
} |
| 566 |
|
| 567 |
/** |
| 568 |
* Fetch content for up to CONTENT_BATCH_SIZE items missing a file, remove |
| 569 |
* orphaned files, chain another batch if items remain. Cron-context only. |
| 570 |
*/ |
| 571 |
public function sync_content_batch(): void { |
| 572 |
$previous = $this->assume_owner(); |
| 573 |
|
| 574 |
try { |
| 575 |
$this->do_sync_content_batch(); |
| 576 |
} finally { |
| 577 |
$this->restore_user( $previous ); |
| 578 |
} |
| 579 |
} |
| 580 |
|
| 581 |
private function do_sync_content_batch(): void { |
| 582 |
$list = $this->get_list(); |
| 583 |
if ( null === $list ) { |
| 584 |
return; |
| 585 |
} |
| 586 |
|
| 587 |
// Lazy patterns fetch their own markup on insert; prefetching it here |
| 588 |
// would download the whole catalog to serve the few that get used. |
| 589 |
if ( PatternRegistrar::lazy_mode() ) { |
| 590 |
return; |
| 591 |
} |
| 592 |
|
| 593 |
$this->remove_orphan_files( wp_list_pluck( $list['items'], 'id' ) ); |
| 594 |
|
| 595 |
add_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 596 |
|
| 597 |
$fetched = 0; |
| 598 |
foreach ( $this->missing_content_ids() as $id ) { |
| 599 |
if ( $fetched >= self::CONTENT_BATCH_SIZE ) { |
| 600 |
$this->schedule_once( self::EVENT_SYNC_CONTENT ); |
| 601 |
break; |
| 602 |
} |
| 603 |
$content = $this->fetch_content( $id ); |
| 604 |
if ( is_string( $content ) && '' !== $content ) { |
| 605 |
$this->write_content_file( $id, $content ); |
| 606 |
} |
| 607 |
$fetched++; |
| 608 |
} |
| 609 |
|
| 610 |
remove_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 611 |
} |
| 612 |
|
| 613 |
/** |
| 614 |
* Pattern markup for one item: cached file first, otherwise fetched now and |
| 615 |
* cached for next time. |
| 616 |
* |
| 617 |
* This is the read path for the lazy-pattern block — the only moment a |
| 618 |
* pattern's real content is needed is when someone actually inserts it, so |
| 619 |
* a cache miss here is normal rather than exceptional. |
| 620 |
*/ |
| 621 |
public function get_content( int $id ): ?string { |
| 622 |
$path = $this->content_path( $id ); |
| 623 |
if ( file_exists( $path ) ) { |
| 624 |
$cached = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 625 |
|
| 626 |
if ( is_string( $cached ) && '' !== $cached ) { |
| 627 |
return $cached; |
| 628 |
} |
| 629 |
} |
| 630 |
|
| 631 |
add_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 632 |
$content = $this->fetch_content( $id ); |
| 633 |
remove_filter( 'templately_request_source', [ $this, 'source_marker' ] ); |
| 634 |
|
| 635 |
if ( ! is_string( $content ) || '' === $content ) { |
| 636 |
return null; |
| 637 |
} |
| 638 |
|
| 639 |
$this->write_content_file( $id, $content ); |
| 640 |
|
| 641 |
return $content; |
| 642 |
} |
| 643 |
|
| 644 |
/** |
| 645 |
* Write one inert content file. Rejects anything containing a PHP open-tag |
| 646 |
* sequence: the file is later `include`d by WP's pattern registry, so a |
| 647 |
* `<?` that slipped through would EXECUTE (research D2 — RCE guarantee). |
| 648 |
*/ |
| 649 |
public function write_content_file( int $id, string $content ): bool { |
| 650 |
if ( false !== strpos( $content, '<?' ) ) { |
| 651 |
Helper::log( "block-patterns: rejected content for item {$id} (contains PHP open tag)" ); |
| 652 |
|
| 653 |
return false; |
| 654 |
} |
| 655 |
|
| 656 |
$dir = $this->content_dir(); |
| 657 |
if ( ! wp_mkdir_p( $dir ) ) { |
| 658 |
return false; |
| 659 |
} |
| 660 |
$this->harden_dir( $dir ); |
| 661 |
|
| 662 |
$tmp = $dir . "/{$id}.tmp"; |
| 663 |
if ( false === file_put_contents( $tmp, $content ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 664 |
return false; |
| 665 |
} |
| 666 |
|
| 667 |
return rename( $tmp, $this->content_path( $id ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 668 |
} |
| 669 |
|
| 670 |
public function content_path( int $id ): string { |
| 671 |
return $this->content_dir() . "/{$id}.php"; |
| 672 |
} |
| 673 |
|
| 674 |
/** |
| 675 |
* Where cached pattern content lives. |
| 676 |
* |
| 677 |
* Filterable so tests can point it at a scratch directory. They MUST: the |
| 678 |
* content top-up runner removes files whose id is absent from the current |
| 679 |
* list, so a test that seeds a small fixture list and runs a batch will |
| 680 |
* delete every real cached pattern on the site it runs against. That is |
| 681 |
* exactly how a full test run once emptied a working sandbox. |
| 682 |
*/ |
| 683 |
public function content_dir(): string { |
| 684 |
$uploads = wp_upload_dir( null, false ); |
| 685 |
|
| 686 |
return (string) apply_filters( |
| 687 |
'templately_block_patterns_content_dir', |
| 688 |
$uploads['basedir'] . '/templately/patterns' |
| 689 |
); |
| 690 |
} |
| 691 |
|
| 692 |
/** |
| 693 |
* Purge cached catalogs on a real DISCONNECT only. |
| 694 |
* |
| 695 |
* Deliberately narrow. This used to fire on any write to the stored account |
| 696 |
* blob (`_templately_user`), but the plugin rewrites that blob on every |
| 697 |
* routine profile sync — far more often than the plan ever changes — so the |
| 698 |
* cache was emptied during ordinary admin page loads and patterns vanished |
| 699 |
* from the inserter until the next cron run. |
| 700 |
* |
| 701 |
* Nothing else needs a purge: the cache is keyed BY plan, so a plan change |
| 702 |
* simply reads a different key (a free account can never be served a pro |
| 703 |
* catalog), and a disconnected site resolves `plan_key()` to null and serves |
| 704 |
* nothing at all. Correctness comes from the keying, not from eviction — |
| 705 |
* this hook only reclaims space once the site is genuinely disconnected. |
| 706 |
*/ |
| 707 |
public function maybe_invalidate_for_meta( string $meta_key ): void { |
| 708 |
if ( false === strpos( $meta_key, '_templately_api_key' ) ) { |
| 709 |
return; |
| 710 |
} |
| 711 |
|
| 712 |
// Only when the key is actually gone — `Options::set()` on connect writes |
| 713 |
// this same meta, and purging there would throw away a catalog we just |
| 714 |
// synced for the very account that is connecting. |
| 715 |
if ( empty( Options::get_instance()->get( 'api_key' ) ) ) { |
| 716 |
$this->invalidate_all(); |
| 717 |
|
| 718 |
return; |
| 719 |
} |
| 720 |
|
| 721 |
// Just connected — and this hook fires as the connecting user, which is |
| 722 |
// the one context that reliably knows who owns the key. |
| 723 |
$this->remember_owner(); |
| 724 |
|
| 725 |
// Sync NOW rather than waiting for the 12h cron: a fresh connection with |
| 726 |
// an empty library is indistinguishable from a broken feature, and it read |
| 727 |
// exactly that way twice during development. |
| 728 |
if ( null === $this->get_list() ) { |
| 729 |
$this->schedule_once( self::EVENT_SYNC_LIST, 0 ); |
| 730 |
} |
| 731 |
} |
| 732 |
|
| 733 |
public function invalidate_all(): void { |
| 734 |
foreach ( self::PLAN_KEYS as $key ) { |
| 735 |
Database::delete_transient( self::LIST_KEY_PREFIX . $key ); |
| 736 |
delete_option( self::SHADOW_KEY_PREFIX . $key ); |
| 737 |
|
| 738 |
// A parked cursor left behind would make the next composition resume |
| 739 |
// into a catalog that no longer exists, producing a list missing every |
| 740 |
// type before the cursor. |
| 741 |
delete_option( self::PROGRESS_KEY_PREFIX . $key ); |
| 742 |
} |
| 743 |
|
| 744 |
// The type axis too — it is what the next sync composes against, so a |
| 745 |
// "clear the pattern library" that left it behind would rebuild the same |
| 746 |
// catalog shape from a cache the user just asked to be rid of. |
| 747 |
Database::delete_transient( self::TYPES_KEY ); |
| 748 |
|
| 749 |
delete_option( self::OWNER_OPTION ); |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Schedule the first sync when nothing has ever synced (admin_init). |
| 754 |
*/ |
| 755 |
public function ensure_scheduled(): void { |
| 756 |
if ( null === $this->plan_key() ) { |
| 757 |
return; |
| 758 |
} |
| 759 |
|
| 760 |
// admin_init has a real user, so this is where the cron run gets told |
| 761 |
// whose connection to use. |
| 762 |
$this->remember_owner(); |
| 763 |
|
| 764 |
if ( null === $this->get_list() && ! wp_next_scheduled( self::EVENT_SYNC_LIST ) ) { |
| 765 |
$this->schedule_once( self::EVENT_SYNC_LIST ); |
| 766 |
} |
| 767 |
} |
| 768 |
|
| 769 |
/** |
| 770 |
* @param int $delay Seconds from now; 0 makes the event due immediately so |
| 771 |
* the very next request runs it. |
| 772 |
*/ |
| 773 |
private function schedule_once( string $event, int $delay = MINUTE_IN_SECONDS ): void { |
| 774 |
if ( ! wp_next_scheduled( $event ) ) { |
| 775 |
wp_schedule_single_event( time() + $delay, $event ); |
| 776 |
} |
| 777 |
} |
| 778 |
|
| 779 |
/** |
| 780 |
* Fetch one catalog endpoint and normalise it into Catalog Items. |
| 781 |
* |
| 782 |
* `kind` comes from WHICH endpoint answered, not from a field on the item: |
| 783 |
* the blocks endpoint returns sections and the pages endpoint returns page |
| 784 |
* templates, and the payload itself does not distinguish them reliably. |
| 785 |
* |
| 786 |
* @param string $endpoint Cloud query name (`items` | `pages`). |
| 787 |
* @param string $kind Kind to stamp on everything it returns. |
| 788 |
* @return array<int, array> |
| 789 |
*/ |
| 790 |
/** |
| 791 |
* The two full pages the chooser gets: newest, and most downloaded. |
| 792 |
* |
| 793 |
* @return array<int, array> |
| 794 |
*/ |
| 795 |
private function fetch_featured_pages( string $plan_key ): array { |
| 796 |
$pages = []; |
| 797 |
|
| 798 |
foreach ( self::FEATURED_PAGE_SORTS as $sort ) { |
| 799 |
$total_pages = 1; |
| 800 |
|
| 801 |
$batch = $this->fetch_page( 'pages', 'page', 1, $plan_key, 1, $total_pages, '', [ 'sort_by' => $sort ] ); |
| 802 |
|
| 803 |
if ( ! empty( $batch[0] ) ) { |
| 804 |
$pages[] = $batch[0]; |
| 805 |
} |
| 806 |
} |
| 807 |
|
| 808 |
return $pages; |
| 809 |
} |
| 810 |
|
| 811 |
/** |
| 812 |
* Where the last run stopped, and what it had collected by then. |
| 813 |
* |
| 814 |
* A cursor of 0 means "start a fresh composition" — which is also what a |
| 815 |
* completed run leaves behind, so the next scheduled sync recomposes from |
| 816 |
* the top rather than resuming a finished one. |
| 817 |
* |
| 818 |
* @return array{cursor:int, items:array<int, array>} |
| 819 |
*/ |
| 820 |
private function composition_progress( string $plan_key ): array { |
| 821 |
$saved = get_option( self::PROGRESS_KEY_PREFIX . $plan_key ); |
| 822 |
|
| 823 |
if ( ! is_array( $saved ) || empty( $saved['cursor'] ) ) { |
| 824 |
return [ 'cursor' => 0, 'items' => [] ]; |
| 825 |
} |
| 826 |
|
| 827 |
return [ |
| 828 |
'cursor' => (int) $saved['cursor'], |
| 829 |
'items' => isset( $saved['items'] ) && is_array( $saved['items'] ) ? $saved['items'] : [], |
| 830 |
]; |
| 831 |
} |
| 832 |
|
| 833 |
/** |
| 834 |
* Park the cursor and chain the next batch, or clear it when complete. |
| 835 |
* |
| 836 |
* @param int $cursor Next type index, or 0 when the composition finished. |
| 837 |
* @param array $items Everything collected so far. |
| 838 |
* @return void |
| 839 |
*/ |
| 840 |
private function save_composition_progress( string $plan_key, int $cursor, array $items ): void { |
| 841 |
if ( $cursor < 1 ) { |
| 842 |
delete_option( self::PROGRESS_KEY_PREFIX . $plan_key ); |
| 843 |
|
| 844 |
return; |
| 845 |
} |
| 846 |
|
| 847 |
update_option( self::PROGRESS_KEY_PREFIX . $plan_key, [ |
| 848 |
'cursor' => $cursor, |
| 849 |
'items' => $items, |
| 850 |
], false ); |
| 851 |
|
| 852 |
// Chain immediately rather than waiting for the 12h cycle. If cron never |
| 853 |
// fires — the failure mode this module already plans around — the editor's |
| 854 |
// own sync request picks the composition up instead, because a parked |
| 855 |
// cursor makes the cache "not fresh enough" (see sync_if_stale()). |
| 856 |
$this->schedule_once( self::EVENT_SYNC_LIST, 0 ); |
| 857 |
} |
| 858 |
|
| 859 |
/** |
| 860 |
* The section template types, cached. |
| 861 |
* |
| 862 |
* `groupedCategories` is the cloud's own axis and takes no platform argument, |
| 863 |
* so it can name a type that has no Gutenberg designs at all. Those cost one |
| 864 |
* request that returns nothing and are then absent from the catalog, which is |
| 865 |
* the correct outcome — filtering them out up front would mean trusting |
| 866 |
* `platforms`, a free-form string, to be parseable. |
| 867 |
* |
| 868 |
* @return array<int, array{id:int, slug:string}> |
| 869 |
*/ |
| 870 |
private function section_template_types(): array { |
| 871 |
$cached = Database::get_transient( self::TYPES_KEY ); |
| 872 |
|
| 873 |
if ( is_array( $cached ) ) { |
| 874 |
return $cached; |
| 875 |
} |
| 876 |
|
| 877 |
$response = Http::get_instance() |
| 878 |
->query( 'groupedCategories', 'item_categories { id, name, slug, type }', [] ) |
| 879 |
->post(); |
| 880 |
|
| 881 |
if ( is_wp_error( $response ) || empty( $response['item_categories'] ) ) { |
| 882 |
return []; |
| 883 |
} |
| 884 |
|
| 885 |
$types = []; |
| 886 |
|
| 887 |
foreach ( (array) $response['item_categories'] as $type ) { |
| 888 |
$id = absint( $type['id'] ?? 0 ); |
| 889 |
|
| 890 |
// `type` is the cloud's page-vs-block marker. Pages are composed from |
| 891 |
// FEATURED_PAGE_SORTS, not fanned out per type, so only blocks here. |
| 892 |
if ( ! $id || ( ! empty( $type['type'] ) && 'block' !== $type['type'] ) ) { |
| 893 |
continue; |
| 894 |
} |
| 895 |
|
| 896 |
$types[] = [ |
| 897 |
'id' => $id, |
| 898 |
'slug' => sanitize_key( $type['slug'] ?? '' ), |
| 899 |
]; |
| 900 |
} |
| 901 |
|
| 902 |
Database::set_transient( self::TYPES_KEY, $types, self::TYPES_TTL ); |
| 903 |
|
| 904 |
return $types; |
| 905 |
} |
| 906 |
|
| 907 |
/** |
| 908 |
* Cut the catalog to the ceiling WITHOUT emptying whole categories. |
| 909 |
* |
| 910 |
* The composition appends one category at a time, so a plain |
| 911 |
* `array_slice( $items, 0, $ceiling )` removes the LAST categories entirely |
| 912 |
* — reinstating exactly the empty-sidebar problem composing by category |
| 913 |
* exists to fix, just at the tail instead of the middle. It also drops the |
| 914 |
* two featured pages last, which is the wrong order: they are 2 rows and the |
| 915 |
* only thing feeding the new-page chooser. |
| 916 |
* |
| 917 |
* So: keep the pages, then take from each category in turn until the budget |
| 918 |
* is spent. Every category keeps its most-downloaded designs (the cloud's own |
| 919 |
* order within a category is preserved) and every category keeps something. |
| 920 |
* |
| 921 |
* @param array<int, array> $items |
| 922 |
* @return array<int, array> |
| 923 |
*/ |
| 924 |
private function trim_evenly( array $items, int $ceiling ): array { |
| 925 |
$pages = []; |
| 926 |
$by_group = []; |
| 927 |
|
| 928 |
foreach ( $items as $item ) { |
| 929 |
if ( isset( $item['kind'] ) && 'page' === $item['kind'] ) { |
| 930 |
$pages[] = $item; |
| 931 |
continue; |
| 932 |
} |
| 933 |
|
| 934 |
$group = isset( $item['category'] ) ? (string) $item['category'] : 'general'; |
| 935 |
$by_group[ $group ][] = $item; |
| 936 |
} |
| 937 |
|
| 938 |
$kept = array_slice( $pages, 0, $ceiling ); |
| 939 |
|
| 940 |
// Round-robin, so the cut falls on the deepest categories rather than on |
| 941 |
// whichever ones happen to sort last. |
| 942 |
$round = 0; |
| 943 |
while ( count( $kept ) < $ceiling ) { |
| 944 |
$took = false; |
| 945 |
|
| 946 |
foreach ( $by_group as $group => $group_items ) { |
| 947 |
if ( ! isset( $group_items[ $round ] ) ) { |
| 948 |
continue; |
| 949 |
} |
| 950 |
|
| 951 |
$kept[] = $group_items[ $round ]; |
| 952 |
$took = true; |
| 953 |
|
| 954 |
if ( count( $kept ) >= $ceiling ) { |
| 955 |
break; |
| 956 |
} |
| 957 |
} |
| 958 |
|
| 959 |
if ( ! $took ) { |
| 960 |
break; // every category exhausted |
| 961 |
} |
| 962 |
|
| 963 |
$round++; |
| 964 |
} |
| 965 |
|
| 966 |
return $kept; |
| 967 |
} |
| 968 |
|
| 969 |
/** |
| 970 |
* Collapse designs returned by more than one query, keeping the first seen. |
| 971 |
* |
| 972 |
* @param array<int, array> $items |
| 973 |
* @return array<int, array> |
| 974 |
*/ |
| 975 |
private function unique_by_id( array $items ): array { |
| 976 |
$seen = []; |
| 977 |
$unique = []; |
| 978 |
|
| 979 |
foreach ( $items as $item ) { |
| 980 |
$id = (int) ( $item['id'] ?? 0 ); |
| 981 |
|
| 982 |
if ( ! $id || isset( $seen[ $id ] ) ) { |
| 983 |
continue; |
| 984 |
} |
| 985 |
|
| 986 |
$seen[ $id ] = true; |
| 987 |
$unique[] = $item; |
| 988 |
} |
| 989 |
|
| 990 |
return $unique; |
| 991 |
} |
| 992 |
|
| 993 |
private function fetch_type( string $endpoint, string $kind, int $limit, string $plan_key, array $extra = [] ): array { |
| 994 |
if ( $limit < 1 ) { |
| 995 |
return []; |
| 996 |
} |
| 997 |
|
| 998 |
// The cloud caps its own page size, so one request returns fewer rows than |
| 999 |
// `per_page` asks for and reports the real total in `total_page`. Ignoring |
| 1000 |
// that capped the catalog at whatever page 1 happened to hold. |
| 1001 |
$items = []; |
| 1002 |
$page = 1; |
| 1003 |
$total_pages = 1; // replaced by the first response; must be set before the guard reads it |
| 1004 |
|
| 1005 |
do { |
| 1006 |
$batch = $this->fetch_page( $endpoint, $kind, $limit, $plan_key, $page, $total_pages, '', $extra ); |
| 1007 |
$items = array_merge( $items, $batch ); |
| 1008 |
$page++; |
| 1009 |
} while ( count( $items ) < $limit && $page <= (int) $total_pages && $page <= self::MAX_PAGES ); |
| 1010 |
|
| 1011 |
return array_slice( $items, 0, $limit ); |
| 1012 |
} |
| 1013 |
|
| 1014 |
/** |
| 1015 |
* One page of one endpoint. |
| 1016 |
* |
| 1017 |
* @param int|null $total_pages Set to the endpoint's reported page count. |
| 1018 |
* @return array<int, array> |
| 1019 |
*/ |
| 1020 |
/** |
| 1021 |
* The catalog's dependency rows, sanitized but NOT renamed. |
| 1022 |
* |
| 1023 |
* The field names stay exactly as the cloud sends them — `plugin_file`, |
| 1024 |
* `plugin_original_slug`, `is_pro`, `link` — because these rows are handed |
| 1025 |
* straight to `templately/v1/dependencies/check` and `.../install`, the same |
| 1026 |
* endpoints the full-site and single-import dependency steps post to. Renaming |
| 1027 |
* them into a shape of our own would mean writing a second installer for a |
| 1028 |
* problem that already has one. |
| 1029 |
* |
| 1030 |
* Anything without a `plugin_file` is dropped: `check_dependencies()` skips |
| 1031 |
* such a row anyway, and there would be nothing to install. |
| 1032 |
* |
| 1033 |
* @param mixed $dependencies Raw `dependencies` array from the items query. |
| 1034 |
* @return array<int,array<string,mixed>> |
| 1035 |
*/ |
| 1036 |
private static function normalize_dependencies( $dependencies ): array { |
| 1037 |
if ( ! is_array( $dependencies ) ) { |
| 1038 |
return []; |
| 1039 |
} |
| 1040 |
|
| 1041 |
$rows = []; |
| 1042 |
foreach ( $dependencies as $dependency ) { |
| 1043 |
$dependency = (array) $dependency; |
| 1044 |
$file = isset( $dependency['plugin_file'] ) ? (string) $dependency['plugin_file'] : ''; |
| 1045 |
|
| 1046 |
// `plugin_file` is a path (`folder/file.php`), so sanitize_text_field is |
| 1047 |
// the wrong tool — strip anything that could escape the plugins dir. |
| 1048 |
$file = ltrim( str_replace( [ '..', '\\' ], '', $file ), '/' ); |
| 1049 |
if ( '' === $file || false === strpos( $file, '/' ) ) { |
| 1050 |
continue; |
| 1051 |
} |
| 1052 |
|
| 1053 |
$rows[] = [ |
| 1054 |
'plugin_file' => $file, |
| 1055 |
'name' => sanitize_text_field( (string) ( $dependency['name'] ?? '' ) ), |
| 1056 |
'plugin_original_slug' => sanitize_key( (string) ( $dependency['plugin_original_slug'] ?? '' ) ), |
| 1057 |
'is_pro' => ! empty( $dependency['is_pro'] ), |
| 1058 |
'link' => esc_url_raw( (string) ( $dependency['link'] ?? '' ) ), |
| 1059 |
]; |
| 1060 |
} |
| 1061 |
|
| 1062 |
return $rows; |
| 1063 |
} |
| 1064 |
|
| 1065 |
private function fetch_page( string $endpoint, string $kind, int $limit, string $plan_key, int $page, &$total_pages, string $search = '', array $extra = [] ): array { |
| 1066 |
$total_pages = 1; |
| 1067 |
|
| 1068 |
$args = [ |
| 1069 |
'platform' => 'gutenberg', |
| 1070 |
'page' => $page, |
| 1071 |
'per_page' => $limit, |
| 1072 |
'sort_by' => 'download', |
| 1073 |
]; |
| 1074 |
|
| 1075 |
// Composition arguments — `template_type_id` for the per-type fan-out, and |
| 1076 |
// `sort_by` for the two featured pages. Merged BEFORE the search branch so |
| 1077 |
// a live search still wins on ordering, which is its whole point. |
| 1078 |
if ( $extra ) { |
| 1079 |
$args = array_merge( $args, $extra ); |
| 1080 |
} |
| 1081 |
|
| 1082 |
// The cloud's own relevance ordering, which only applies when searching. |
| 1083 |
if ( '' !== $search ) { |
| 1084 |
$args['search'] = $search; |
| 1085 |
$args['sort_by'] = 'latest'; |
| 1086 |
} |
| 1087 |
|
| 1088 |
// `dependencies` is the CLOUD's own answer to "what does this design need", |
| 1089 |
// the same field the full-site and single-import dependency steps read. It |
| 1090 |
// carries the plugin file, the wordpress.org slug and the purchase link, so |
| 1091 |
// nothing on our side has to infer a plugin from a block namespace — which |
| 1092 |
// cannot be done reliably anyway: a free plugin may register its paid tier's |
| 1093 |
// block names as upsell stubs, and core's own block-directory search resolves |
| 1094 |
// `woocommerce/mini-cart` to an unrelated plugin called OffCanvas. |
| 1095 |
// |
| 1096 |
// `pack` is what lets the insert apply the pack's global colours and |
| 1097 |
// typography, the way single-import already does. Both fields were already |
| 1098 |
// available on this query; asking for them costs nothing extra. |
| 1099 |
$query = 'total_page, current_page, data { id, name, price, type, template_type{ slug }, slug, thumbnail, tags{ name }, pack { id, has_settings }, dependencies{ name, plugin_file, plugin_original_slug, is_pro, link } }'; |
| 1100 |
$response = Http::get_instance()->query( $endpoint, $query, $args )->post(); |
| 1101 |
|
| 1102 |
if ( is_wp_error( $response ) || empty( $response['data'] ) || ! is_array( $response['data'] ) ) { |
| 1103 |
return []; |
| 1104 |
} |
| 1105 |
|
| 1106 |
$total_pages = isset( $response['total_page'] ) ? (int) $response['total_page'] : 1; |
| 1107 |
|
| 1108 |
$items = []; |
| 1109 |
foreach ( $response['data'] as $item ) { |
| 1110 |
if ( empty( $item['id'] ) ) { |
| 1111 |
continue; |
| 1112 |
} |
| 1113 |
|
| 1114 |
$is_pro = ! empty( $item['price'] ) && (float) $item['price'] > 0; |
| 1115 |
// Free accounts must never even cache a pro item under their key — |
| 1116 |
// registration-side gating starts at the cache boundary (FR-003). |
| 1117 |
if ( 'free' === $plan_key && $is_pro ) { |
| 1118 |
continue; |
| 1119 |
} |
| 1120 |
|
| 1121 |
$items[] = [ |
| 1122 |
'id' => absint( $item['id'] ), |
| 1123 |
'title' => sanitize_text_field( $item['name'] ?? '' ), |
| 1124 |
'slug' => sanitize_key( $item['slug'] ?? '' ), |
| 1125 |
'kind' => $kind, |
| 1126 |
'plan' => $is_pro ? 'pro' : 'free', |
| 1127 |
'category' => sanitize_key( $item['template_type']['slug'] ?? 'general' ), |
| 1128 |
'preview_url' => esc_url_raw( $item['thumbnail'] ?? '' ), |
| 1129 |
// Feeds the inserter's `keywords` scoring field. |
| 1130 |
'tags' => array_values( array_filter( array_map( |
| 1131 |
'sanitize_text_field', |
| 1132 |
wp_list_pluck( (array) ( $item['tags'] ?? [] ), 'name' ) |
| 1133 |
) ) ), |
| 1134 |
// What this design needs installed, straight from the catalog. |
| 1135 |
'requires' => self::normalize_dependencies( $item['dependencies'] ?? [] ), |
| 1136 |
// Pack provenance, read at insert time to merge the pack's global |
| 1137 |
// settings into the markup. Both absent for a standalone item, and |
| 1138 |
// absent from any cache written before this field existed — the |
| 1139 |
// insert then behaves exactly as it did before, unstyled but working, |
| 1140 |
// until the next resync fills them in. |
| 1141 |
'pack_id' => absint( $item['pack']['id'] ?? 0 ), |
| 1142 |
'has_settings' => ! empty( $item['pack']['has_settings'] ), |
| 1143 |
]; |
| 1144 |
} |
| 1145 |
|
| 1146 |
return $items; |
| 1147 |
} |
| 1148 |
|
| 1149 |
/** |
| 1150 |
* @return int[] Item ids in the current list with no content file yet. |
| 1151 |
*/ |
| 1152 |
private function missing_content_ids(): array { |
| 1153 |
$list = $this->get_list(); |
| 1154 |
if ( null === $list ) { |
| 1155 |
return []; |
| 1156 |
} |
| 1157 |
|
| 1158 |
$missing = []; |
| 1159 |
foreach ( $list['items'] as $item ) { |
| 1160 |
if ( ! file_exists( $this->content_path( (int) $item['id'] ) ) ) { |
| 1161 |
$missing[] = (int) $item['id']; |
| 1162 |
} |
| 1163 |
} |
| 1164 |
|
| 1165 |
return $missing; |
| 1166 |
} |
| 1167 |
|
| 1168 |
private function remove_orphan_files( array $live_ids ): void { |
| 1169 |
$live = array_map( 'intval', $live_ids ); |
| 1170 |
$files = glob( $this->content_dir() . '/*.php' ); |
| 1171 |
foreach ( (array) $files as $file ) { |
| 1172 |
$basename = basename( $file, '.php' ); |
| 1173 |
if ( 'index' === $basename ) { |
| 1174 |
continue; |
| 1175 |
} |
| 1176 |
if ( ! in_array( (int) $basename, $live, true ) ) { |
| 1177 |
wp_delete_file( $file ); |
| 1178 |
} |
| 1179 |
} |
| 1180 |
} |
| 1181 |
|
| 1182 |
private function fetch_content( int $id ): ?string { |
| 1183 |
$api_key = Options::get_instance()->get( 'api_key' ); |
| 1184 |
$response = Http::get_instance()->query( 'itemContent', 'status, message, data', [ |
| 1185 |
'api_key' => $api_key, |
| 1186 |
'id' => $id, |
| 1187 |
] )->post(); |
| 1188 |
|
| 1189 |
if ( is_wp_error( $response ) || empty( $response['data'] ) ) { |
| 1190 |
return null; |
| 1191 |
} |
| 1192 |
|
| 1193 |
$data = is_string( $response['data'] ) ? json_decode( $response['data'], true ) : (array) $response['data']; |
| 1194 |
|
| 1195 |
return isset( $data['content'] ) && is_string( $data['content'] ) ? $data['content'] : null; |
| 1196 |
} |
| 1197 |
|
| 1198 |
private function harden_dir( string $dir ): void { |
| 1199 |
if ( ! file_exists( $dir . '/.htaccess' ) ) { |
| 1200 |
file_put_contents( $dir . '/.htaccess', "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 1201 |
} |
| 1202 |
if ( ! file_exists( $dir . '/index.php' ) ) { |
| 1203 |
file_put_contents( $dir . '/index.php', "<?php // Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 1204 |
} |
| 1205 |
} |
| 1206 |
} |
| 1207 |
|