PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.2
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.2
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / inc / abilities / class-merchant-schema-generator.php

class-merchant-schema-generator.php in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.2, at inc/abilities/class-merchant-schema-generator.php

686 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Merchant Schema Generator.
4 *
5 * Converts module option group definitions and current wp_options values
6 * into JSON-ready structures for the WP Abilities API.
7 *
8 * @package Merchant
9 * @since 2.3.0
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Merchant_Schema_Generator
18 *
19 * Reads a module's `get_option_groups()` field definitions and
20 * current settings values, producing a structured response
21 * suitable for AI clients. Supports field type mapping,
22 * schema inclusion/exclusion, caching, and campaign detection.
23 *
24 * @since 2.3.0
25 */
26 class Merchant_Schema_Generator {
27
28 /**
29 * Field types that are display-only and should be excluded.
30 *
31 * @var array<int, string>
32 */
33 private static $excluded_types = array(
34 'info',
35 'warning',
36 'divider',
37 'custom_callback',
38 'info_block',
39 'content',
40 );
41
42 /**
43 * Field type (canonical registry key) to JSON schema kind mapping.
44 *
45 * @var array<string, string>
46 */
47 private static $type_map = array(
48 'text' => 'string',
49 'text_readonly' => 'string',
50 'textarea' => 'string',
51 'textarea_code' => 'string',
52 'textarea_multiline' => 'string',
53 'number' => 'number',
54 'range' => 'number',
55 'select' => 'string',
56 'select_ajax' => 'array',
57 'select_size_chart' => 'number',
58 'radio' => 'string',
59 'radio_alt' => 'string',
60 'buttons' => 'string',
61 'buttons_alt' => 'string',
62 'buttons_content' => 'string',
63 'image_picker' => 'string',
64 'color' => 'string',
65 'switcher' => 'boolean',
66 'checkbox' => 'boolean',
67 'checkbox_multiple' => 'array',
68 'gallery' => 'string',
69 'choices' => 'array',
70 'products_selector' => 'string',
71 'reviews_selector' => 'array',
72 'wc_coupons' => 'string',
73 'date_time' => 'string',
74 'flexible_content' => 'array',
75 'fields_group' => 'object',
76 'upload' => 'string',
77 'url' => 'string',
78 'create_page' => 'string',
79 'hook_select' => 'object',
80 'sortable' => 'array',
81 'sortable_repeater' => 'array',
82 'sortable_repeater_icons' => 'array',
83 'dimensions' => 'object',
84 'responsive_dimensions' => 'object',
85 );
86
87 /**
88 * Maps ai_meta keys to x-merchant-* JSON Schema extension properties.
89 *
90 * @var array<string, string>
91 */
92 private static $ai_meta_map = array(
93 'entity' => 'x-merchant-entity',
94 'reference_type' => 'x-merchant-reference-type',
95 'taxonomy' => 'x-merchant-taxonomy',
96 'value_format' => 'x-merchant-value-format',
97 'usage_hint' => 'x-merchant-usage-hint',
98 'semantic_group' => 'x-merchant-semantic-group',
99 'abstraction_level' => 'x-merchant-abstraction-level',
100 'toggle_for' => 'x-merchant-toggle-for',
101 'toggled_by' => 'x-merchant-toggled-by',
102 'allow_empty' => 'x-merchant-allow-empty',
103 );
104
105 /**
106 * Generate the full settings response for a module.
107 *
108 * @param string $module_id The module identifier.
109 * @param bool $include_schema Whether to include field schemas alongside values.
110 *
111 * @return array<string, mixed> The structured settings response.
112 */
113 public function generate( $module_id, $include_schema = true ) {
114 $module = Merchant_Modules::get_module( $module_id );
115
116 if ( ! $module ) {
117 return array();
118 }
119
120 $option_groups = $module->get_option_groups();
121
122 if ( empty( $option_groups ) ) {
123 return array();
124 }
125
126 $settings = $module->get_module_settings();
127
128 $groups_output = array();
129 foreach ( $option_groups as $group ) {
130 $group_data = array(
131 'title' => isset( $group['title'] ) ? $group['title'] : '',
132 'fields' => array(),
133 );
134
135 if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
136 $groups_output[] = $group_data;
137 continue;
138 }
139
140 foreach ( $group['fields'] as $field_def ) {
141 $field_output = $this->process_field( $field_def, $settings, $include_schema );
142
143 if ( null !== $field_output ) {
144 $group_data['fields'][] = $field_output;
145 }
146 }
147
148 $groups_output[] = $group_data;
149 }
150
151 return array( 'option_groups' => $groups_output );
152 }
153
154 /**
155 * Get all field IDs for a module (flat list).
156 *
157 * @param string $module_id The module identifier.
158 *
159 * @return array<int, string> List of valid field IDs.
160 */
161 public function get_field_ids( $module_id ) {
162 $module = Merchant_Modules::get_module( $module_id );
163
164 if ( ! $module ) {
165 return array();
166 }
167
168 $ids = array();
169 $option_groups = $module->get_option_groups();
170
171 foreach ( $option_groups as $group ) {
172 if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
173 continue;
174 }
175
176 foreach ( $group['fields'] as $field_def ) {
177 if ( ! isset( $field_def['id'] ) ) {
178 continue;
179 }
180
181 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
182 if ( in_array( $type, self::$excluded_types, true ) ) {
183 continue;
184 }
185
186 $ids[] = $field_def['id'];
187 }
188 }
189
190 return $ids;
191 }
192
193 /**
194 * Get the field definition for a specific field.
195 *
196 * @param string $module_id The module identifier.
197 * @param string $field_id The field identifier.
198 *
199 * @return array<string, mixed>|null The field definition, or null if not found.
200 */
201 public function get_field_definition( $module_id, $field_id ) {
202 $module = Merchant_Modules::get_module( $module_id );
203
204 if ( ! $module ) {
205 return null;
206 }
207
208 $option_groups = $module->get_option_groups();
209
210 foreach ( $option_groups as $group ) {
211 if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
212 continue;
213 }
214
215 foreach ( $group['fields'] as $field_def ) {
216 if ( isset( $field_def['id'] ) && $field_def['id'] === $field_id ) {
217 return $field_def;
218 }
219 }
220 }
221
222 return null;
223 }
224
225 /**
226 * Get field IDs inside a flexible_content campaign layout.
227 *
228 * Walks option_groups → fields → flexible_content → layouts → fields
229 * to extract campaign-level field IDs.
230 *
231 * @param string $module_id The module identifier.
232 * @param string $campaign_field_id The flexible_content field ID (e.g. 'campaigns').
233 *
234 * @return array<int, string> List of campaign field IDs.
235 */
236 public function get_campaign_field_ids( $module_id, $campaign_field_id ) {
237 $layout_fields = $this->get_campaign_layout_fields( $module_id, $campaign_field_id );
238
239 $ids = array();
240 foreach ( $layout_fields as $field_def ) {
241 if ( ! isset( $field_def['id'] ) ) {
242 continue;
243 }
244
245 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
246 if ( in_array( $type, self::$excluded_types, true ) ) {
247 continue;
248 }
249
250 $ids[] = $field_def['id'];
251 }
252
253 return $ids;
254 }
255
256 /**
257 * Get a field definition from inside a campaign layout.
258 *
259 * @param string $module_id The module identifier.
260 * @param string $campaign_field_id The flexible_content field ID.
261 * @param string $field_id The campaign field identifier.
262 *
263 * @return array<string, mixed>|null The field definition, or null if not found.
264 */
265 public function get_campaign_field_definition( $module_id, $campaign_field_id, $field_id ) {
266 $layout_fields = $this->get_campaign_layout_fields( $module_id, $campaign_field_id );
267
268 foreach ( $layout_fields as $field_def ) {
269 if ( isset( $field_def['id'] ) && $field_def['id'] === $field_id ) {
270 return $field_def;
271 }
272 }
273
274 return null;
275 }
276
277 /**
278 * Get the default layout key for a flexible_content campaign field.
279 *
280 * Returns the first layout key from the layouts array, which is the
281 * key used in the hidden 'layout' input when the admin UI saves campaigns.
282 *
283 * @param string $module_id The module identifier.
284 * @param string $campaign_field_id The flexible_content field ID (e.g. 'rules', 'offers').
285 *
286 * @return string|null The layout key (e.g. 'offer-details'), or null if not found.
287 */
288 public function get_default_layout_key( $module_id, $campaign_field_id ) {
289 $module = Merchant_Modules::get_module( $module_id );
290
291 if ( ! $module ) {
292 return null;
293 }
294
295 $option_groups = $module->get_option_groups();
296
297 foreach ( $option_groups as $group ) {
298 if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
299 continue;
300 }
301
302 foreach ( $group['fields'] as $field_def ) {
303 if ( ! isset( $field_def['id'] ) || $field_def['id'] !== $campaign_field_id ) {
304 continue;
305 }
306
307 if ( ! isset( $field_def['type'] ) || 'flexible_content' !== $field_def['type'] ) {
308 continue;
309 }
310
311 if ( empty( $field_def['layouts'] ) || ! is_array( $field_def['layouts'] ) ) {
312 return null;
313 }
314
315 $keys = array_keys( $field_def['layouts'] );
316
317 return $keys[0];
318 }
319 }
320
321 return null;
322 }
323
324 /**
325 * Get the full nested field schema for a campaign's default layout.
326 *
327 * @param string $module_id The module identifier.
328 * @param string $campaign_field The flexible_content field ID (e.g. 'offers').
329 *
330 * @return array<string, mixed> An object schema with `properties`, or empty array.
331 */
332 public function get_campaign_fields_schema( $module_id, $campaign_field ) {
333 $layout_fields = $this->get_campaign_layout_fields( $module_id, $campaign_field );
334
335 return $this->build_layout_items_schema( $layout_fields );
336 }
337
338 /**
339 * Get the raw field definitions from a flexible_content layout.
340 *
341 * @param string $module_id The module identifier.
342 * @param string $campaign_field_id The flexible_content field ID.
343 *
344 * @return array<int, array<string, mixed>> List of field definition arrays.
345 */
346 private function get_campaign_layout_fields( $module_id, $campaign_field_id ) {
347 $module = Merchant_Modules::get_module( $module_id );
348
349 if ( ! $module ) {
350 return array();
351 }
352
353 $option_groups = $module->get_option_groups();
354
355 foreach ( $option_groups as $group ) {
356 if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
357 continue;
358 }
359
360 foreach ( $group['fields'] as $field_def ) {
361 if ( ! isset( $field_def['id'] ) || $field_def['id'] !== $campaign_field_id ) {
362 continue;
363 }
364
365 if ( ! isset( $field_def['type'] ) || 'flexible_content' !== $field_def['type'] ) {
366 continue;
367 }
368
369 if ( ! isset( $field_def['layouts'] ) || ! is_array( $field_def['layouts'] ) ) {
370 return array();
371 }
372
373 // Return fields from the first layout.
374 $layout = reset( $field_def['layouts'] );
375
376 return isset( $layout['fields'] ) ? $layout['fields'] : array();
377 }
378 }
379
380 return array();
381 }
382
383 /**
384 * Process a single field definition into the output format.
385 *
386 * @param array<string, mixed> $field_def The field definition.
387 * @param array<string, mixed> $settings Current module settings.
388 * @param bool $include_schema Whether to include schema.
389 *
390 * @return array<string, mixed>|null The processed field, or null if excluded.
391 */
392 private function process_field( $field_def, $settings, $include_schema ) {
393 if ( ! isset( $field_def['id'] ) ) {
394 return null;
395 }
396
397 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
398
399 // Exclude display-only types.
400 if ( in_array( $type, self::$excluded_types, true ) ) {
401 return null;
402 }
403
404 $field_id = $field_def['id'];
405 $default = isset( $field_def['default'] ) ? $field_def['default'] : null;
406 $value = isset( $settings[ $field_id ] ) ? $settings[ $field_id ] : $default;
407
408 if ( 'fields_group' === $type && self::ensure_field_classes() && Merchant_Field_Fields_Group::has_status_field( $field_def ) ) {
409 $value = $this->normalize_status_group_value( $field_def, $value );
410 }
411
412 $output = array(
413 'id' => $field_id,
414 'value' => $value,
415 );
416
417 if ( $include_schema ) {
418 $output['schema'] = $this->build_schema( $field_def );
419 }
420
421 return $output;
422 }
423
424 /**
425 * Ensure a display_status fields_group value exposes its <id>_status.
426 *
427 * Unsaved groups resolve to the scalar group default; saved groups are arrays
428 * that may predate the status sub-key. Normalize both so the read value always
429 * carries the current active/inactive state, matching the object schema.
430 *
431 * @param array<string, mixed> $field_def The fields_group definition.
432 * @param mixed $value Resolved value (scalar default or array).
433 * @return array<string, mixed>
434 */
435 private function normalize_status_group_value( $field_def, $value ) {
436 $status_def = Merchant_Field_Fields_Group::get_status_field_definition( $field_def );
437 $status_id = $status_def['id'];
438 $status_default = isset( $status_def['default'] ) ? $status_def['default'] : 'active';
439 if ( ! is_array( $value ) ) {
440 return array( $status_id => $status_default );
441 }
442 if ( ! isset( $value[ $status_id ] ) ) {
443 $value[ $status_id ] = $status_default;
444 }
445 return $value;
446 }
447
448 /**
449 * Resolve a Merchant field type to its JSON-schema kind (unknown types resolve to 'string').
450 *
451 * @param string $type Merchant field type key.
452 *
453 * @return string One of string|number|boolean|array|object.
454 */
455 public function get_json_type( $type ) {
456 return isset( self::$type_map[ $type ] ) ? self::$type_map[ $type ] : 'string';
457 }
458
459 /**
460 * Build the JSON schema for a field definition.
461 *
462 * @param array<string, mixed> $field_def The field definition.
463 *
464 * @return array<string, mixed> The JSON schema object.
465 */
466 private function build_schema( $field_def ) {
467 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
468 $json_type = $this->get_json_type( $type );
469
470 $schema = array(
471 'type' => $json_type,
472 'title' => isset( $field_def['title'] ) ? $field_def['title'] : '',
473 );
474
475 // Field description.
476 if ( isset( $field_def['desc'] ) && '' !== $field_def['desc'] ) {
477 $schema['description'] = $field_def['desc'];
478 }
479
480 // Default value.
481 if ( isset( $field_def['default'] ) ) {
482 $schema['default'] = $field_def['default'];
483 }
484
485 // Read-only flag.
486 if ( 'text_readonly' === $type ) {
487 $schema['readOnly'] = true;
488 }
489
490 // Number/range constraints.
491 if ( in_array( $type, array( 'number', 'range' ), true ) ) {
492 if ( isset( $field_def['min'] ) ) {
493 $schema['minimum'] = $field_def['min'];
494 }
495 if ( isset( $field_def['max'] ) ) {
496 $schema['maximum'] = $field_def['max'];
497 }
498 if ( isset( $field_def['step'] ) ) {
499 $schema['multipleOf'] = $field_def['step'];
500 }
501 if ( isset( $field_def['unit'] ) ) {
502 $schema['unit'] = $field_def['unit'];
503 }
504 }
505
506 // Enum options (select, radio, radio_alt).
507 if ( in_array( $type, array( 'select', 'radio', 'radio_alt' ), true ) && isset( $field_def['options'] ) ) {
508 $schema['enum'] = array_keys( $field_def['options'] );
509 $schema['options'] = $field_def['options'];
510 }
511
512 // Color format.
513 if ( 'color' === $type ) {
514 $schema['format'] = 'css-color';
515 }
516
517 // Date/time format. An onlyTimepicker field stores a bare H:i time.
518 if ( 'date_time' === $type ) {
519 if ( ! empty( $field_def['options']['onlyTimepicker'] ) ) {
520 $schema['format'] = 'time';
521 $schema['x-merchant-native-format'] = 'H:i';
522 $schema['x-merchant-format-example'] = '14:00';
523 } else {
524 $schema['format'] = 'date-time';
525 $schema['x-merchant-native-format'] = 'm-d-Y h:i A';
526 $schema['x-merchant-format-example'] = '06-24-2026 03:30 PM';
527 }
528 }
529
530 // Pro gating.
531 if ( ! empty( $field_def['pro'] ) ) {
532 $schema['pro_only'] = true;
533 }
534
535 // Condition metadata.
536 if ( isset( $field_def['condition'] ) ) {
537 $schema['condition'] = $field_def['condition'];
538 }
539 if ( isset( $field_def['conditions'] ) ) {
540 $schema['conditions'] = $field_def['conditions'];
541 }
542
543 // Recurse into the default layout so nested fields carry their own ai_meta.
544 if ( 'flexible_content' === $type && ! empty( $field_def['layouts'] ) && is_array( $field_def['layouts'] ) ) {
545 $layout = reset( $field_def['layouts'] );
546 $items = $this->build_layout_items_schema( isset( $layout['fields'] ) ? $layout['fields'] : array() );
547
548 if ( ! empty( $items ) ) {
549 $schema['items'] = $items;
550 }
551 }
552
553 // Fields group: describe sub-fields (and the virtual status control) as nested properties.
554 if ( 'fields_group' === $type ) {
555 $sub_fields = ( isset( $field_def['fields'] ) && is_array( $field_def['fields'] ) ) ? $field_def['fields'] : array();
556 $props = $this->build_properties_map( $sub_fields );
557 $this->maybe_add_status_property( $field_def, $props );
558 if ( ! empty( $props ) ) {
559 $schema['properties'] = $props;
560 }
561 }
562
563 // AI meta extensions.
564 $this->emit_ai_meta( $field_def, $schema );
565
566 return $schema;
567 }
568
569 /**
570 * Build a properties map from an array of field definitions.
571 *
572 * Iterates field defs, skips entries missing `id` or whose `type` is in
573 * `$excluded_types`, and maps each surviving field ID to its schema.
574 *
575 * @param array<mixed> $field_defs Field definitions.
576 *
577 * @return array<string, mixed> Properties map (field ID → schema), may be empty.
578 */
579 private function build_properties_map( array $field_defs ) {
580 $properties = array();
581
582 foreach ( $field_defs as $field_def ) {
583 if ( ! isset( $field_def['id'] ) ) {
584 continue;
585 }
586
587 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
588 if ( in_array( $type, self::$excluded_types, true ) ) {
589 continue;
590 }
591
592 $properties[ $field_def['id'] ] = $this->build_schema( $field_def );
593 }
594
595 return $properties;
596 }
597
598 /**
599 * Build the `items` object schema for a flexible_content layout's fields.
600 *
601 * Each non-display field becomes a property whose schema is produced by
602 * build_schema(), carrying its full ai_meta (x-merchant-*) automatically.
603 *
604 * @param array<int, array<string, mixed>> $layout_fields Resolved layout field defs.
605 *
606 * @return array<string, mixed> An object schema with `properties`, or empty array.
607 */
608 private function build_layout_items_schema( array $layout_fields ) {
609 $properties = $this->build_properties_map( $layout_fields );
610
611 if ( empty( $properties ) ) {
612 return array();
613 }
614
615 return array(
616 'type' => 'object',
617 'properties' => $properties,
618 );
619 }
620
621 /**
622 * Append the virtual status sub-field schema when the group enables it.
623 *
624 * Checks if the field class is loaded and if the group has a status flag,
625 * then injects the virtual `<id>_status` property with enum and usage hint.
626 *
627 * @param array<string, mixed> $field_def The fields_group definition.
628 * @param array<mixed> $properties Properties map (by reference).
629 *
630 * @return void
631 */
632 private function maybe_add_status_property( $field_def, &$properties ) {
633 if ( ! self::ensure_field_classes() || ! Merchant_Field_Fields_Group::has_status_field( $field_def ) ) {
634 return;
635 }
636
637 $status_def = Merchant_Field_Fields_Group::get_status_field_definition( $field_def );
638 $schema = $this->build_schema( $status_def );
639
640 $schema['x-merchant-usage-hint'] = 'Per-location display toggle (distinct from campaign_status). Enum is enforced on write: "active" shows this location, "inactive" hides it. If this key is absent from a saved record, the displayed state falls back to this field\'s schema default shown here.';
641
642 $properties[ $status_def['id'] ] = $schema;
643 }
644
645 /**
646 * Ensure the admin field classes are loaded (REST loads them lazily).
647 *
648 * If `Merchant_Field_Fields_Group` is not loaded but `Merchant_Field_Registry`
649 * is available, triggers the registry singleton which eager-loads field classes.
650 *
651 * @return bool Whether Merchant_Field_Fields_Group is available.
652 */
653 public static function ensure_field_classes() {
654 if ( ! class_exists( 'Merchant_Field_Fields_Group' ) && class_exists( 'Merchant_Field_Registry' ) ) {
655 Merchant_Field_Registry::instance();
656 }
657
658 return class_exists( 'Merchant_Field_Fields_Group' );
659 }
660
661 /**
662 * Emit x-merchant-* extension properties from a field's ai_meta.
663 *
664 * Reads the optional 'ai_meta' key from a field definition and maps
665 * each recognized key to its x-merchant-* JSON Schema extension property.
666 * Unknown keys are silently ignored to allow forward-compatible additions.
667 *
668 * @param array<string, mixed> $field_def The field definition containing an optional 'ai_meta' key.
669 * @param array<string, mixed> $schema The schema array to append extensions to (modified by reference).
670 * @return void
671 */
672 private function emit_ai_meta( $field_def, &$schema ): void {
673 if ( ! isset( $field_def['ai_meta'] ) || ! is_array( $field_def['ai_meta'] ) ) {
674 return;
675 }
676
677 foreach ( $field_def['ai_meta'] as $key => $value ) {
678 if ( ! isset( self::$ai_meta_map[ $key ] ) ) {
679 continue;
680 }
681
682 $schema[ self::$ai_meta_map[ $key ] ] = $value;
683 }
684 }
685 }
686