PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
← All changes | includes/admin/tracking/class-charitable-tracking.php +241 -0 1.8.12.3 → 1.8.13 View file →
@@ -175,8 +175,40 @@
175 175 $data['recommended_plugins_installed'] = get_option( 'charitable_recommended_plugins_installed', array() );
176 176 $data['recommended_plugins_activated'] = get_option( 'charitable_recommended_plugins_activated', array() );
177 177 $data['dashboard_enhance_section_views'] = get_option( 'charitable_dashboard_enhance_views', 0 );
178 178
179 + /*
180 + * Added BEFORE the two plugin lists, deliberately.
181 + *
182 + * The body is form-encoded and the receiver runs
183 + * max_input_vars = 1000 (measured), which PHP enforces by silently
184 + * DROPPING variables past the limit rather than erroring — so
185 + * whatever is last in this array is first to be lost. active_plugins
186 + * and inactive_plugins are the only unbounded part of this payload,
187 + * and they are also the least valuable, so the bounded fields belong
188 + * in front of them.
189 + *
190 + * @since 1.8.13
191 + */
192 + $data['abilities'] = $this->get_abilities_data();
193 +
194 + /*
195 + * Mirrors the cross-brand AM promo-attribution loop. The options are
196 + * unprefixed; we claim only installs whose source names Charitable.
197 + *
198 + * @since 1.8.13
199 + */
200 + $promo = array( 'wpconsent', 'sugar-calendar', 'duplicator', 'uncannyautomator', 'activelayer', 'wpvibe' );
201 +
202 + foreach ( $promo as $plugin ) {
203 + $source = (string) get_option( $plugin . '_source', '' );
204 + $date = (int) get_option( $plugin . '_date', 0 );
205 +
206 + if ( $date && false !== strpos( $source, 'Charitable' ) ) {
207 + $data[ 'charitable_' . $plugin . '_date' ] = $date;
208 + }
209 + }
210 +
179 211 // Retrieve current plugin information.
180 212 if ( ! function_exists( 'get_plugins' ) ) {
181 213 include ABSPATH . '/wp-admin/includes/plugin.php';
182 214 }
@@ -195,8 +227,217 @@
195 227 $data['inactive_plugins'] = $plugins;
196 228 $data['locale'] = get_locale();
197 229
198 230 return $data;
231 + }
232 +
233 + /**
234 + * Abilities / AI-assistant usage for the opt-in check-in.
235 + *
236 + * Fifteen fields: six integers, five booleans, two dates, one client
237 + * slug and one JSON map. Nothing else, and in particular nothing that
238 + * identifies a campaign, a donation, a donor or an administrator — the
239 + * whole payload is counts drawn from Charitable_Abilities_Usage, whose
240 + * stored keys are a closed vocabulary of registered ability slugs.
241 + *
242 + * A `scopes` map was briefly the sixteenth, reporting which of three
243 + * write permissions a site had granted. Write access went back to one
244 + * switch before 1.8.13 shipped, and `write` already carries that.
245 + *
246 + * PUBLIC on purpose, like get_charitable_data() and get_donation_data().
247 + * get_optin_data() is private, so a block built inline there cannot be
248 + * asserted by a test, and this is the payload where an untested field is
249 + * most expensive: the usage receiver reads named keys one at a time, so a
250 + * field that is misnamed or the wrong shape is not an error anywhere — it
251 + * is silently dropped on arrival and cannot be backfilled.
252 + *
253 + * ⚠️ TWO FIELDS ARE DELIBERATELY ABSENT and must not be added here.
254 + * `rest_visible` (the count of abilities a REST client can actually see)
255 + * and application-password availability are both measured by seams that
256 + * are invalid without an authenticated HTTPS request: the first needs
257 + * rest_do_request(), which returns an empty list with no user, and the
258 + * second consults is_ssl(), which is false with no request. THIS RUNS IN
259 + * CRON. Both would report "0 visible" and "not available" across the
260 + * entire fleet of healthy sites — and both already did exactly that under
261 + * WP-CLI, which is why the two System Info lines that report them refuse
262 + * to measure rather than answer. A false catastrophe in a dataset nobody
263 + * thinks to distrust is worse than a missing column.
264 + *
265 + * `observation_complete` IS NEW HERE, added in Lite 1.8.13 and not present
266 + * in Pro's version of this method. It reads
267 + * Charitable_Abilities_Usage::get_counts() rather than ::get() for exactly
268 + * that field: wp_after_execute_ability is core @since 6.9.0 but
269 + * wp_ability_invoked is @since 7.1.0, so on WordPress 6.9 and 7.0.x
270 + * `calls`/`total` read 0 while `ok` can still climb. Without this flag a
271 + * quiet WP-7.0 site and an unused site are indistinguishable on the
272 + * receiving end.
273 + *
274 + * @since 1.8.13
275 + *
276 + * @return array<string,int|string>
277 + */
278 + public function get_abilities_data() {
279 +
280 + $usage = class_exists( 'Charitable_Abilities_Usage' )
281 + ? Charitable_Abilities_Usage::get_counts()
282 + : array(
283 + 'calls' => array(),
284 + 'total' => 0,
285 + 'ok' => 0,
286 + 'denied' => 0,
287 + 'refused' => 0,
288 + 'failed' => 0,
289 + 'first_at' => '',
290 + 'last_at' => '',
291 + 'observation_complete' => 0,
292 + );
293 +
294 + /*
295 + * Integers, not booleans, for the flags. On the wire the two are
296 + * identical — http_build_query() encodes true/false as 1/0 — and the
297 + * receiver parses with filter_var( FILTER_VALIDATE_BOOLEAN ), which
298 + * takes either. Casting here is so the shape is unambiguous to a test
299 + * and to anyone reading a logged payload, where a bare `false` would
300 + * render as an empty string.
301 + */
302 + return array(
303 + 'api' => (int) function_exists( 'wp_register_ability' ),
304 + 'registered' => $this->count_registered_abilities(),
305 + 'write' => (int) ( class_exists( 'Charitable_Abilities_Registrar' ) && Charitable_Abilities_Registrar::write_enabled() ),
306 + 'client' => $this->get_mcp_client_slug(),
307 + 'logs' => (int) ( ! class_exists( 'Charitable_Log' ) || Charitable_Log::is_enabled() ),
308 +
309 + /*
310 + * Passed the empty string as the second argument because the
311 + * filter is per-ability and there is no ability in hand here.
312 + * This reports the site's DEFAULT answer, which is the only
313 + * meaningful one fleet-wide.
314 + */
315 + 'read_logs' => (int) apply_filters( 'charitable_abilities_log_reads', false, '' ),
316 +
317 + /*
318 + * A JSON STRING, never a nested array, and this is load-bearing
319 + * twice over. A nested abilities[calls][list-campaigns]=41 shape
320 + * invites the `[]=x` payload that has twice forced a guard into
321 + * the receiver (a (string) cast on an array raises a warning that
322 + * Laravel rethrows as an ErrorException, 500ing an otherwise good
323 + * check-in). And the body is form-encoded, so a 24-slug map is 24
324 + * more PHP input variables against a max_input_vars budget this
325 + * payload already spends heavily on the full active and inactive
326 + * plugin lists. One string is one variable.
327 + *
328 + * Cast to object so an EMPTY map encodes as `{}` rather than
329 + * `[]`. wp_json_encode( array() ) emits a JSON array, so without
330 + * the cast this field is an object on a site that has used the
331 + * feature and an array on one that has not — and most sites have
332 + * not, so the array form would be the common case for a field
333 + * documented as a map. The receiver tolerates both, but a type
334 + * that changes with the data is the kind of thing that breaks a
335 + * consumer written against the common case years later.
336 + */
337 + 'calls' => (string) wp_json_encode( (object) (array) $usage['calls'] ),
338 +
339 + /*
340 + * Sent rather than recovered by summing `calls` receiver-side.
341 + * get_counts() already computes it, so this passes along a value
342 + * that exists; without it the headline usage number is only
343 + * reachable by summing a JSON blob in every query, against no
344 + * index.
345 + */
346 + 'total' => (int) $usage['total'],
347 + 'ok' => (int) $usage['ok'],
348 + 'denied' => (int) $usage['denied'],
349 + 'refused' => (int) $usage['refused'],
350 + 'failed' => (int) $usage['failed'],
351 + 'first_at' => (string) $usage['first_at'],
352 + 'last_at' => (string) $usage['last_at'],
353 + 'observation_complete' => (int) $usage['observation_complete'],
354 + );
355 + }
356 +
357 + /**
358 + * How many Charitable abilities are registered.
359 + *
360 + * A weak signal deliberately kept: one smallint that reveals a build or
361 + * a partial deploy where some abilities failed to register at all.
362 + *
363 + * ⚠️ NOTE THE SIDE EFFECT, which is the reason this is one line of code
364 + * and a paragraph of comment. wp_get_abilities() reaches
365 + * WP_Abilities_Registry::get_instance(), which fires
366 + * `wp_abilities_api_init` lazily on first use — so asking for this count
367 + * inside the check-in FORCES the whole abilities surface to register:
368 + * four registrar objects and twenty-four wp_register_ability() calls
369 + * with their full input and output schemas, in a weekly cron request
370 + * that would otherwise never touch any of it.
371 + *
372 + * Accepted, because it is once a week and the alternative is worse. A
373 + * hardcoded 24 drifts the moment an ability is added or removed, and
374 + * this field exists precisely to catch a build where the real number is
375 + * not 24 — a count that cannot disagree with reality reports nothing.
376 + *
377 + * Do NOT reach for a cached value here either: a cache populated during
378 + * a normal admin request would report yesterday's build in today's
379 + * check-in, which is the same failure in slower motion.
380 + *
381 + * @since 1.8.13
382 + *
383 + * @return int
384 + */
385 + private function count_registered_abilities() {
386 +
387 + if ( ! function_exists( 'wp_get_abilities' ) ) {
388 + return 0;
389 + }
390 +
391 + $count = 0;
392 +
393 + foreach ( wp_get_abilities() as $ability ) {
394 + if ( is_object( $ability ) && method_exists( $ability, 'get_name' ) && 0 === strpos( $ability->get_name(), 'charitable/' ) ) {
395 + ++$count;
396 + }
397 + }
398 +
399 + return $count;
400 + }
401 +
402 + /**
403 + * The slug of the MCP client this site is running, or '' for none.
404 + *
405 + * Detection shares Charitable_Abilities' basename map with the System
406 + * Info screen, so the two cannot disagree about whether a client is
407 + * present. Almost every reporting site is expected to say `wpvibe`; the
408 + * value of the field is the day one does not.
409 + *
410 + * First match wins where two are somehow active. A list would be a
411 + * second JSON column to express a state that should not occur, and the
412 + * question this answers is "which client", not "how many".
413 + *
414 + * DELIBERATELY NOT FILTERED, and the asymmetry is worth stating because
415 + * it otherwise reads as an oversight: `charitable_abilities_mcp_clients`
416 + * changes what System Info displays but has no effect here. That filter
417 + * takes and returns human-readable LABELS for a pasted report, so it
418 + * cannot supply the slug this field needs, and quietly running labels
419 + * through here would write values the receiver sanitises away. A site
420 + * using that filter to declare a bespoke client therefore shows it on
421 + * screen and reports no client in telemetry, which is the right answer
422 + * of the two available: the slug vocabulary is shared with the receiver,
423 + * so it is not a site's to extend.
424 + *
425 + * @since 1.8.13
426 + *
427 + * @return string
428 + */
429 + private function get_mcp_client_slug() {
430 +
431 + if ( ! class_exists( 'Charitable_Abilities' ) ) {
432 + return '';
433 + }
434 +
435 + foreach ( Charitable_Abilities::get_active_mcp_clients() as $entry ) {
436 + return (string) $entry['slug'];
437 + }
438 +
439 + return '';
199 440 }
200 441
201 442
202 443 /**