PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / AI / Schema / CampaignSchema.php

CampaignSchema.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/AI/Schema/CampaignSchema.php

565 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\AI\Schema;
4
5 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * Canonical, machine-readable description of a Better Payment campaign.
13 *
14 * This is the single source of truth the AI layer uses to (a) tell a model what
15 * it is allowed to produce and (b) validate what a model returns. It never
16 * hardcodes the element list — everything is derived live from
17 * {@see ElementRegistry::get_all()} so the AI schema can never drift from the
18 * builder's real capabilities.
19 *
20 * A campaign is structured JSON:
21 * {
22 * "layout": "1-column" | "2-column" | "3-column",
23 * "columns": [
24 * { "id": string, "label": string, "width": "NN%",
25 * "elements": [ { "id": string, "type": <element type>, "settings": { ... } } ] }
26 * ]
27 * }
28 * plus flat campaign meta (goal amount, colours, donation amounts, ...).
29 *
30 * @see \Better_Payment\Lite\AI\Operations\OperationValidator Uses these allowlists.
31 * @see \Better_Payment\Lite\AI\Prompt\PromptBuilder Serialises this for the model.
32 */
33 class CampaignSchema {
34
35 /**
36 * Feature slug Pro declares on `better_payment/pro_provides` for the Pro-widget
37 * guidance it ships: the generation instruction, the "Pro widgets" prompt section
38 * on a Pro install, the three widgets' content guides, the setting-key aliases
39 * and the video URL guard. Lite's copy was deleted when
40 * ProCompatibility::MIN_PRO_VERSION reached 1.4.4; the slug stays as the
41 * documented name of the hand-off.
42 */
43 const PRO_GUIDANCE_FEATURE = 'ai_pro_widget_guidance';
44
45 /**
46 * Layout presets the builder understands.
47 *
48 * @return array<int, string>
49 */
50 public static function layout_presets(): array {
51 return [ '1-column', '2-column', '3-column' ];
52 }
53
54 /**
55 * Every registered element type key (e.g. campaign_title, photo, ...).
56 *
57 * @return array<int, string>
58 */
59 public static function element_types(): array {
60 return array_keys( ElementRegistry::get_all() );
61 }
62
63 /**
64 * Whether a given element type is registered.
65 *
66 * Deliberately entitlement-BLIND. A campaign built while Pro was active may
67 * still hold a `donors_wall`; when the licence lapses that element must
68 * survive an AI edit turn untouched, exactly as
69 * {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
70 * restores its settings rather than deleting it. Making this method
71 * entitlement-aware would make an "edit the title" turn — whose `set_layout`
72 * echoes the whole page back — silently delete the user's Pro elements.
73 *
74 * Entitlement decides what we *offer* ({@see self::offerable_element_types()}),
75 * never what we recognise.
76 */
77 public static function is_element_type( string $type ): bool {
78 return in_array( $type, self::element_types(), true );
79 }
80
81 /**
82 * Whether Pro is active on this install, read live per request.
83 *
84 * Never inferred from stored campaign data — same contract the builder and
85 * MetaBox follow.
86 */
87 public static function pro_enabled(): bool {
88 return (bool) apply_filters( 'better_payment/pro_enabled', false );
89 }
90
91 /**
92 * Whether an element type is flagged Pro in the registry.
93 *
94 * The `pro` flag is present whether or not Pro is installed — on Lite's lock
95 * stubs and on Pro's full definitions alike — so this asks "is this a Pro
96 * widget", not "is it locked".
97 */
98 public static function is_pro_element_type( string $type ): bool {
99 $schema = ElementRegistry::get( $type );
100
101 return null !== $schema && ! empty( $schema['pro'] );
102 }
103
104 /**
105 * The element types this install may be offered: everything registered, minus
106 * the Pro ones when Pro is inactive.
107 *
108 * A free install used to be handed `donors_wall`, `faq` and `video` in the
109 * prompt under a rule reading "Only use the element types listed above" — so
110 * the model reasonably used them, and they reached a page that has no
111 * renderer for them and drops them to nothing. This is the list that decides
112 * what the model is told exists.
113 *
114 * @return array<int, string>
115 */
116 public static function offerable_element_types(): array {
117 $types = self::element_types();
118
119 if ( self::pro_enabled() ) {
120 return $types;
121 }
122
123 return array_values( array_filter(
124 $types,
125 static function ( $type ) {
126 return ! self::is_pro_element_type( $type );
127 }
128 ) );
129 }
130
131 /**
132 * Whether a type may be offered on this install (fed to the model in the
133 * prompt schema).
134 *
135 * NOTE this is no longer the gate for *inserting* an element. A free user who
136 * explicitly asks the AI for a Pro widget gets that exact widget dropped onto
137 * the canvas as a locked preview — see
138 * {@see \Better_Payment\Lite\AI\Operations\OperationValidator::validate_insert_block()}
139 * and {@see self::locked_pro_types_in_operations()}. Offering (proactive) and
140 * inserting (on explicit request) are now different questions.
141 */
142 public static function is_offerable_element_type( string $type ): bool {
143 return in_array( $type, self::offerable_element_types(), true );
144 }
145
146 /**
147 * The Pro element types this batch of operations puts onto the canvas that
148 * the install is NOT entitled to — the widgets that will render as a locked
149 * preview and stay inactive on the live page.
150 *
151 * This exists so the assistant can tell the user plainly, whatever the model
152 * wrote in its own reply, that a widget they asked for is a Pro-only feature.
153 * Reused by {@see \Better_Payment\Lite\AI\Services\AIService::run()}.
154 *
155 * Detection is deliberately "newly introduced", not merely "present". A
156 * lapsed-Pro user's routine edit ("shorten the title") comes back as a
157 * `set_layout` that echoes the whole page — leftover Pro widgets and all — and
158 * announcing "FAQ is a Pro widget" on every such turn would be noise about
159 * something the user did not just do. A type therefore counts only when an
160 * `insert_block` creates it, or a `set_layout` adds one the current layout did
161 * not already contain. Returns `[]` when Pro is active (nothing is locked).
162 *
163 * @param array $operations Validated operations (canonical `op` + type keys).
164 * @param array $context Trusted current state: [ 'layout' => [...] ].
165 * @return array<int, string> Distinct Pro type keys, in first-seen order.
166 */
167 public static function locked_pro_types_in_operations( array $operations, array $context = [] ): array {
168 if ( self::pro_enabled() ) {
169 return [];
170 }
171
172 $existing = self::pro_types_in_columns( $context['layout']['columns'] ?? [] );
173 $found = [];
174
175 foreach ( $operations as $operation ) {
176 if ( ! is_array( $operation ) ) {
177 continue;
178 }
179 $name = $operation['op'] ?? ( $operation['operation'] ?? '' );
180
181 if ( 'insert_block' === $name ) {
182 $type = self::resolve_element_type( is_string( $operation['type'] ?? null ) ? $operation['type'] : '' );
183 if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $found, true ) ) {
184 $found[] = $type;
185 }
186 continue;
187 }
188
189 if ( 'set_layout' === $name ) {
190 foreach ( self::pro_types_in_columns( $operation['columns'] ?? [] ) as $type ) {
191 if ( ! in_array( $type, $existing, true ) && ! in_array( $type, $found, true ) ) {
192 $found[] = $type;
193 }
194 }
195 }
196 }
197
198 return $found;
199 }
200
201 /**
202 * Distinct Pro element type keys present in a set of layout columns.
203 *
204 * @param mixed $columns
205 * @return array<int, string>
206 */
207 private static function pro_types_in_columns( $columns ): array {
208 $types = [];
209 if ( ! is_array( $columns ) ) {
210 return $types;
211 }
212 foreach ( $columns as $column ) {
213 if ( ! is_array( $column ) || empty( $column['elements'] ) || ! is_array( $column['elements'] ) ) {
214 continue;
215 }
216 foreach ( $column['elements'] as $element ) {
217 if ( ! is_array( $element ) ) {
218 continue;
219 }
220 $type = self::resolve_element_type( is_string( $element['type'] ?? null ) ? $element['type'] : '' );
221 if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $types, true ) ) {
222 $types[] = $type;
223 }
224 }
225 }
226 return $types;
227 }
228
229 /**
230 * Pro element type keys registered on this install, regardless of entitlement.
231 *
232 * Used by the prompt builder to name the locked Pro widgets to a free install
233 * (they are absent from {@see self::offerable_element_types()}), so the model
234 * can honour an explicit request for one instead of substituting a free widget.
235 *
236 * @return array<int, string>
237 */
238 public static function pro_element_types(): array {
239 return array_values( array_filter(
240 self::element_types(),
241 [ self::class, 'is_pro_element_type' ]
242 ) );
243 }
244
245 /**
246 * Common alias → canonical element type. Models frequently emit generic names
247 * (heading, paragraph, image, button …) instead of the registry keys; mapping
248 * them recovers the element instead of silently dropping it.
249 *
250 * @return array<string, string>
251 */
252 private static function type_aliases(): array {
253 return apply_filters( 'better_payment/ai/element_type_aliases', [
254 'heading' => 'campaign_title', 'title' => 'campaign_title', 'header' => 'campaign_title', 'headline' => 'campaign_title', 'campaigntitle' => 'campaign_title',
255 'paragraph' => 'campaign_description', 'text' => 'campaign_description', 'story' => 'campaign_description', 'description' => 'campaign_description', 'content' => 'campaign_description', 'body' => 'campaign_description', 'richtext' => 'campaign_description', 'rich_text' => 'campaign_description',
256 'image' => 'photo', 'img' => 'photo', 'picture' => 'photo', 'hero_image' => 'photo', 'heroimage' => 'photo', 'gallery' => 'photo',
257 'button' => 'donation_form', 'cta' => 'donation_form', 'donate_button' => 'donation_form', 'donatebutton' => 'donation_form', 'donate' => 'donation_form', 'donatenow' => 'donation_form', 'donate_now' => 'donation_form',
258 'progress' => 'progress_bar', 'progressbar' => 'progress_bar',
259 'donation' => 'donate_amount', 'amounts' => 'donate_amount', 'donation_amounts' => 'donate_amount', 'suggested_amounts' => 'donate_amount', 'donation_amount' => 'donate_amount', 'tiers' => 'donate_amount',
260 'summary' => 'campaign_summary', 'stats' => 'campaign_summary', 'statistics' => 'campaign_summary', 'campaignsummary' => 'campaign_summary',
261 'share' => 'social_sharing', 'sharing' => 'social_sharing', 'social' => 'social_sharing', 'socialshare' => 'social_sharing', 'social_share' => 'social_sharing',
262 'links' => 'social_links', 'sociallinks' => 'social_links', 'social_link' => 'social_links',
263 'author' => 'organizer', 'creator' => 'organizer', 'organizer_card' => 'organizer', 'host' => 'organizer',
264 // Pro elements. Aliased unconditionally — resolution is not permission,
265 // and a free install simply never sees these types offered.
266 'donors' => 'donors_wall', 'donorswall' => 'donors_wall', 'donor_list' => 'donors_wall', 'donorlist' => 'donors_wall', 'supporters' => 'donors_wall', 'recent_donors' => 'donors_wall', 'contributors' => 'donors_wall',
267 'faqs' => 'faq', 'questions' => 'faq', 'accordion' => 'faq', 'q_and_a' => 'faq', 'qa' => 'faq', 'frequently_asked_questions' => 'faq',
268 'youtube' => 'video', 'vimeo' => 'video', 'embed' => 'video', 'video_embed' => 'video', 'media' => 'video',
269 ] );
270 }
271
272 /**
273 * Resolve a possibly-aliased element type to a registered type, or '' when it
274 * cannot be mapped.
275 */
276 public static function resolve_element_type( string $type ): string {
277 $t = strtolower( trim( $type ) );
278 if ( self::is_element_type( $t ) ) {
279 return $t;
280 }
281 $norm = preg_replace( '/[\s\-]+/', '_', $t );
282 if ( self::is_element_type( $norm ) ) {
283 return $norm;
284 }
285 $aliases = self::type_aliases();
286 if ( isset( $aliases[ $t ] ) ) {
287 return $aliases[ $t ];
288 }
289 if ( isset( $aliases[ $norm ] ) ) {
290 return $aliases[ $norm ];
291 }
292 return '';
293 }
294
295 /**
296 * Per-type alias → canonical setting key, so a mis-named content key
297 * (e.g. `text` on a title) still lands in the right place.
298 *
299 * @return array<string, array<string, string>>
300 */
301 private static function setting_key_aliases(): array {
302 $aliases = [
303 'campaign_title' => [ 'text' => 'title', 'heading' => 'title', 'label' => 'title', 'headline' => 'title', 'name' => 'title' ],
304 'campaign_description' => [ 'text' => 'content', 'body' => 'content', 'paragraph' => 'content', 'description' => 'content', 'story' => 'content', 'heading' => 'headline', 'title' => 'headline' ],
305 'donation_form' => [ 'text' => 'button_label', 'label' => 'button_label', 'cta' => 'button_label', 'button_text' => 'button_label', 'title' => 'button_label' ],
306 'photo' => [ 'url' => 'src', 'image' => 'src', 'source' => 'src', 'href' => 'src' ],
307 'progress_bar' => [ 'title' => 'headline', 'text' => 'headline' ],
308 'campaign_summary' => [ 'title' => 'headline', 'text' => 'headline' ],
309 'donate_amount' => [ 'title' => 'headline', 'text' => 'headline' ],
310 'organizer' => [ 'text' => 'description', 'bio' => 'description', 'name' => 'role_title', 'title' => 'role_title' ],
311 'social_sharing' => [ 'title' => 'headline', 'text' => 'headline' ],
312 'social_links' => [ 'title' => 'headline', 'text' => 'headline' ],
313 ];
314
315 // Pro supplies its widgets' aliases through the filter below.
316 return apply_filters( 'better_payment/ai/setting_key_aliases', $aliases );
317 }
318
319 /**
320 * Resolve a possibly-aliased setting key for a type. Returns the canonical key
321 * when it is (or maps to) an allowed key, otherwise the original (which the
322 * allowlist will then drop).
323 */
324 public static function resolve_setting_key( string $type, string $key ): string {
325 if ( self::is_allowed_settings_key( $type, $key ) ) {
326 return $key;
327 }
328 $map = self::setting_key_aliases();
329 $k = strtolower( trim( $key ) );
330 if ( isset( $map[ $type ][ $k ] ) ) {
331 return $map[ $type ][ $k ];
332 }
333 return $key;
334 }
335
336 /**
337 * Campaign-level meta keys the AI is allowed to write.
338 *
339 * Deliberately a subset of {@see \Better_Payment\Lite\Campaign\MetaBox} keys:
340 * layout (bpc_fields_layout), the form page id and the template key are
341 * managed structurally, not by free-form AI meta writes.
342 *
343 * @return array<int, string>
344 */
345 public static function writable_meta_keys(): array {
346 $keys = [
347 'title',
348 'bpc_goal_amount',
349 'bpc_end_date',
350 'bpc_status',
351 'bpc_allow_custom_amount',
352 'bpc_minimum_amount',
353 'bpc_color_primary',
354 'bpc_color_background',
355 'bpc_css_class',
356 'bpc_suggested_amounts',
357 ];
358
359 /**
360 * Filter the campaign meta keys the AI layer may write.
361 *
362 * @param array<int, string> $keys
363 */
364 return apply_filters( 'better_payment/ai/writable_meta_keys', $keys );
365 }
366
367 public static function is_writable_meta_key( string $key ): bool {
368 return in_array( $key, self::writable_meta_keys(), true );
369 }
370
371 /**
372 * The full set of setting keys a given element type accepts.
373 *
374 * Built from the element's defaultSettings keys plus every control key in
375 * its settingsSchema (recursing into collapsible `section` children). Synthetic
376 * container keys (sections, notes — always prefixed with `_`) are excluded.
377 *
378 * @return array<int, string>
379 */
380 public static function allowed_settings_keys( string $type ): array {
381 $schema = ElementRegistry::get( $type );
382 if ( null === $schema ) {
383 return [];
384 }
385
386 $keys = array_keys( (array) ( $schema['defaultSettings'] ?? [] ) );
387
388 $collect = function ( $controls, &$out ) use ( &$collect ) {
389 foreach ( (array) $controls as $control ) {
390 if ( ! is_array( $control ) ) {
391 continue;
392 }
393 $ctype = $control['type'] ?? '';
394 if ( in_array( $ctype, [ 'section', 'section_label', 'note' ], true ) ) {
395 if ( ! empty( $control['children'] ) ) {
396 $collect( $control['children'], $out );
397 }
398 continue;
399 }
400 if ( ! empty( $control['key'] ) && 0 !== strpos( (string) $control['key'], '_' ) ) {
401 $out[] = (string) $control['key'];
402 }
403 if ( ! empty( $control['children'] ) ) {
404 $collect( $control['children'], $out );
405 }
406 }
407 };
408
409 $collect( $schema['settingsSchema'] ?? [], $keys );
410
411 return array_values( array_unique( $keys ) );
412 }
413
414 public static function is_allowed_settings_key( string $type, string $key ): bool {
415 return in_array( $key, self::allowed_settings_keys( $type ), true );
416 }
417
418 /**
419 * Enumerated values for a select/align control, or null when the key is free-form.
420 *
421 * @return array<int, string>|null
422 */
423 public static function enum_values( string $type, string $key ) {
424 // `align` controls carry no options array but accept a fixed set.
425 $control = self::find_control( $type, $key );
426 if ( null === $control ) {
427 return null;
428 }
429 if ( 'align' === ( $control['type'] ?? '' ) ) {
430 return [ 'left', 'center', 'right' ];
431 }
432 if ( ! empty( $control['options'] ) && is_array( $control['options'] ) ) {
433 $values = [];
434 foreach ( $control['options'] as $option ) {
435 if ( is_array( $option ) && array_key_exists( 'value', $option ) ) {
436 $values[] = (string) $option['value'];
437 }
438 }
439 return $values;
440 }
441 return null;
442 }
443
444 /**
445 * Locate a single control definition (recursing sections) for an element key.
446 *
447 * @return array|null
448 */
449 public static function find_control( string $type, string $key ) {
450 $schema = ElementRegistry::get( $type );
451 if ( null === $schema ) {
452 return null;
453 }
454
455 $search = function ( $controls ) use ( &$search, $key ) {
456 foreach ( (array) $controls as $control ) {
457 if ( ! is_array( $control ) ) {
458 continue;
459 }
460 if ( ( $control['key'] ?? null ) === $key ) {
461 return $control;
462 }
463 if ( ! empty( $control['children'] ) ) {
464 $found = $search( $control['children'] );
465 if ( null !== $found ) {
466 return $found;
467 }
468 }
469 }
470 return null;
471 };
472
473 return $search( $schema['settingsSchema'] ?? [] );
474 }
475
476 /**
477 * A compact, prompt-friendly description of the whole builder capability set.
478 *
479 * Shape:
480 * [
481 * 'layout_presets' => [...],
482 * 'meta_keys' => [...],
483 * 'elements' => [
484 * [ 'type' => 'campaign_title', 'label' => '...', 'guide' => '...',
485 * 'settings' => [ 'title' => [ 'type' => 'text' ],
486 * 'align' => [ 'type' => 'align', 'enum' => ['left','center','right'] ] ] ],
487 * ...
488 * ],
489 * ]
490 *
491 * Only settable, non-container keys are described, keeping the prompt small.
492 *
493 * Two things shape this list beyond the raw registry:
494 * - Pro elements are omitted unless Pro is active. Describing a widget the
495 * install cannot render is not a harmless extra option: the rules tell the
496 * model these types are the ones it may use.
497 * - Each element carries a `guide` — what its content is actually *for*.
498 * A key list alone is not self-describing, and three elements sharing a
499 * key named `headline` otherwise get three copies of the same sentence.
500 *
501 * @return array<string, mixed>
502 */
503 public static function for_prompt(): array {
504 $elements = [];
505
506 foreach ( self::offerable_element_types() as $type ) {
507 $elements[] = self::describe_element( $type );
508 }
509
510 return [
511 'layout_presets' => self::layout_presets(),
512 'meta_keys' => self::writable_meta_keys(),
513 'elements' => $elements,
514 ];
515 }
516
517 /**
518 * Describe ONE element type: its label, its content guide, and every settable
519 * key with that key's control type and enum.
520 *
521 * Extracted from {@see self::for_prompt()} so the whole-page schema and the
522 * single-widget "target element" prompt section are built from the same code.
523 * Two builders would drift, and a drift here is not cosmetic — the targeted
524 * section exists precisely to tell the model which fields the widget in front
525 * of the user actually has, so a stale copy would describe a widget that is
526 * not the one being edited.
527 *
528 * Deliberately **entitlement-blind**, unlike {@see self::for_prompt()}. This
529 * describes an element that already exists on the page, and the caller has
530 * already decided it may be edited; filtering here would leave a lapsed
531 * subscriber's Pro widget selectable but undescribable, so the model would be
532 * asked to edit a widget it had been told nothing about. Entitlement decides
533 * what is *offered* (`offerable_element_types()`) and what may be newly
534 * *inserted* (`validate_insert_block()`) — never what is recognised.
535 *
536 * @param string $type Element type key.
537 * @return array{type: string, label: string, guide: string, settings: array<string, array>}
538 */
539 public static function describe_element( string $type ): array {
540 $schema = ElementRegistry::get( $type );
541 $schema = is_array( $schema ) ? $schema : [];
542
543 $settings = [];
544 foreach ( self::allowed_settings_keys( $type ) as $key ) {
545 $control = self::find_control( $type, $key );
546 $entry = [ 'type' => $control['type'] ?? 'text' ];
547 $enum = self::enum_values( $type, $key );
548 if ( null !== $enum && ! empty( $enum ) ) {
549 $entry['enum'] = $enum;
550 }
551 if ( ! empty( $control['label'] ) ) {
552 $entry['label'] = (string) $control['label'];
553 }
554 $settings[ $key ] = $entry;
555 }
556
557 return [
558 'type' => $type,
559 'label' => (string) ( $schema['label'] ?? $type ),
560 'guide' => ElementContentGuide::for_type( $type ),
561 'settings' => $settings,
562 ];
563 }
564 }
565