PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
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.12.8, at inc/ai-form-builder/field-mapping.php

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