PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / posimyth-sdk / class-posimyth-tracker-wdk.php

class-posimyth-tracker-wdk.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.1, at includes/posimyth-sdk/class-posimyth-tracker-wdk.php

725 lines 29.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POSIMYTH Analytics tracker — WDesignKit (WDK).
4 *
5 * Requires class-posimyth-tracker-base.php to be loaded first.
6 * Boot from the main plugin file: Posimyth_Tracker_WDK::init();
7 *
8 * Verified against WDesignKit 2.6.3 in this repo:
9 * - version constant : WDKIT_VERSION
10 * - settings : wkit_settings_panel — the ten feature toggles wdesignkit/settings exposes
11 * - onboarding : wkit_onbording_end (Wdkit_Api::$wdkit_onbording_end)
12 * - licence : wdkit_licence_data, written by the licence ability and class-api.php
13 * - widgets : files under WDKIT_BUILDER_PATH/{builder}/{folder}/*.json, the same layout
14 * wdesignkit/list-widgets reads; disabled ones listed in wkit_deactivate_widgets
15 * - widget placement : all four builders — Elementor `"widgetType":"wb-<id>"`, Gutenberg and
16 * Gutenberg core `wp:wdkit/<slug>`, Bricks `"name":"wdkit-<slug>"`; see
17 * used_features() for where each of those names comes from
18 * - template imports : the wdkit_template_imported action added to Wdkit_Api's two import routes,
19 * plus the older wdkit_after_kit_import used by the ability route
20 *
21 * Consent is deliberately WDesignKit's OWN — posimyth_wdk_share_analytics, under suite_key
22 * `wdk_suite`. Nexter Extension and Nexter Blocks share one answer because they are one brand with
23 * one dashboard; WDesignKit is a separate product on its own site (wdesignkit.com) with its own
24 * dashboard and its own audience, so it asks and stores separately — the same reasoning The Plus
25 * Addons for Elementor and Sticky Header Effects follow.
26 *
27 * @package POSIMYTH\Analytics\SDK
28 */
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 // Self-load the shared base so this subclass defines correctly regardless of require order.
35 if ( ! class_exists( 'Posimyth_Tracker_Base' ) ) {
36 require_once __DIR__ . '/class-posimyth-tracker-base.php';
37 }
38
39 if ( ! class_exists( 'Posimyth_Tracker_WDK' ) && class_exists( 'Posimyth_Tracker_Base' ) ) {
40
41 /**
42 * WDesignKit's tracker: supplies the product-specific identity, settings, widget catalogue and
43 * usage that the shared base assembles into a payload.
44 */
45 class Posimyth_Tracker_WDK extends Posimyth_Tracker_Base {
46
47 /**
48 * WDesignKit's OWN consent option — deliberately NOT the one Nexter Extension and Nexter
49 * Blocks share, and not The Plus Addons' or Sticky Header Effects' either.
50 *
51 * Do NOT point this at posimyth_nexter_share_analytics.
52 */
53 const OPT_IN_OPTION = 'posimyth_wdk_share_analytics';
54
55 /**
56 * Running count of templates imported on this site.
57 *
58 * WDesignKit's templates live in the cloud, not in the database — includes/abilities/templates/
59 * every one of them calls WDKIT_SERVER_API_URL. So there is nothing local to count after the
60 * fact, and this counter only knows about imports that happen from this version onward. A site
61 * that imported fifty templates last year reports zero until it imports its next one.
62 */
63 const IMPORTS_OPTION = 'wdkit_template_imports';
64
65 /**
66 * Last cloud usage figures seen by the dashboard.
67 *
68 * Written by Wdkit_Api::wdkit_check_user_credit(), which is the only place the storage and
69 * credit numbers exist: the cloud endpoint needs a user token that only the logged-in
70 * dashboard has, so a background heartbeat can never fetch them itself. Read here as a
71 * possibly-stale cache, and always reported alongside its own age so the hub can discard
72 * figures that are too old to mean anything.
73 */
74 const USAGE_OPTION = 'wdkit_cloud_usage';
75
76 /**
77 * Short internal id used for this product's own options (install time, usage cache).
78 *
79 * @return string
80 */
81 protected static function id(): string {
82 return 'wdk';
83 }
84
85 /**
86 * Plugin slug reported to the hub.
87 *
88 * Must match an entry in the hub's PLUGIN_SLUGS allowlist, or every ping is rejected.
89 *
90 * @return string
91 */
92 protected static function slug(): string {
93 return 'wdesignkit';
94 }
95
96 /**
97 * Name shown in WordPress's Privacy Policy suggestions and in the consent notice.
98 *
99 * @return string
100 */
101 protected static function display_name(): string {
102 return 'WDesignKit';
103 }
104
105 /**
106 * Option holding WDesignKit's own sharing consent — see OPT_IN_OPTION.
107 *
108 * @return string
109 */
110 protected static function opt_in_option(): string {
111 return self::OPT_IN_OPTION;
112 }
113
114 /**
115 * Currently installed version of this plugin.
116 *
117 * @return string
118 */
119 protected static function version(): string {
120 return defined( 'WDKIT_VERSION' ) ? WDKIT_VERSION : '';
121 }
122
123 /**
124 * Whether this install is on a paid plan.
125 *
126 * WDesignKit ships as ONE plugin — there is no separate Pro build and so no Pro constant to
127 * check, unlike every sibling product. The only paid/free signal is whether a licence record
128 * has been stored, which the licence ability and class-api.php both write to
129 * wdkit_licence_data.
130 *
131 * Presence, not validity: an expired licence still reports Pro here, because an expired paid
132 * install is exactly the cohort worth seeing rather than one to hide among free users.
133 * Validity travels separately, in license().
134 *
135 * @return bool
136 */
137 protected static function is_pro(): bool {
138 $licence = get_option( 'wdkit_licence_data', array() );
139
140 return is_array( $licence ) && ! empty( $licence );
141 }
142
143 /**
144 * Licence status and plan.
145 *
146 * The cloud owns this record's shape and has changed its field names before — the licence
147 * ability itself probes six different spellings of the key field (ApiKey, api_key, licencekey,
148 * licence_key, key, item_api_key). So every field here is read defensively and anything
149 * unrecognised reports empty rather than a guess.
150 *
151 * The record as the cloud writes it today (class-api.php and includes/abilities/licence/):
152 * `license` => 'valid' | 'expired' | 'invalid' | 'site_inactive', `user_type` =>
153 * 'agency-lifetime' | 'studio-lifetime' | 'personal-yearly' | …, `price_id`, `item_name` =>
154 * 'WDesignKit', `expires`, plus the credential and the customer's name and email. Only the
155 * first two are read here — see each probe list below for why the others are not.
156 *
157 * The licence KEY is never read into the payload and must never be added: it is a credential,
158 * and the consent copy promises non-sensitive data only.
159 *
160 * @return array{status:string, plan:string}
161 */
162 protected static function license(): array {
163 $licence = get_option( 'wdkit_licence_data', array() );
164
165 if ( ! is_array( $licence ) || empty( $licence ) ) {
166 return array(
167 'status' => '',
168 'plan' => '',
169 );
170 }
171
172 /*
173 * `license` FIRST, because that is the field the cloud actually writes.
174 *
175 * The record stored by class-api.php and the licence ability looks like
176 * ['success'=>true,'license'=>'valid','item_id'=>…,'item_name'=>'WDesignKit','expires'=>…,
177 * 'user_type'=>'agency-lifetime','price_id'=>…,'ApiKey'=>…] — it carries no `status`,
178 * `licence_status` or `license_status` at all. Probing only for those three meant $status
179 * stayed empty on every licensed install, the 'active' fallback below fired, and expired,
180 * invalid and site-inactive licences were reported to the hub as 'active' — removing the one
181 * cohort this field exists to surface. The three legacy spellings are kept behind it: the
182 * cloud has renamed this field before, and a record written by an older or newer shape must
183 * still be read rather than guessed at.
184 */
185 $status = '';
186 foreach ( array( 'license', 'status', 'licence_status', 'license_status' ) as $field ) {
187 if ( ! empty( $licence[ $field ] ) && is_scalar( $licence[ $field ] ) ) {
188 $status = (string) $licence[ $field ];
189 break;
190 }
191 }
192
193 /*
194 * The PLAN lives in `user_type` (e.g. 'agency-lifetime', 'studio-lifetime',
195 * 'personal-yearly'), with `price_id` as the cloud's numeric equivalent.
196 *
197 * `item_name` and `product_name` are deliberately NOT probed. They hold the product name —
198 * always the literal 'WDesignKit' — so while they were in this list every paying customer on
199 * every tier reported license_plan = 'WDesignKit' and the field carried no information at
200 * all. A wrong-but-plausible value is worse than an empty one here: empty is visibly
201 * missing on the hub, whereas 'WDesignKit' looks like a real answer.
202 */
203 $plan = '';
204 foreach ( array( 'plan', 'plan_name', 'user_type', 'price_id' ) as $field ) {
205 if ( ! empty( $licence[ $field ] ) && is_scalar( $licence[ $field ] ) ) {
206 $plan = (string) $licence[ $field ];
207 break;
208 }
209 }
210
211 // A stored record with no readable status is still a licensed install, so say so rather
212 // than reporting it as indistinguishable from a free one.
213 return array(
214 'status' => '' !== $status ? $status : 'active',
215 'plan' => $plan,
216 );
217 }
218
219 /**
220 * Whether the setup wizard has been finished on this site.
221 *
222 * Read as truthy rather than compared to a specific value, so a future change to what the
223 * wizard writes does not silently reset every site's onboarding figure to pending.
224 *
225 * @return string 'completed' or 'pending'.
226 */
227 protected static function onboarding_status(): string {
228 return get_option( 'wkit_onbording_end' ) ? 'completed' : 'pending';
229 }
230
231 /**
232 * Registers the shared hooks plus WDesignKit's own template-import counter.
233 *
234 * The counter is deliberately registered regardless of consent: it only ever writes to a local
235 * option, and nothing leaves the site unless the persistent sharing toggle is on. Gating the
236 * COUNTING on consent instead would mean a site that opts in later reports zero imports
237 * forever, which is the same trap install_time avoids by being recorded up front.
238 *
239 * @return void
240 */
241 public static function init(): void {
242 parent::init();
243
244 // Dashboard import routes — Wdkit_Api::wdkit_import_template() for a single template and
245 // wdkit_import_kit_template() for a page kit. Both carry the builder and the kind.
246 add_action( 'wdkit_template_imported', array( static::class, 'record_template_import' ), 10, 3 );
247
248 // The MCP/ability route reaches the same outcome through its own older hook, which knows
249 // neither the kind nor the builder — it is always a kit, and the builder is unknown rather
250 // than guessed. Separate entry point, so this does not double-count the routes above.
251 add_action( 'wdkit_after_kit_import', array( static::class, 'record_kit_import' ), 10, 2 );
252 }
253
254 /**
255 * Records completed template imports.
256 *
257 * Stores a total, a split by kind (single template vs page kit) and one by builder, plus the
258 * date of the most recent import — so a site that imported once two years ago is
259 * distinguishable from one importing every week, and a kit of twelve pages is distinguishable
260 * from twelve separate sections.
261 *
262 * The template ids themselves are NOT stored: they identify cloud objects the hub cannot
263 * resolve without joining to the user's account, and a growing list of them in an option is
264 * how options turn into bloat.
265 *
266 * @param string $kind 'single' or 'kit'.
267 * @param string $builder Builder the template was imported for; empty when unknown.
268 * @param int $count How many templates this import brought in.
269 * @return void
270 */
271 public static function record_template_import( $kind = 'single', $builder = '', $count = 1 ): void {
272 $imports = get_option( self::IMPORTS_OPTION, array() );
273 if ( ! is_array( $imports ) ) {
274 $imports = array();
275 }
276
277 $kind = ( 'kit' === $kind ) ? 'kit' : 'single';
278 $count = max( 1, (int) $count );
279
280 $imports['total'] = (int) ( $imports['total'] ?? 0 ) + $count;
281 $imports['kinds'] = is_array( $imports['kinds'] ?? null ) ? $imports['kinds'] : array();
282 $imports['kinds'][ $kind ] = (int) ( $imports['kinds'][ $kind ] ?? 0 ) + $count;
283
284 // Unknown stays its own bucket rather than being folded into a builder it might not be.
285 $builder = ( is_string( $builder ) && '' !== $builder ) ? sanitize_key( $builder ) : 'unknown';
286 $imports['builders'] = is_array( $imports['builders'] ?? null ) ? $imports['builders'] : array();
287 $imports['builders'][ $builder ] = (int) ( $imports['builders'][ $builder ] ?? 0 ) + $count;
288
289 $imports['last'] = gmdate( 'Y-m-d H:i:s' );
290
291 // Not autoloaded: it is read once a week by the heartbeat and never on a front-end request.
292 update_option( self::IMPORTS_OPTION, $imports, false );
293 }
294
295 /**
296 * Bridges the older kit-import hook onto record_template_import().
297 *
298 * wdkit_after_kit_import passes ( $result, $template_id ) and knows nothing about the builder,
299 * so this cannot simply be pointed at the method above — its second argument would land in
300 * $builder and every ability-driven import would file itself under a builder named after a
301 * cloud id.
302 *
303 * @param mixed $result Import result; unused.
304 * @param mixed $template_id Cloud template id; unused, see above.
305 * @return void
306 */
307 public static function record_kit_import( $result = null, $template_id = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- required by the hook signature.
308 static::record_template_import( 'kit', '', 1 );
309 }
310
311 /**
312 * Every widget installed on this site, read once per request from the widget folders.
313 *
314 * Same layout wdesignkit/list-widgets reads: WDKIT_BUILDER_PATH/{builder}/{folder}/{file}.json,
315 * with the useful fields under widget_data.widgetdata. Deactivated widgets are listed by
316 * widget_id in wkit_deactivate_widgets.
317 *
318 * The library/custom split comes from `r_id`. wdesignkit/create-widget writes `'r_id' => 0`
319 * for everything built locally and notes that real marketplace widgets carry their own id, so
320 * a non-zero r_id means the widget came from the WDesignKit library rather than the builder.
321 * A widget whose JSON predates that field falls to 'custom', which under-counts library
322 * widgets rather than inventing them.
323 *
324 * Statically cached: build_payload() asks for this three times in one request and the scan
325 * touches the filesystem.
326 *
327 * @return array<int,array{widget_id:string,name:string,builder:string,library:bool,active:bool}>
328 */
329 protected static function wdk_widget_catalogue(): array {
330 static $catalogue = null;
331
332 if ( null !== $catalogue ) {
333 return $catalogue;
334 }
335
336 $catalogue = array();
337
338 if ( ! defined( 'WDKIT_BUILDER_PATH' ) || ! is_dir( WDKIT_BUILDER_PATH ) ) {
339 return $catalogue;
340 }
341
342 $disabled = get_option( 'wkit_deactivate_widgets', array() );
343 $disabled_ids = array();
344 if ( is_array( $disabled ) ) {
345 foreach ( $disabled as $entry ) {
346 if ( ! empty( $entry['w_unique'] ) ) {
347 $disabled_ids[] = (string) $entry['w_unique'];
348 }
349 }
350 }
351
352 foreach ( array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' ) as $builder ) {
353 $builder_dir = WDKIT_BUILDER_PATH . '/' . $builder;
354 if ( ! is_dir( $builder_dir ) ) {
355 continue;
356 }
357
358 $folders = glob( $builder_dir . '/*', GLOB_ONLYDIR );
359 if ( ! is_array( $folders ) ) {
360 continue;
361 }
362
363 foreach ( $folders as $folder ) {
364 $json_files = glob( $folder . '/*.json' );
365 if ( empty( $json_files ) ) {
366 continue;
367 }
368
369 $decoded = wp_json_file_decode( $json_files[0], array( 'associative' => true ) );
370 $data = $decoded['widget_data']['widgetdata'] ?? null;
371 if ( ! is_array( $data ) || empty( $data['widget_id'] ) ) {
372 continue;
373 }
374
375 $widget_id = (string) $data['widget_id'];
376 $name = (string) ( $data['name'] ?? '' );
377 $catalogue[] = array(
378 'widget_id' => $widget_id,
379 'name' => $name,
380 // Gutenberg and Bricks key their block/element name off the widget NAME, not the
381 // id — `wdkit/{slug}` and `wdkit-{slug}` respectively, both sanitize_title( name )
382 // in wdesignkit/create-widget. Computed once here so used_features() can map a
383 // name found in content back to the widget_id the hub joins on.
384 'slug' => sanitize_title( $name ),
385 'builder' => $builder,
386 'library' => ! empty( $data['r_id'] ),
387 'active' => ! in_array( $widget_id, $disabled_ids, true ),
388 );
389 }
390 }
391
392 return $catalogue;
393 }
394
395 /**
396 * Which widgets are installed and switched on, keyed by widget_id.
397 *
398 * KEYED THE SAME WAY AS used_features(), and that is the whole point. The base states that
399 * `enabled_widgets` is joined against `widget_usage` and must stay a widget map — so for a
400 * product that has real widgets, filling it with settings toggles would produce a join between
401 * two key spaces that do not correspond, and the hub would silently report nonsense. Nexter
402 * Extension can put toggles here only because its widget_usage is empty, so nothing joins.
403 *
404 * WDesignKit has both, so the split is: widgets here, settings in plugin_meta.settings.
405 *
406 * A deactivated widget is reported as false rather than omitted — "installed but switched off"
407 * and "not installed" are different facts and the hub can only tell them apart if both appear.
408 *
409 * @return array<string,bool> widget_id => switched on.
410 */
411 protected static function enabled_features(): array {
412 $enabled = array();
413
414 foreach ( static::wdk_widget_catalogue() as $widget ) {
415 $enabled[ $widget['widget_id'] ] = (bool) $widget['active'];
416 }
417
418 return $enabled;
419 }
420
421 /**
422 * The ten feature toggles wkit_settings_panel stores.
423 *
424 * In plugin_meta rather than enabled_widgets — see enabled_features() for why that separation
425 * is load-bearing rather than tidiness.
426 *
427 * Reported by stored key, not by the label the dashboard shows, so renaming a control in the
428 * UI does not split one setting into two series on the hub.
429 *
430 * @return array<string,bool>
431 */
432 protected static function wdk_settings(): array {
433 $settings = get_option( 'wkit_settings_panel', array() );
434 if ( ! is_array( $settings ) ) {
435 $settings = array();
436 }
437
438 $keys = array(
439 'builder',
440 'template',
441 'elementor_builder',
442 'gutenberg_builder',
443 'gutenberg_core_builder',
444 'bricks_builder',
445 'elementor_template',
446 'gutenberg_template',
447 'code_snippet',
448 'debugger_mode',
449 );
450
451 $enabled = array();
452 foreach ( $keys as $key ) {
453 $enabled[ $key ] = ! empty( $settings[ $key ] );
454 }
455
456 return $enabled;
457 }
458
459 /**
460 * Widgets actually placed in content, counted per widget_id.
461 *
462 * Covers all four builders. Every name below was read out of wdesignkit/create-widget, which is
463 * the file that generates the widget code, so none of them is a guess:
464 *
465 * - Elementor : get_name() returns 'wb-{widget_hash}' and the same file sets
466 * `$widget_id = $widget_hash`, so `"widgetType":"wb-<widget_id>"` in
467 * _elementor_data IS the id.
468 * - Gutenberg : register_block_type( 'wdkit/{$slug}' ) / registerBlockType, with
469 * and core $slug = sanitize_title( $name ) — `<!-- wp:wdkit/<slug>` in post_content.
470 * - Bricks : the generated element declares `public $name = 'wdkit-{$slug}'`, which
471 * lands in Bricks' own page-content meta as `"name":"wdkit-<slug>"`.
472 *
473 * Three of the four therefore key off the widget NAME rather than its id, so each scan maps
474 * what it finds back to the widget_id before counting — the hub gets one series it can join
475 * against the catalogue regardless of which builder a widget was built for.
476 *
477 * The base only ever calls this on the weekly cron; activate / deactivate read the cached copy,
478 * so the admin never waits on it. See wdk_scan_content() for the bounds each scan runs under.
479 *
480 * @return array<string,int> widget_id => placement count.
481 */
482 protected static function used_features(): array {
483 global $wpdb;
484
485 /*
486 * Each builder writes its widgets into content under a DIFFERENT name, and only one of the
487 * three is the widget_id — so every scan maps what it finds back to the widget_id before
488 * counting, and the hub gets one series it can join against the catalogue.
489 *
490 * - Elementor : `"widgetType":"wb-<widget_id>"`. get_name() returns 'wb-{widget_hash}' and
491 * wdesignkit/create-widget sets $widget_id = $widget_hash, so this IS the id.
492 * - Gutenberg : `<!-- wp:wdkit/<slug>`, from register_block_type( 'wdkit/{$slug}' ), where
493 * and core $slug = sanitize_title( $name ). Both Gutenberg builders register the same
494 * way, so one scan covers them.
495 * - Bricks : `"name":"wdkit-<slug>"`, from the generated element's `public $name`.
496 *
497 * Only widgets this site actually has are counted, so a name left behind in a page after its
498 * widget was deleted does not appear as usage. Two widgets sharing a name collapse to one
499 * slug and therefore one count — the generator already warns that duplicate names collide,
500 * so that is the same ambiguity the builder itself has, not one introduced here.
501 */
502 $elementor = array();
503 $blocks = array();
504 $bricks = array();
505
506 foreach ( static::wdk_widget_catalogue() as $widget ) {
507 switch ( $widget['builder'] ) {
508 case 'elementor':
509 $elementor[ $widget['widget_id'] ] = $widget['widget_id'];
510 break;
511 case 'gutenberg':
512 case 'gutenberg_core':
513 if ( '' !== $widget['slug'] ) {
514 $blocks[ $widget['slug'] ] = $widget['widget_id'];
515 }
516 break;
517 case 'bricks':
518 if ( '' !== $widget['slug'] ) {
519 $bricks[ $widget['slug'] ] = $widget['widget_id'];
520 }
521 break;
522 }
523 }
524
525 $counts = array();
526
527 static::wdk_scan_content(
528 "SELECT meta_id AS scan_id, meta_value AS scan_value FROM {$wpdb->postmeta}
529 WHERE meta_key = '_elementor_data' AND meta_value LIKE %s AND meta_id > %d
530 ORDER BY meta_id ASC LIMIT %d",
531 '%' . $wpdb->esc_like( '"widgetType":"wb-' ) . '%',
532 '/"widgetType":"wb-([A-Za-z0-9_-]+)"/',
533 $elementor,
534 $counts
535 );
536
537 /*
538 * Revisions and auto-drafts are excluded deliberately. A page edited thirty times keeps
539 * thirty revision rows carrying the same blocks, and counting those would report one
540 * placement as thirty — the postmeta scans above do not have this problem because Elementor
541 * and Bricks store their data on the parent post.
542 */
543 static::wdk_scan_content(
544 "SELECT ID AS scan_id, post_content AS scan_value FROM {$wpdb->posts}
545 WHERE post_content LIKE %s AND ID > %d
546 AND post_type != 'revision' AND post_status NOT IN ( 'auto-draft', 'trash' )
547 ORDER BY ID ASC LIMIT %d",
548 '%' . $wpdb->esc_like( 'wp:wdkit/' ) . '%',
549 // Negative lookbehind skips the closing "<!-- /wp:wdkit/<slug> -->" comment a
550 // non-self-closing block also carries, so each placed block is counted once (opening
551 // tag only) instead of twice — same guard the base class's own block scanner uses.
552 '/(?<!\/)wp:wdkit\/([a-z0-9-]+)/',
553 $blocks,
554 $counts
555 );
556
557 static::wdk_scan_content(
558 "SELECT meta_id AS scan_id, meta_value AS scan_value FROM {$wpdb->postmeta}
559 WHERE meta_key IN ( '_bricks_page_content_2', '_bricks_page_header_2', '_bricks_page_footer_2' )
560 AND meta_value LIKE %s AND meta_id > %d
561 ORDER BY meta_id ASC LIMIT %d",
562 '%' . $wpdb->esc_like( '"name":"wdkit-' ) . '%',
563 '/"name":"wdkit-([a-z0-9-]+)"/',
564 $bricks,
565 $counts
566 );
567
568 return $counts;
569 }
570
571 /**
572 * Batched scan of one content store, counting matches into $counts by widget_id.
573 *
574 * Bounded exactly like the base's scanners and for the same reasons: capped by the shared
575 * `posimyth_scan_post_cap` filter (default 2000 rows), 100 rows per batch because these are
576 * longtext blobs, and keyset pagination (id > last, ORDER BY id) rather than LIMIT/OFFSET,
577 * which with no defined order can repeat or skip rows between batches. The cap applies PER
578 * store, so a site running all four builders can scan up to three times it — still only on the
579 * weekly cron, and the result is cached, so activate / deactivate never wait on it.
580 *
581 * Returns nothing when the map is empty: a site with no widgets for that builder should not
582 * run the query at all.
583 *
584 * @param string $sql Query with %s (LIKE), %d (last id), %d (limit) placeholders,
585 * selecting `scan_id` and `scan_value`.
586 * @param string $like Prepared LIKE argument.
587 * @param string $regex Pattern whose first capture group is the name to map.
588 * @param array<string,string> $map Found name => widget_id.
589 * @param array<string,int> $counts Accumulator, modified in place.
590 * @return void
591 */
592 protected static function wdk_scan_content( string $sql, string $like, string $regex, array $map, array &$counts ): void {
593 global $wpdb;
594
595 if ( empty( $map ) ) {
596 return;
597 }
598
599 $cap = (int) apply_filters( 'posimyth_scan_post_cap', 2000 );
600 $batch = 100;
601 $last_id = 0;
602 $scanned = 0;
603
604 while ( $scanned < $cap ) {
605 // Clamp the batch to what the cap still allows, so `posimyth_scan_post_cap` is an exact
606 // bound rather than a between-batches check that could overshoot by a whole batch.
607 $take = min( $batch, $cap - $scanned );
608
609 // Batched scan of a builder's own blob; no core API can query inside it. Runs only on the
610 // weekly cron and the result is cached in an option, so per-query caching would add a
611 // second cache layer for a job that runs at most once a week. $sql is built from literals
612 // and $wpdb table properties in used_features(), never from input.
613 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
614 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $like, $last_id, $take ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
615
616 if ( empty( $rows ) ) {
617 return;
618 }
619
620 foreach ( $rows as $row ) {
621 $last_id = (int) $row['scan_id'];
622 if ( preg_match_all( $regex, (string) $row['scan_value'], $matches ) ) {
623 foreach ( $matches[1] as $found ) {
624 if ( isset( $map[ $found ] ) ) {
625 $widget_id = $map[ $found ];
626 $counts[ $widget_id ] = ( $counts[ $widget_id ] ?? 0 ) + 1;
627 }
628 }
629 }
630 }
631
632 $scanned += count( $rows );
633 if ( count( $rows ) < $take ) {
634 return;
635 }
636 }
637 }
638
639 /**
640 * WDesignKit-specific figures that do not fit the shared columns.
641 *
642 * Four groups, each answering a question the shared payload cannot:
643 *
644 * - widgets: how many exist, how many are switched on, split per builder. `enabled_widgets`
645 * carries settings toggles for this product, so the widget counts live here.
646 * - library: how many of those came from the WDesignKit library rather than the builder,
647 * and which ones. Names are capped so a site with hundreds of widgets cannot
648 * push plugin_meta past the hub's 64 KB limit and have the whole object dropped.
649 * - templates: imports counted since this version — see IMPORTS_OPTION for why there is no
650 * history.
651 * - cloud: storage and AI credit as last seen by the dashboard, with the age of that
652 * reading. Never fetched here: the cloud endpoint needs a user token that only a
653 * logged-in dashboard request carries. Only the figures travel — no account id,
654 * no email, nothing that identifies the cloud user.
655 *
656 * @return array<string,mixed>
657 */
658 protected static function plugin_meta(): array {
659 $catalogue = static::wdk_widget_catalogue();
660
661 $per_builder = array();
662 $active = 0;
663 $library_names = array();
664 $library_count = 0;
665
666 foreach ( $catalogue as $widget ) {
667 $builder = $widget['builder'];
668 $per_builder[ $builder ] = (int) ( $per_builder[ $builder ] ?? 0 ) + 1;
669
670 if ( $widget['active'] ) {
671 ++$active;
672 }
673
674 if ( $widget['library'] ) {
675 ++$library_count;
676 if ( '' !== $widget['name'] ) {
677 $library_names[] = $widget['name'];
678 }
679 }
680 }
681
682 $imports = get_option( self::IMPORTS_OPTION, array() );
683 $imports = is_array( $imports ) ? $imports : array();
684
685 $usage = get_option( self::USAGE_OPTION, array() );
686 $usage = is_array( $usage ) ? $usage : array();
687
688 return array(
689 // The dashboard's feature toggles. Here rather than in enabled_widgets, which this
690 // product needs for its actual widget map — see enabled_features().
691 'settings' => static::wdk_settings(),
692 'widgets' => array(
693 'total' => count( $catalogue ),
694 'active' => $active,
695 'deactivated' => count( $catalogue ) - $active,
696 'per_builder' => $per_builder,
697 ),
698 'library' => array(
699 'count' => $library_count,
700 'custom' => count( $catalogue ) - $library_count,
701 // Capped, and the cap is the point: plugin_meta is dropped WHOLE if it exceeds the
702 // hub's limit, so one site with 400 widgets would cost itself every other figure here.
703 'names' => array_slice( array_values( array_unique( $library_names ) ), 0, 50 ),
704 ),
705 'templates' => array(
706 'imported' => (int) ( $imports['total'] ?? 0 ),
707 // single vs kit — a page kit and a single section are not the same amount of use.
708 'per_kind' => is_array( $imports['kinds'] ?? null ) ? $imports['kinds'] : array(),
709 'per_builder' => is_array( $imports['builders'] ?? null ) ? $imports['builders'] : array(),
710 'last_import' => (string) ( $imports['last'] ?? '' ),
711 ),
712 'cloud' => array(
713 'storage_used' => isset( $usage['storage_used'] ) ? (float) $usage['storage_used'] : null,
714 'storage_total' => isset( $usage['storage_total'] ) ? (float) $usage['storage_total'] : null,
715 'credit_used' => isset( $usage['credit_used'] ) ? (float) $usage['credit_used'] : null,
716 'credit_total' => isset( $usage['credit_total'] ) ? (float) $usage['credit_total'] : null,
717 // Without this the figures are unreadable: a number cached eight months ago looks
718 // exactly like one cached this morning.
719 'read_at' => (string) ( $usage['cached_at'] ?? '' ),
720 ),
721 );
722 }
723 }
724 }
725