PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.10.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.10.0
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
sureforms / inc / ai-form-builder / field-mapping.php

field-mapping.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.10.0, at inc/ai-form-builder/field-mapping.php

480 lines 23.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureForms - AI Form Builder.
4 *
5 * @package sureforms
6 * @since 0.0.8
7 */
8
9 namespace SRFM\Inc\AI_Form_Builder;
10
11 use SRFM\Inc\Helper;
12 use SRFM\Inc\Traits\Get_Instance;
13 use WP_Error;
14
15 // Exit if accessed directly.
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * SureForms AI Form Builder Class.
22 */
23 class Field_Mapping {
24 use Get_Instance;
25
26 /**
27 * Generate Gutenberg Fields from AI data.
28 *
29 * @param \WP_REST_Request $request Full details about the request.
30 * @return string|WP_Error
31 */
32 public static function generate_gutenberg_fields_from_questions( $request ) {
33
34 // Get params from request.
35 $params = $request->get_params();
36
37 // check parama is empty or not and is an array and consist form_data key.
38 if ( empty( $params ) || ! is_array( $params ) || ! isset( $params['form_data'] ) || 0 === count( $params['form_data'] ) ) {
39 return new WP_Error(
40 'srfm_ai_mapping_missing_form_data',
41 __( 'The AI form data is missing. Please try again.', 'sureforms' ),
42 [ 'status' => 400 ]
43 );
44 }
45
46 // Get questions from form data.
47 $form_data = $params['form_data'];
48 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
49 return new WP_Error(
50 'srfm_ai_mapping_invalid_form_data',
51 __( 'The AI form data is not in the expected format.', 'sureforms' ),
52 [ 'status' => 400 ]
53 );
54 }
55
56 $form = $form_data['form'] ?? null;
57 if ( empty( $form ) || ! is_array( $form ) ) {
58 return new WP_Error(
59 'srfm_ai_mapping_missing_form',
60 __( 'The AI response did not include a form. Please try again.', 'sureforms' ),
61 [ 'status' => 400 ]
62 );
63 }
64
65 $form_fields = $form['formFields'] ?? null;
66 if ( empty( $form_fields ) || ! is_array( $form_fields ) ) {
67 return new WP_Error(
68 'srfm_ai_mapping_missing_form_fields',
69 __( 'The AI was unable to generate form fields. Please try again.', 'sureforms' ),
70 [ 'status' => 400 ]
71 );
72 }
73
74 // Initialize post content string.
75 $post_content = '';
76
77 $is_conversational = isset( $params['is_conversional'] ) ? filter_var( $params['is_conversional'], FILTER_VALIDATE_BOOLEAN ) : false;
78 $form_type = isset( $params['form_type'] ) ? Helper::get_string_value( $params['form_type'] ) : 'simple';
79
80 // Filer to skip fields while mapping the fields.
81 $skip_fields = apply_filters( 'srfm_ai_field_map_skip_fields', [], $is_conversational, $form_type );
82
83 // Loop through questions.
84 foreach ( $form_fields as $question ) {
85
86 // Check if question is empty then continue to next question.
87 if ( empty( $question ) || ! is_array( $question ) ) {
88 return new WP_Error(
89 'srfm_ai_mapping_invalid_field',
90 __( 'The AI returned a malformed form field. Please try again.', 'sureforms' ),
91 [ 'status' => 400 ]
92 );
93 }
94
95 // Initialize common attributes.
96 $common_attributes = [
97 'block_id' => bin2hex( random_bytes( 4 ) ), // Generate random block_id.
98 'formId' => 0, // Set your formId here.
99 ];
100
101 // Merge common attributes with question attributes.
102 $merged_attributes = array_merge(
103 $common_attributes,
104 [
105 'label' => sanitize_text_field( $question['label'] ),
106 'required' => filter_var( $question['required'], FILTER_VALIDATE_BOOLEAN ),
107 'help' => isset( $question['helpText'] ) ? sanitize_text_field( $question['helpText'] ) : '',
108 'slug' => isset( $question['slug'] ) ? sanitize_text_field( $question['slug'] ) : '',
109 ]
110 );
111
112 // Forward `placeholder` to the block attrs. Every input-like
113 // block (`input`, `email`, `url`, `phone`, `number`,
114 // `textarea`, `dropdown`) declares a `placeholder` attribute
115 // in its block.json; without this passthrough the value is
116 // silently dropped by the mapper even when the caller (AI,
117 // MCP, or the HTML-form converter) supplied it.
118 if ( isset( $question['placeholder'] ) && is_string( $question['placeholder'] ) && '' !== $question['placeholder'] ) {
119 // Bound the placeholder to 500 chars: other string fields
120 // in this mapper are implicitly bounded by their upstream
121 // schema, but `placeholder` lands here from three call
122 // sites (AI, MCP, HTML converter) and a pathological
123 // caller could push a multi-MB string into the block's
124 // `_srfm_*` post meta. `wp_html_excerpt` strips HTML
125 // first, then truncates safely on word boundaries.
126 $merged_attributes['placeholder'] = wp_html_excerpt( sanitize_text_field( $question['placeholder'] ), 500 );
127 }
128
129 // Apply filter to modify field type.
130 $field_type = apply_filters( 'srfm_ai_field_modify_field_type', $question['fieldType'], $question, $is_conversational, $form_type );
131
132 // Determine field type based on field_type.
133 switch ( $field_type ) {
134 case 'input':
135 case 'email':
136 case 'number':
137 case 'textarea':
138 case 'dropdown':
139 case 'checkbox':
140 case 'address':
141 case 'inline-button':
142 case 'gdpr':
143 case 'multi-choice':
144 case 'url':
145 case 'phone':
146 case 'payment':
147 // if payment block then map payment specific attributes.
148 if ( 'payment' === $field_type ) {
149 // Amount-unit convention (do not change without auditing the full
150 // chain): the AI prompt schema describes fixedAmount / oneTimeFixedAmount
151 // / subscriptionFixedAmount in MAJOR units (dollars/euros/etc.) using
152 // dollar-magnitude examples (e.g. 99, 1500). All downstream layers
153 // agree: block attrs and stored block_config keep the value in major
154 // units, frontend JS multiplies by 100 only at the boundary when posting
155 // to create_payment_intent, and the server divides it back via
156 // Stripe_Helper::amount_from_stripe_format() before validating against
157 // the stored fixed_amount. Stripe API itself is the only consumer that
158 // expects minor units and it is fed the JS-multiplied value. Reviewers:
159 // do not flag a "cents vs dollars ambiguity" here — the convention is
160 // consistent end-to-end, and adding a unit declaration to the AI schema
161 // would actually break the existing pipeline.
162 //
163 // Default-amount convention (do not change without auditing every
164 // callsite): the fallback `10` used when the AI omits fixedAmount /
165 // oneTimeFixedAmount / subscriptionFixedAmount is the same starter
166 // value that block.json sets when an admin manually adds a payment
167 // block in the Gutenberg editor. payment-markup.php and
168 // field-validation.php apply the same default. AI-generated forms
169 // therefore behave identically to manually-built forms when an amount
170 // is missing — admin reviews the form preview and adjusts before
171 // publishing. The schema marks these three amounts as `required`, so
172 // in practice this fallback only fires for malformed AI responses;
173 // changing it to 0 would make the manual-editor UX worse without
174 // closing any real revenue-loss vector. Reviewers: do not flag the
175 // `10` default here as a hidden charge — it is the project-wide
176 // payment-block starter value.
177 //
178 // Update-flow caveat (pre-existing, not specific to "both" mode):
179 // generate_gutenberg_fields_from_questions() is also called by the
180 // update-form ability (inc/abilities/forms/update-form.php) which
181 // regenerates the entire post_content from the AI's input. There is
182 // no merge with the form's currently-saved attributes — every field
183 // type's default-on-omit behavior applies. If an AI partial update
184 // omits a field attribute (e.g. a previously-saved subscriptionFixedAmount
185 // of $15), the default kicks in and overwrites the saved value. This
186 // is a long-standing characteristic of the update flow, affecting all
187 // fields equally; it is not a regression introduced by the "both"
188 // payment-type work and should be addressed (if at all) by teaching
189 // generate_gutenberg_fields_from_questions to merge with existing block
190 // attrs — a broader refactor outside this scope. Reviewers: do not
191 // flag this as a payment-specific bug.
192 //
193 // Schema "required" scope (sureforms-ai-templates/payment.json):
194 // the JSON schema lists every payment property — including all 11
195 // "both"-mode attrs — in a single flat `required` array applied to
196 // every payment field, not scoped per paymentType. This is a
197 // constraint of OpenAI's strict structured output mode: when
198 // `additionalProperties: false` is set, every property must also
199 // appear in `required`. The per-property `description` strings tell
200 // the model to emit empty strings / 0 for inapplicable modes (e.g.
201 // `oneTimeLabel: ''` when paymentType='one-time'). The mapping below
202 // only reads those attrs when paymentType='both', so empty values
203 // for other modes are silently and correctly dropped — there is no
204 // silent conflict. Reviewers: do not flag the flat `required` list
205 // as a scoping bug; it is how OpenAI strict mode works.
206 $amount_types = [ 'fixed', 'variable', 'user-choice' ];
207 $intervals = [ 'day', 'week', 'month', 'quarter', 'year' ];
208
209 $merged_attributes['customerNameField'] = isset( $question['customerNameField'] ) ? sanitize_text_field( $question['customerNameField'] ) : '';
210 $merged_attributes['customerEmailField'] = isset( $question['customerEmailField'] ) ? sanitize_text_field( $question['customerEmailField'] ) : '';
211 $merged_attributes['paymentType'] = isset( $question['paymentType'] ) && in_array( $question['paymentType'], [ 'one-time', 'subscription', 'both' ], true ) ? sanitize_text_field( $question['paymentType'] ) : 'one-time';
212 $merged_attributes['subscriptionPlan'] = isset( $question['subscriptionPlan'] ) && is_array( $question['subscriptionPlan'] ) ? [
213 'name' => isset( $question['subscriptionPlan']['name'] ) ? sanitize_text_field( $question['subscriptionPlan']['name'] ) : 'Subscription Plan',
214 'interval' => isset( $question['subscriptionPlan']['interval'] ) && in_array( $question['subscriptionPlan']['interval'], $intervals, true ) ? sanitize_text_field( $question['subscriptionPlan']['interval'] ) : 'month',
215 'billingCycles' => isset( $question['subscriptionPlan']['billingCycles'] ) ? ( is_numeric( $question['subscriptionPlan']['billingCycles'] ) ? intval( $question['subscriptionPlan']['billingCycles'] ) : sanitize_text_field( $question['subscriptionPlan']['billingCycles'] ) ) : 'ongoing',
216 ] : [
217 'name' => 'Subscription Plan',
218 'interval' => 'month',
219 'billingCycles' => 'ongoing',
220 ];
221 $merged_attributes['amountType'] = isset( $question['amountType'] ) && in_array( $question['amountType'], $amount_types, true ) ? sanitize_text_field( $question['amountType'] ) : 'fixed';
222 $merged_attributes['fixedAmount'] = isset( $question['fixedAmount'] ) && is_numeric( $question['fixedAmount'] ) ? floatval( $question['fixedAmount'] ) : 10;
223 $merged_attributes['minimumAmount'] = isset( $question['minimumAmount'] ) && is_numeric( $question['minimumAmount'] ) ? floatval( $question['minimumAmount'] ) : 0;
224 $merged_attributes['amountLabel'] = isset( $question['amountLabel'] ) ? sanitize_text_field( $question['amountLabel'] ) : 'Enter Amount';
225 $merged_attributes['variableAmountField'] = isset( $question['variableAmountField'] ) ? sanitize_text_field( $question['variableAmountField'] ) : '';
226
227 // "Both" mode attributes — admins configure one-time AND subscription in the same block.
228 if ( 'both' === $merged_attributes['paymentType'] ) {
229 $merged_attributes['oneTimeLabel'] = isset( $question['oneTimeLabel'] ) ? sanitize_text_field( $question['oneTimeLabel'] ) : 'One-Time Payment';
230 $merged_attributes['subscriptionLabel'] = isset( $question['subscriptionLabel'] ) ? sanitize_text_field( $question['subscriptionLabel'] ) : 'Subscription';
231 $merged_attributes['defaultPaymentChoice'] = isset( $question['defaultPaymentChoice'] ) && in_array( $question['defaultPaymentChoice'], [ 'one-time', 'subscription' ], true ) ? sanitize_text_field( $question['defaultPaymentChoice'] ) : 'one-time';
232 $merged_attributes['oneTimeAmountType'] = isset( $question['oneTimeAmountType'] ) && in_array( $question['oneTimeAmountType'], $amount_types, true ) ? sanitize_text_field( $question['oneTimeAmountType'] ) : 'fixed';
233 $merged_attributes['oneTimeFixedAmount'] = isset( $question['oneTimeFixedAmount'] ) && is_numeric( $question['oneTimeFixedAmount'] ) ? floatval( $question['oneTimeFixedAmount'] ) : 10;
234 $merged_attributes['oneTimeMinimumAmount'] = isset( $question['oneTimeMinimumAmount'] ) && is_numeric( $question['oneTimeMinimumAmount'] ) ? floatval( $question['oneTimeMinimumAmount'] ) : 0;
235 $merged_attributes['oneTimeVariableAmountField'] = isset( $question['oneTimeVariableAmountField'] ) ? sanitize_text_field( $question['oneTimeVariableAmountField'] ) : '';
236 $merged_attributes['subscriptionAmountType'] = isset( $question['subscriptionAmountType'] ) && in_array( $question['subscriptionAmountType'], $amount_types, true ) ? sanitize_text_field( $question['subscriptionAmountType'] ) : 'fixed';
237 $merged_attributes['subscriptionFixedAmount'] = isset( $question['subscriptionFixedAmount'] ) && is_numeric( $question['subscriptionFixedAmount'] ) ? floatval( $question['subscriptionFixedAmount'] ) : 10;
238 $merged_attributes['subscriptionMinimumAmount'] = isset( $question['subscriptionMinimumAmount'] ) && is_numeric( $question['subscriptionMinimumAmount'] ) ? floatval( $question['subscriptionMinimumAmount'] ) : 0;
239 $merged_attributes['subscriptionVariableAmountField'] = isset( $question['subscriptionVariableAmountField'] ) ? sanitize_text_field( $question['subscriptionVariableAmountField'] ) : '';
240 }
241 }
242
243 // Handle specific attributes for certain fields.
244 if ( 'dropdown' === $field_type && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
245 ! empty( $question['fieldOptions'][0]['label'] )
246 ) {
247 // Defense-in-depth: although the upstream middleware is
248 // trusted and these endpoints are capability-gated,
249 // strings flow into Gutenberg block markup so we run
250 // the user-facing fields through sanitize_text_field.
251 $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
252
253 if ( isset( $question['showValues'] ) ) {
254 $merged_attributes['showValues'] = filter_var( $question['showValues'], FILTER_VALIDATE_BOOLEAN );
255 }
256
257 // remove icon from options for the dropdown field.
258 foreach ( $merged_attributes['options'] as $key => $option ) {
259 if ( ! empty( $merged_attributes['options'][ $key ]['icon'] ) ) {
260 $merged_attributes['options'][ $key ]['icon'] = '';
261 }
262 }
263 }
264 if ( 'multi-choice' === $field_type ) {
265
266 // Remove duplicate icons and clear icons if all are the same.
267 $icons = array_column( $question['fieldOptions'], 'icon' );
268 $options = array_column( $question['fieldOptions'], 'optionTitle' );
269 $unique_icons = array_unique( $icons );
270 if ( count( $unique_icons ) === 1 || count( $options ) !== count( $icons ) ) {
271 foreach ( $question['fieldOptions'] as &$option ) {
272 $option['icon'] = '';
273 }
274 }
275
276 // Set options if they are valid.
277 if ( ! empty( $question['fieldOptions'][0]['optionTitle'] ) ) {
278 // Same defense-in-depth sanitization as the
279 // dropdown branch above.
280 $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
281 }
282
283 // Determine vertical layout based on icons.
284 if ( ! empty( $merged_attributes['options'] ) ) {
285 $merged_attributes['verticalLayout'] = array_reduce(
286 $merged_attributes['options'],
287 static fn( $carry, $option ) => $carry && ! empty( $option['icon'] ),
288 true
289 );
290 }
291
292 if ( isset( $question['showValues'] ) ) {
293 $merged_attributes['showValues'] = filter_var( $question['showValues'], FILTER_VALIDATE_BOOLEAN );
294 }
295
296 // Set single selection if provided.
297 if ( isset( $question['singleSelection'] ) ) {
298 $merged_attributes['singleSelection'] = filter_var( $question['singleSelection'], FILTER_VALIDATE_BOOLEAN );
299 }
300
301 // Set choiceWidth for options divisible by 3.
302 if ( ! empty( $merged_attributes['options'] ) && count( $merged_attributes['options'] ) % 3 === 0 ) {
303 $merged_attributes['choiceWidth'] = 33.33;
304 }
305 }
306 if ( 'phone' === $field_type ) {
307 $merged_attributes['autoCountry'] = true;
308 }
309
310 // Apply filter to modify merged attributes.
311 $merged_attributes = apply_filters( 'srfm_ai_form_builder_modify_merged_attributes', $merged_attributes, $question, $is_conversational, $form_type );
312
313 // if field type is needs to be skipped then skip that field.
314 if ( ! empty( $skip_fields ) && in_array( $field_type, $skip_fields, true ) ) {
315 break;
316 }
317
318 $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
319 break;
320 case 'slider':
321 case 'page-break':
322 case 'date-picker':
323 case 'time-picker':
324 case 'upload':
325 case 'hidden':
326 case 'rating':
327 case 'signature':
328 case 'nps':
329 // If pro version is not active then do not add pro fields.
330 if ( ! defined( 'SRFM_PRO_VER' ) ) {
331 break;
332 }
333
334 if ( 'signature' === $field_type && defined( 'SRFM_PRO_PRODUCT' ) && SRFM_PRO_PRODUCT === 'SureForms Starter' ) {
335 // If the product is SureForms Starter then skip the signature field.
336 break;
337 }
338
339 // Handle specific attributes for certain pro fields.
340 if ( 'slider' === $field_type ) {
341 $merged_attributes['min'] = ! empty( $question['min'] ) ? filter_var( $question['min'], FILTER_VALIDATE_INT ) : 0;
342 $merged_attributes['max'] = ! empty( $question['max'] ) ? filter_var( $question['max'], FILTER_VALIDATE_INT ) : 100;
343 $merged_attributes['step'] = ! empty( $question['step'] ) ? filter_var( $question['step'], FILTER_VALIDATE_INT ) : 1;
344
345 $merged_attributes['prefixTooltip'] = ! empty( $question['prefixTooltip'] ) ? $question['prefixTooltip'] : '';
346 $merged_attributes['suffixTooltip'] = ! empty( $question['suffixTooltip'] ) ? $question['suffixTooltip'] : '';
347
348 // get min and max then diveide by 2 and round it.
349 $min = $merged_attributes['min'];
350 $max = $merged_attributes['max'];
351
352 if ( is_numeric( $min ) && is_numeric( $max ) ) {
353 $min = intval( $min );
354 $max = intval( $max );
355
356 // If min and max are same then set the value to 0.
357 $merged_attributes['numberDefaultValue'] = Helper::get_string_value( round( ( $min + $max ) / 2 ) );
358 }
359 }
360 if ( 'date-picker' === $field_type ) {
361 $merged_attributes['dateFormat'] = ! empty( $question['dateFormat'] ) ? sanitize_text_field( $question['dateFormat'] ) : 'mm/dd/yy';
362 $merged_attributes['min'] = ! empty( $question['minDate'] ) ? sanitize_text_field( $question['minDate'] ) : '';
363 $merged_attributes['max'] = ! empty( $question['maxDate'] ) ? sanitize_text_field( $question['maxDate'] ) : '';
364 }
365 if ( 'time-picker' === $field_type ) {
366 $merged_attributes['increment'] = ! empty( $question['increment'] ) ? filter_var( $question['increment'], FILTER_VALIDATE_INT ) : 30;
367 $merged_attributes['showTwelveHourFormat'] = ! empty( $question['showTwelveHourFormat'] ) ? filter_var( $question['useTwelveHourFormat'], FILTER_VALIDATE_BOOLEAN ) : false;
368 $merged_attributes['min'] = ! empty( $question['minTime'] ) ? sanitize_text_field( $question['minTime'] ) : '';
369 $merged_attributes['max'] = ! empty( $question['maxTime'] ) ? sanitize_text_field( $question['maxTime'] ) : '';
370 }
371 if ( 'rating' === $field_type ) {
372 $merged_attributes['iconShape'] = ! empty( $question['iconShape'] ) ? sanitize_text_field( $question['iconShape'] ) : 'star';
373 $merged_attributes['showText'] = ! empty( $question['showTooltip'] ) ? filter_var( $question['showTooltip'], FILTER_VALIDATE_BOOLEAN ) : false;
374 $merged_attributes['defaultRating'] = ! empty( $question['defaultRating'] ) ? filter_var( $question['defaultRating'], FILTER_VALIDATE_INT ) : 0;
375
376 if ( ! empty( $merged_attributes['showText'] ) ) {
377 foreach ( $question['tooltipValues'] as $tooltips ) {
378 $i = 0;
379 foreach ( $tooltips as $value ) {
380 $merged_attributes['ratingText'][ $i ] = ! empty( $value ) ? sanitize_text_field( $value ) : '';
381 $i++;
382 }
383 }
384 }
385 }
386 if ( 'upload' === $field_type ) {
387 if ( ! empty( $question['allowedTypes'] ) ) {
388 $allowed_types = str_replace( '.', '', $question['allowedTypes'] );
389
390 $allowed_types = explode( ',', $allowed_types );
391
392 $types_array = array_map(
393 static function( $type ) {
394 return [
395 'value' => trim( $type ),
396 'label' => trim( $type ),
397 ];
398 },
399 $allowed_types
400 );
401
402 $merged_attributes['allowedFormats'] = $types_array;
403 } else {
404 $merged_attributes['allowedFormats'] = [
405 [
406 'value' => 'jpg',
407 'label' => 'jpg',
408 ],
409 [
410 'value' => 'jpeg',
411 'label' => 'jpeg',
412 ],
413 [
414 'value' => 'gif',
415 'label' => 'gif',
416 ],
417 [
418 'value' => 'png',
419 'label' => 'png',
420 ],
421 [
422 'value' => 'pdf',
423 'label' => 'pdf',
424 ],
425 ];
426 }
427
428 $merged_attributes['fileSizeLimit'] = ! empty( $question['uploadSize'] ) ? filter_var( $question['uploadSize'], FILTER_VALIDATE_INT ) : 10;
429
430 $merged_attributes['multiple'] = ! empty( $question['multiUpload'] ) ? filter_var( $question['multiUpload'], FILTER_VALIDATE_BOOLEAN ) : false;
431
432 $merged_attributes['maxFiles'] = ! empty( $question['multiFilesNumber'] ) ? filter_var( $question['multiFilesNumber'], FILTER_VALIDATE_INT ) : 2;
433
434 }
435
436 $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
437 break;
438 default:
439 // Unsupported field type - fallback to input.
440 $post_content .= '<!-- wp:srfm/input ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
441 }
442 }
443
444 return apply_filters( 'srfm_ai_form_builder_post_content', $post_content, $is_conversational, $form_type );
445 }
446
447 /**
448 * Sanitize the user-facing strings on each entry of the AI-generated
449 * fieldOptions array before they are merged into block attributes.
450 *
451 * Defense-in-depth: the middleware is trusted today, but these strings
452 * are serialized into Gutenberg block markup. Running each string field
453 * through sanitize_text_field() prevents stored-content injection if
454 * the upstream ever returns reflected user content. Non-string fields
455 * (icon class names, booleans) are left untouched.
456 *
457 * @param array<int, array<string, mixed>> $options Raw fieldOptions array.
458 * @since 2.8.2
459 * @return array<int, array<string, mixed>> Sanitized options.
460 */
461 private static function sanitize_field_options( $options ) {
462 if ( ! is_array( $options ) ) {
463 return [];
464 }
465 $sanitizable_keys = [ 'label', 'value', 'optionTitle' ];
466 foreach ( $options as $key => $option ) {
467 if ( ! is_array( $option ) ) {
468 continue;
469 }
470 foreach ( $sanitizable_keys as $field ) {
471 if ( isset( $option[ $field ] ) && is_string( $option[ $field ] ) ) {
472 $options[ $key ][ $field ] = sanitize_text_field( $option[ $field ] );
473 }
474 }
475 }
476 return $options;
477 }
478
479 }
480