| 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 |
|