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-field-validator.php

class-merchant-field-validator.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-field-validator.php

918 lines 28.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Merchant Field Validator.
4 *
5 * Validates and sanitizes field values submitted via the WP Abilities API.
6 * Implements a 4-stage pipeline: unknown key rejection, type coercion,
7 * constraint enforcement, and WordPress sanitization.
8 *
9 * @package Merchant
10 * @since 2.3.0
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Merchant_Field_Validator
19 *
20 * Validates input fields against a module's option group definitions.
21 * Produces valid values, errors, and warnings.
22 *
23 * @since 2.3.0
24 */
25 class Merchant_Field_Validator {
26
27 /**
28 * Schema generator for field lookup.
29 *
30 * @var Merchant_Schema_Generator
31 */
32 private $schema_generator;
33
34 /**
35 * Field registry used to delegate sanitization to the canonical field classes.
36 *
37 * @var Merchant_Field_Registry|null
38 */
39 private $field_registry;
40
41 /**
42 * Accumulated errors from fields_group recursive validation.
43 *
44 * @var array<int, array<string, mixed>>
45 */
46 private $fields_group_errors = array();
47
48 /**
49 * Constructor.
50 *
51 * @param Merchant_Schema_Generator $schema_generator Schema generator instance.
52 * @param Merchant_Field_Registry|null $field_registry Optional field registry; resolved lazily when null.
53 */
54 public function __construct( $schema_generator, $field_registry = null ) {
55 $this->schema_generator = $schema_generator;
56 $this->field_registry = $field_registry;
57 }
58
59 /**
60 * Resolve the field registry, lazily falling back to the singleton.
61 *
62 * @return Merchant_Field_Registry
63 */
64 private function registry() {
65 if ( null === $this->field_registry ) {
66 $this->field_registry = Merchant_Field_Registry::instance();
67 }
68
69 return $this->field_registry;
70 }
71
72 /**
73 * Validate and sanitize input fields against a module's definitions.
74 *
75 * @param string $module_id The module identifier.
76 * @param array<string, mixed> $input Key-value pairs of field updates.
77 * @param array<string, mixed> $context Optional context for conditional evaluation.
78 *
79 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>, warnings: array<int, array<string, mixed>>}
80 */
81 public function validate_and_sanitize( $module_id, $input, $context = array() ) {
82 $valid = array();
83 $errors = array();
84 $warnings = array();
85
86 $this->fields_group_errors = array();
87
88 $known_fields = $this->schema_generator->get_field_ids( $module_id );
89
90 foreach ( $input as $field_id => $value ) {
91 // Stage 1: Unknown key rejection.
92 if ( ! in_array( $field_id, $known_fields, true ) ) {
93 $errors[] = array(
94 'code' => 'invalid_field_id',
95 'field' => $field_id,
96 'message' => sprintf( "Unknown field ID '%s'.", $field_id ),
97 'valid_fields' => $known_fields,
98 );
99 continue;
100 }
101
102 $field_def = $this->schema_generator->get_field_definition( $module_id, $field_id );
103
104 if ( null === $field_def ) {
105 continue;
106 }
107
108 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
109
110 // Stage 2: Type coercion & validation.
111 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
112 if ( null !== $type_result['error'] ) {
113 $errors[] = $type_result['error'];
114 continue;
115 }
116 $value = $type_result['value'];
117
118 // Stage 3: Constraint enforcement.
119 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
120 if ( null !== $constraint_error ) {
121 $errors[] = $constraint_error;
122 continue;
123 }
124
125 // Stage 4: WordPress sanitization.
126 $value = $this->sanitize_value( $value, $type, $field_def );
127
128 $valid[ $field_id ] = $value;
129 }
130
131 $errors = array_merge( $errors, $this->fields_group_errors );
132
133 return array(
134 'valid' => $valid,
135 'errors' => $errors,
136 'warnings' => $warnings,
137 );
138 }
139
140 /**
141 * Validate and sanitize campaign field updates.
142 *
143 * Same 4-stage pipeline as validate_and_sanitize() but resolves
144 * field definitions from inside a flexible_content campaign layout.
145 *
146 * @param string $module_id The module identifier.
147 * @param string $campaign_field_id The flexible_content field ID (e.g. 'campaigns').
148 * @param array<string, mixed> $input Key-value pairs of campaign field updates.
149 *
150 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>, warnings: array<int, array<string, mixed>>}
151 */
152 public function validate_campaign_updates( $module_id, $campaign_field_id, $input ) {
153 $valid = array();
154 $errors = array();
155 $warnings = array();
156
157 $this->fields_group_errors = array();
158
159 $known_fields = $this->schema_generator->get_campaign_field_ids( $module_id, $campaign_field_id );
160
161 foreach ( $input as $field_id => $value ) {
162 // Stage 1: Unknown key rejection.
163 if ( ! in_array( $field_id, $known_fields, true ) ) {
164 $errors[] = array(
165 'code' => 'invalid_field_id',
166 'field' => $field_id,
167 'message' => sprintf( "Unknown campaign field ID '%s'.", $field_id ),
168 'valid_fields' => $known_fields,
169 );
170 continue;
171 }
172
173 $field_def = $this->schema_generator->get_campaign_field_definition(
174 $module_id,
175 $campaign_field_id,
176 $field_id
177 );
178
179 if ( null === $field_def ) {
180 continue;
181 }
182
183 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
184
185 // Stage 2: Type coercion & validation.
186 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
187 if ( null !== $type_result['error'] ) {
188 $errors[] = $type_result['error'];
189 continue;
190 }
191 $value = $type_result['value'];
192
193 // Stage 3: Constraint enforcement.
194 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
195 if ( null !== $constraint_error ) {
196 $errors[] = $constraint_error;
197 continue;
198 }
199
200 // Stage 4: WordPress sanitization.
201 $value = $this->sanitize_value( $value, $type, $field_def );
202
203 $valid[ $field_id ] = $value;
204 }
205
206 $errors = array_merge( $errors, $this->fields_group_errors );
207
208 return array(
209 'valid' => $valid,
210 'errors' => $errors,
211 'warnings' => $warnings,
212 );
213 }
214
215 /**
216 * Validate input against hand-authored field definitions.
217 *
218 * Unlike validate_and_sanitize(), the field defs here aren't looked up from a
219 * module's option groups — the caller supplies them directly (e.g. a bundle's
220 * per-item schema). Runs the same type-coercion and constraint stages, plus
221 * sanitization, so callers get back values ready to persist.
222 *
223 * @param array<string, array<string, mixed>> $field_defs Map of field_id => field definition.
224 * @param array<string, mixed> $input Key-value pairs to validate.
225 *
226 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>}
227 */
228 public function validate_against_spec( array $field_defs, array $input ) {
229 $valid = array();
230 $errors = array();
231
232 $this->fields_group_errors = array();
233
234 foreach ( $input as $field_id => $value ) {
235 if ( ! array_key_exists( $field_id, $field_defs ) ) {
236 $errors[] = array(
237 'code' => 'unknown_field',
238 'field' => $field_id,
239 'message' => sprintf( "Unknown field '%s'.", $field_id ),
240 );
241 continue;
242 }
243
244 $field_def = $field_defs[ $field_id ];
245 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
246
247 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
248 if ( null !== $type_result['error'] ) {
249 $errors[] = $type_result['error'];
250 continue;
251 }
252 $value = $type_result['value'];
253
254 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
255 if ( null !== $constraint_error ) {
256 $errors[] = $constraint_error;
257 continue;
258 }
259
260 $valid[ $field_id ] = $this->sanitize_value( $value, $type, $field_def );
261 }
262
263 $errors = array_merge( $errors, $this->fields_group_errors );
264
265 return array(
266 'valid' => $valid,
267 'errors' => $errors,
268 );
269 }
270
271 /**
272 * Validate and coerce the value to the expected type.
273 *
274 * @param string $field_id Field identifier.
275 * @param mixed $value Input value.
276 * @param string $type Merchant field type.
277 * @param array<string, mixed> $field_def Full field definition.
278 *
279 * @return array{value: mixed, error: array<string, mixed>|null}
280 */
281 private function validate_type( $field_id, $value, $type, $field_def ) {
282 switch ( $type ) {
283 case 'text_readonly':
284 return array(
285 'value' => $value,
286 'error' => array(
287 'code' => 'readonly_field',
288 'field' => $field_id,
289 'message' => sprintf( "Field '%s' is read-only and cannot be updated.", $field_id ),
290 ),
291 );
292
293 case 'text':
294 case 'textarea':
295 case 'textarea_code':
296 case 'textarea_multiline':
297 case 'url':
298 case 'upload':
299 case 'select':
300 case 'radio':
301 case 'radio_alt':
302 case 'products_selector':
303 case 'gallery':
304 // gallery stores a comma-separated string of attachment/product IDs,
305 // like products_selector — mirrors Merchant_Field_Gallery::sanitize_value().
306 return $this->validate_string_type( $field_id, $value );
307
308 case 'number':
309 case 'range':
310 return $this->validate_numeric_type( $field_id, $value, $field_def );
311
312 case 'switcher':
313 case 'checkbox':
314 return $this->validate_boolean_type( $field_id, $value );
315
316 case 'color':
317 return $this->validate_color_type( $field_id, $value );
318
319 case 'date_time':
320 return $this->validate_date_time_type( $field_id, $value, $field_def );
321 }
322
323 if ( ! $this->registry()->has( $type ) ) {
324 return array(
325 'value' => $value,
326 'error' => array(
327 'code' => 'unsupported_field_type',
328 'field' => $field_id,
329 'message' => sprintf( "Field '%s' has unsupported type '%s'.", $field_id, $type ),
330 ),
331 );
332 }
333
334 $kind = $this->schema_generator->get_json_type( $type );
335
336 // Reject scalars for object kinds and for array types that would silently drop to []; others tolerate strings their field class decodes.
337 $needs_array = ( 'object' === $kind )
338 || in_array( $type, array( 'checkbox_multiple', 'flexible_content' ), true );
339
340 if ( $needs_array && ! is_array( $value ) ) {
341 return array(
342 'value' => $value,
343 'error' => array(
344 'code' => 'invalid_field_value',
345 'field' => $field_id,
346 'message' => sprintf(
347 "Field '%s' expects %s value.",
348 $field_id,
349 'object' === $kind ? 'an object' : 'an array'
350 ),
351 ),
352 );
353 }
354
355 return array( 'value' => $value, 'error' => null );
356 }
357
358 /**
359 * Validate that the value is a string.
360 *
361 * @param string $field_id Field identifier.
362 * @param mixed $value Input value.
363 *
364 * @return array{value: mixed, error: array<string, mixed>|null}
365 */
366 private function validate_string_type( $field_id, $value ) {
367 if ( ! is_string( $value ) ) {
368 return array(
369 'value' => $value,
370 'error' => $this->make_type_error( $field_id, 'string' ),
371 );
372 }
373
374 return array( 'value' => $value, 'error' => null );
375 }
376
377 /**
378 * Validate and coerce a numeric value.
379 *
380 * Casts to float when the step has a decimal point, otherwise int.
381 *
382 * @param string $field_id Field identifier.
383 * @param mixed $value Input value.
384 * @param array<string, mixed> $field_def Full field definition.
385 *
386 * @return array{value: mixed, error: array<string, mixed>|null}
387 */
388 private function validate_numeric_type( $field_id, $value, $field_def ) {
389 if ( ! is_numeric( $value ) ) {
390 return array(
391 'value' => $value,
392 'error' => $this->make_type_error( $field_id, 'number' ),
393 );
394 }
395
396 $step = isset( $field_def['step'] ) ? $field_def['step'] : 1;
397 if ( is_float( $step ) || ( is_string( $step ) && strpos( (string) $step, '.' ) !== false ) ) {
398 $value = (float) $value;
399 } else {
400 $value = (int) $value;
401 }
402
403 return array( 'value' => $value, 'error' => null );
404 }
405
406 /**
407 * Validate and coerce a boolean-like value (0/1/"0"/"1"/true/false → int).
408 *
409 * @param string $field_id Field identifier.
410 * @param mixed $value Input value.
411 *
412 * @return array{value: mixed, error: array<string, mixed>|null}
413 */
414 private function validate_boolean_type( $field_id, $value ) {
415 if ( ! in_array( $value, array( 0, 1, '0', '1', true, false ), true ) ) {
416 return array(
417 'value' => $value,
418 'error' => $this->make_type_error( $field_id, 'boolean (0 or 1)' ),
419 );
420 }
421
422 return array( 'value' => (int) (bool) $value, 'error' => null );
423 }
424
425 /**
426 * Validate a CSS color value.
427 *
428 * Accepts hex (#RGB, #RGBA, #RRGGBB, #RRGGBBAA), rgb(), rgba(), hsl(), hsla().
429 * Patterns match Merchant_Field_Color::sanitize_value() for consistency.
430 *
431 * @param string $field_id Field identifier.
432 * @param mixed $value Input value.
433 *
434 * @return array{value: mixed, error: array<string, mixed>|null}
435 */
436 private function validate_color_type( $field_id, $value ) {
437 if ( ! is_string( $value ) || ! $this->is_valid_css_color( $value ) ) {
438 return array(
439 'value' => $value,
440 'error' => array(
441 'code' => 'invalid_field_value',
442 'field' => $field_id,
443 'message' => sprintf(
444 "Value '%s' is not a valid CSS color. Accepted: hex (#RGB, #RRGGBB, #RRGGBBAA), rgb(), rgba(), hsl(), hsla().",
445 is_string( $value ) ? $value : gettype( $value )
446 ),
447 ),
448 );
449 }
450
451 return array( 'value' => $value, 'error' => null );
452 }
453
454 /**
455 * Check whether a string is a valid CSS color value.
456 *
457 * @param string $value The trimmed color string.
458 *
459 * @return bool
460 */
461 private function is_valid_css_color( $value ) {
462 // Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.
463 if ( preg_match( '/^#([A-Fa-f0-9]{3,4}){1,2}$/', $value ) ) {
464 return true;
465 }
466
467 // rgb(R, G, B) or rgba(R, G, B, A).
468 if ( preg_match( '/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/', $value ) ) {
469 return true;
470 }
471
472 // hsl(H, S%, L%) or hsla(H, S%, L%, A).
473 if ( preg_match( '/^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/', $value ) ) {
474 return true;
475 }
476
477 return false;
478 }
479
480 /**
481 * Validate a date/time string value.
482 *
483 * Accepts the native air-datepicker format (m-d-Y h:i A),
484 * ISO 8601 (Y-m-d, Y-m-dTH:i, Y-m-dTH:i:s), and m/d/y.
485 * Non-native formats are normalized to m-d-Y h:i A.
486 * Empty strings are accepted only when ai_meta.allow_empty is true.
487 *
488 * @param string $field_id Field identifier.
489 * @param mixed $value Input value.
490 * @param array<string, mixed> $field_def Full field definition.
491 *
492 * @return array{value: mixed, error: array<string, mixed>|null}
493 */
494 private function validate_date_time_type( $field_id, $value, $field_def ) {
495 if ( ! is_string( $value ) ) {
496 return array(
497 'value' => $value,
498 'error' => $this->make_type_error( $field_id, 'date/time string' ),
499 );
500 }
501
502 // Empty string gating via ai_meta.allow_empty.
503 if ( '' === $value ) {
504 $allow_empty = isset( $field_def['ai_meta']['allow_empty'] ) && $field_def['ai_meta']['allow_empty'];
505
506 if ( $allow_empty ) {
507 return array( 'value' => $value, 'error' => null );
508 }
509
510 return array(
511 'value' => $value,
512 'error' => array(
513 'code' => 'invalid_field_value',
514 'field' => $field_id,
515 'message' => sprintf( "Field '%s' requires a date/time value.", $field_id ),
516 ),
517 );
518 }
519
520 // A time-only picker (onlyTimepicker) stores a bare H:i time, not a datetime.
521 if ( ! empty( $field_def['options']['onlyTimepicker'] ) ) {
522 return $this->validate_time_only_value( $field_id, $value );
523 }
524
525 $native_format = 'm-d-Y h:i A';
526
527 // Try native format first (m-d-Y h:i A, e.g. 06-24-2026 03:30 PM).
528 if ( preg_match( '/^\d{2}-\d{2}-\d{4} \d{2}:\d{2} [AP]M$/', $value ) ) {
529 $d = DateTime::createFromFormat( $native_format, $value );
530
531 if ( $d && $d->format( $native_format ) === $value ) {
532 return array( 'value' => $value, 'error' => null );
533 }
534 }
535
536 // Try ISO 8601 with seconds (Y-m-dTH:i:s).
537 if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $value ) ) {
538 $d = DateTime::createFromFormat( 'Y-m-d\TH:i:s', $value );
539
540 if ( $d && $d->format( 'Y-m-d\TH:i:s' ) === $value ) {
541 return array( 'value' => $d->format( $native_format ), 'error' => null );
542 }
543 }
544
545 // Try ISO 8601 without seconds (Y-m-dTH:i).
546 if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $value ) ) {
547 $d = DateTime::createFromFormat( 'Y-m-d\TH:i', $value );
548
549 if ( $d && $d->format( 'Y-m-d\TH:i' ) === $value ) {
550 return array( 'value' => $d->format( $native_format ), 'error' => null );
551 }
552 }
553
554 // Try date-only ISO (Y-m-d).
555 if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
556 $d = DateTime::createFromFormat( 'Y-m-d', $value );
557
558 if ( $d && $d->format( 'Y-m-d' ) === $value ) {
559 $d->setTime( 0, 0, 0 );
560
561 return array( 'value' => $d->format( $native_format ), 'error' => null );
562 }
563 }
564
565 // Try slash format (m/d/y or m/d/yyyy).
566 if ( preg_match( '#^\d{1,2}/\d{1,2}/\d{2,4}$#', $value ) ) {
567 $parts = explode( '/', $value );
568 $format = strlen( $parts[2] ) <= 2 ? 'n/j/y' : 'n/j/Y';
569 $d = DateTime::createFromFormat( $format, $value );
570
571 if ( $d && $d->format( $format ) === $value ) {
572 $d->setTime( 0, 0, 0 );
573
574 return array( 'value' => $d->format( $native_format ), 'error' => null );
575 }
576 }
577
578 return array(
579 'value' => $value,
580 'error' => array(
581 'code' => 'invalid_field_value',
582 'field' => $field_id,
583 'message' => sprintf(
584 "Value '%s' is not a valid date/time. Accepted formats: m-d-Y h:i A (e.g. 06-24-2026 03:30 PM), Y-m-d, Y-m-dTH:i:s, m/d/y.",
585 $value
586 ),
587 ),
588 );
589 }
590
591 /**
592 * Validate a 24-hour time value, normalizing near misses ('9:30', '14:00:00')
593 * to the zero-padded H:i the pickers store.
594 *
595 * @param string $field_id Field identifier.
596 * @param string $value Input value.
597 *
598 * @return array{value: mixed, error: array<string, mixed>|null}
599 */
600 private function validate_time_only_value( $field_id, $value ) {
601 if ( preg_match( '/^(\d{1,2}):(\d{2})(?::\d{2})?$/', $value, $matches ) ) {
602 $hours = (int) $matches[1];
603 $minutes = (int) $matches[2];
604
605 if ( $hours <= 23 && $minutes <= 59 ) {
606 return array(
607 'value' => sprintf( '%02d:%02d', $hours, $minutes ),
608 'error' => null,
609 );
610 }
611 }
612
613 return array(
614 'value' => $value,
615 'error' => array(
616 'code' => 'invalid_field_value',
617 'field' => $field_id,
618 'message' => sprintf( "Value '%s' is not a valid time. Use 24-hour H:i, e.g. 14:00.", $value ),
619 ),
620 );
621 }
622
623 /**
624 * Validate constraints (enum, min/max, step).
625 *
626 * @param string $field_id Field identifier.
627 * @param mixed $value Coerced value.
628 * @param string $type Merchant field type.
629 * @param array<string, mixed> $field_def Full field definition.
630 *
631 * @return array<string, mixed>|null Error array, or null if valid.
632 */
633 private function validate_constraints( $field_id, $value, $type, $field_def ) {
634 // Enum check.
635 if ( in_array( $type, array( 'select', 'radio', 'radio_alt' ), true ) ) {
636 if ( isset( $field_def['options'] ) ) {
637 $allowed = array_keys( $field_def['options'] );
638 } elseif ( isset( $field_def['choices'] ) && is_array( $field_def['choices'] ) ) {
639 $allowed = $field_def['choices'];
640 } else {
641 $allowed = null;
642 }
643
644 if ( null !== $allowed && ! in_array( $value, $allowed, true ) ) {
645 return array(
646 'code' => 'invalid_field_value',
647 'field' => $field_id,
648 'message' => sprintf( "Value '%s' is not a valid option for '%s'.", $value, $field_id ),
649 'allowed_values' => $allowed,
650 );
651 }
652 }
653
654 // Min/Max check.
655 if ( in_array( $type, array( 'number', 'range' ), true ) ) {
656 if ( isset( $field_def['min'] ) && $value < $field_def['min'] ) {
657 return array(
658 'code' => 'invalid_field_value',
659 'field' => $field_id,
660 'message' => sprintf( "Value %s is below minimum %s for '%s'.", $value, $field_def['min'], $field_id ),
661 );
662 }
663 if ( isset( $field_def['max'] ) && $value > $field_def['max'] ) {
664 return array(
665 'code' => 'invalid_field_value',
666 'field' => $field_id,
667 'message' => sprintf( "Value %s exceeds maximum %s for '%s'.", $value, $field_def['max'], $field_id ),
668 );
669 }
670 }
671
672 return null;
673 }
674
675 /**
676 * Sanitize a value using WordPress sanitization functions.
677 *
678 * @param mixed $value The value to sanitize.
679 * @param string $type Merchant field type.
680 * @param array<string, mixed> $field_def Full field definition.
681 *
682 * @return mixed Sanitized value.
683 */
684 private function sanitize_value( $value, $type, $field_def ) {
685 switch ( $type ) {
686 case 'gallery':
687 // Not delegated: the field class only sanitize_text_field()s; the absint/CSV/cap hardening lives here.
688 $raw_ids = array_filter( explode( ',', (string) $value ) );
689 $ids = array_filter( array_map( 'absint', $raw_ids ) );
690 $ids = array_slice( array_values( $ids ), 0, $this->get_array_cap( $type ) );
691 return implode( ',', $ids );
692
693 case 'select_ajax':
694 return $this->sanitize_select_ajax( $value, $field_def );
695
696 case 'color':
697 case 'date_time':
698 return sanitize_text_field( $value );
699
700 case 'select':
701 case 'radio':
702 case 'radio_alt':
703 // Enum-constrained already; sanitize_text_field (not sanitize_key) preserves mixed-case keys like popFade.
704 return sanitize_text_field( $value );
705
706 case 'fields_group':
707 return $this->validate_fields_group( $field_def['id'], $value, $field_def );
708 }
709
710 $registry = $this->registry();
711
712 if ( ! $registry->has( $type ) ) {
713 return is_array( $value ) ? array() : sanitize_text_field( (string) $value );
714 }
715
716 /** @var Merchant_Field_Interface $field */
717 $field = $registry->create( $type, $field_def, $value );
718
719 // preprocess() only where the AI array shape matches the human $_POST shape; reviews_selector's would corrupt the array.
720 if ( in_array( $type, array( 'flexible_content', 'textarea_code', 'textarea_multiline' ), true ) ) {
721 $value = $field->preprocess( $value );
722 }
723
724 $value = $field->sanitize( $value );
725
726 // preserve_keys = true so an under-cap value matches the human save path; object kinds are never sliced (would drop named keys).
727 if ( is_array( $value ) && 'array' === $this->schema_generator->get_json_type( $type ) ) {
728 $value = array_slice( $value, 0, $this->get_array_cap( $type ), true );
729 }
730
731 return $value;
732 }
733
734 /**
735 * Return the maximum allowed items for an array field type.
736 *
737 * Defaults may be overridden via the merchant_ability_array_limits filter.
738 *
739 * @param string $type Field type key.
740 *
741 * @return int The cap for the given type, or PHP_INT_MAX if uncapped.
742 */
743 private function get_array_cap( $type ) {
744 $defaults = array(
745 'gallery' => 50,
746 'select_ajax' => 200,
747 'checkbox_multiple' => 200,
748 'choices' => 200,
749 'reviews_selector' => 200,
750 'sortable' => 200,
751 'sortable_repeater' => 200,
752 'sortable_repeater_icons' => 200,
753 // Shares the create-campaign cap filter key so both write paths use one ceiling.
754 'flexible_content' => 100,
755 );
756
757 /**
758 * Filter the maximum number of items allowed per array field type.
759 *
760 * @since 2.3.0
761 * @param array $defaults Map of field type => max items.
762 */
763 $limits = apply_filters( 'merchant_ability_array_limits', $defaults );
764
765 return isset( $limits[ $type ] ) ? (int) $limits[ $type ] : PHP_INT_MAX;
766 }
767
768 /**
769 * Validate and sanitize the sub-fields of a fields_group.
770 *
771 * Routes each sub-field through sanitize_value() rather than delegating the whole
772 * group, so the per-type preprocess decision (e.g. reviews_selector's wire-shape)
773 * and sub-field validation errors are preserved.
774 *
775 * @param string $parent_id The parent field_group ID (for error reporting).
776 * @param array<string, mixed> $value The submitted associative array of sub-field values.
777 * @param array<string, mixed> $field_def The fields_group field definition (contains 'fields').
778 *
779 * @return array<string, mixed> The validated and sanitized sub-field values.
780 */
781 private function validate_fields_group( $parent_id, $value, $field_def ) {
782 if ( ! isset( $field_def['fields'] ) || ! is_array( $field_def['fields'] ) ) {
783 return $value;
784 }
785
786 $sub_field_defs = array();
787 foreach ( $field_def['fields'] as $sub_field ) {
788 if ( isset( $sub_field['id'] ) ) {
789 $sub_field_defs[ $sub_field['id'] ] = $sub_field;
790 }
791 }
792
793 $validated = array();
794 foreach ( $value as $sub_key => $sub_value ) {
795 if ( ! isset( $sub_field_defs[ $sub_key ] ) ) {
796 continue;
797 }
798
799 $sub_def = $sub_field_defs[ $sub_key ];
800 $sub_type = isset( $sub_def['type'] ) ? $sub_def['type'] : '';
801
802 // Stage 2: Type coercion.
803 $type_result = $this->validate_type( $sub_key, $sub_value, $sub_type, $sub_def );
804 if ( null !== $type_result['error'] ) {
805 $type_result['error']['field'] = $parent_id . '.' . $type_result['error']['field'];
806 $this->fields_group_errors[] = $type_result['error'];
807 continue;
808 }
809 $sub_value = $type_result['value'];
810
811 // Stage 3: Constraint enforcement.
812 $constraint_error = $this->validate_constraints( $sub_key, $sub_value, $sub_type, $sub_def );
813 if ( null !== $constraint_error ) {
814 $constraint_error['field'] = $parent_id . '.' . $constraint_error['field'];
815 $this->fields_group_errors[] = $constraint_error;
816 continue;
817 }
818
819 // Stage 4: Sanitization.
820 $sub_value = $this->sanitize_value( $sub_value, $sub_type, $sub_def );
821
822 $validated[ $sub_key ] = $sub_value;
823 }
824
825 // Preserve the virtual display-status (active/inactive) control.
826 if (
827 Merchant_Schema_Generator::ensure_field_classes()
828 && Merchant_Field_Fields_Group::has_status_field( $field_def )
829 ) {
830 $status_key = $field_def['id'] . '_status';
831 if ( isset( $value[ $status_key ] ) ) {
832 $status_def = Merchant_Field_Fields_Group::get_status_field_definition( $field_def );
833 $allowed = array_keys( $status_def['options'] );
834 $raw = is_string( $value[ $status_key ] ) ? sanitize_text_field( $value[ $status_key ] ) : '';
835 if ( in_array( $raw, $allowed, true ) ) {
836 $validated[ $status_key ] = $raw;
837 } else {
838 $this->fields_group_errors[] = array(
839 'code' => 'invalid_field_value',
840 'field' => $parent_id . '.' . $status_key,
841 'message' => sprintf( "Field '%s' must be one of: %s.", $status_key, implode( ', ', $allowed ) ),
842 );
843 }
844 }
845 }
846
847 return $validated;
848 }
849
850 /**
851 * Sanitize a select_ajax value.
852 *
853 * When inline options are provided, values are kept verbatim if they match
854 * the allowlist — preserving percent-encoded taxonomy slugs (e.g. Arabic
855 * category slugs that sanitize_text_field would destroy).
856 * Mirrors Merchant_Field_Select_Ajax::sanitize_value().
857 *
858 * @param mixed $value Scalar or array of submitted values.
859 * @param array<string, mixed> $field_def Field definition (may include 'options').
860 *
861 * @return mixed Sanitized scalar or array.
862 */
863 private function sanitize_select_ajax( $value, $field_def ) {
864 // A multi select stores a list. Coercing scalars keeps a cleared value from
865 // landing as an empty string, which consumers cast into array( '' ) and read
866 // as one selected option. A single value still counts as a one item list.
867 if ( ! empty( $field_def['multiple'] ) && ! is_array( $value ) ) {
868 $value = ( '' === $value || null === $value ) ? array() : array( $value );
869 }
870
871 $has_options = ! empty( $field_def['options'] ) && is_array( $field_def['options'] );
872
873 if ( $has_options ) {
874 $valid_ids = wp_list_pluck( $field_def['options'], 'id' );
875 $cap = $this->get_array_cap( 'select_ajax' );
876
877 if ( is_array( $value ) ) {
878 $filtered = array_values(
879 array_filter(
880 $value,
881 static function ( $v ) use ( $valid_ids ) {
882 return in_array( $v, $valid_ids, true );
883 }
884 )
885 );
886 return array_slice( $filtered, 0, $cap );
887 }
888
889 return in_array( $value, $valid_ids, true ) ? $value : '';
890 }
891
892 // AJAX-loaded source: sanitize normally.
893 if ( is_array( $value ) ) {
894 $cap = $this->get_array_cap( 'select_ajax' );
895 $sanitized = array_filter( array_map( 'sanitize_text_field', $value ) );
896 return array_slice( array_values( $sanitized ), 0, $cap );
897 }
898
899 return sanitize_text_field( $value );
900 }
901
902 /**
903 * Build a type mismatch error.
904 *
905 * @param string $field_id Field identifier.
906 * @param string $expected_type Expected type description.
907 *
908 * @return array<string, string> Error array.
909 */
910 private function make_type_error( $field_id, $expected_type ) {
911 return array(
912 'code' => 'invalid_field_value',
913 'field' => $field_id,
914 'message' => sprintf( "Field '%s' expects a %s value.", $field_id, $expected_type ),
915 );
916 }
917 }
918