PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.3
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.3
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 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / ai-form-builder / field-mapping.php
sureforms / inc / ai-form-builder Last commit date
ai-auth.php 5 months ago ai-form-builder.php 1 month ago ai-helper.php 3 months ago field-mapping.php 2 months ago
field-mapping.php
494 lines
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 // Forward `className` (Additional CSS Class) to the block attrs.
130 // Field blocks inherit core's className support and render it onto the
131 // field wrapper (see inc/fields/base.php::set_properties()). Lands from
132 // multiple callers (AI, MCP, HTML converter), so sanitize each token.
133 if ( isset( $question['className'] ) && is_string( $question['className'] ) && '' !== $question['className'] ) {
134 $classes = preg_split( '/\s+/', trim( $question['className'] ) );
135 if ( is_array( $classes ) ) {
136 $clean = implode( ' ', array_filter( array_map( 'sanitize_html_class', $classes ) ) );
137 if ( '' !== $clean ) {
138 $merged_attributes['className'] = $clean;
139 }
140 }
141 }
142
143 // Apply filter to modify field type.
144 $field_type = apply_filters( 'srfm_ai_field_modify_field_type', $question['fieldType'], $question, $is_conversational, $form_type );
145
146 // Determine field type based on field_type.
147 switch ( $field_type ) {
148 case 'input':
149 case 'email':
150 case 'number':
151 case 'textarea':
152 case 'dropdown':
153 case 'checkbox':
154 case 'address':
155 case 'inline-button':
156 case 'gdpr':
157 case 'multi-choice':
158 case 'url':
159 case 'phone':
160 case 'payment':
161 // if payment block then map payment specific attributes.
162 if ( 'payment' === $field_type ) {
163 // Amount-unit convention (do not change without auditing the full
164 // chain): the AI prompt schema describes fixedAmount / oneTimeFixedAmount
165 // / subscriptionFixedAmount in MAJOR units (dollars/euros/etc.) using
166 // dollar-magnitude examples (e.g. 99, 1500). All downstream layers
167 // agree: block attrs and stored block_config keep the value in major
168 // units, frontend JS multiplies by 100 only at the boundary when posting
169 // to create_payment_intent, and the server divides it back via
170 // Stripe_Helper::amount_from_stripe_format() before validating against
171 // the stored fixed_amount. Stripe API itself is the only consumer that
172 // expects minor units and it is fed the JS-multiplied value. Reviewers:
173 // do not flag a "cents vs dollars ambiguity" here — the convention is
174 // consistent end-to-end, and adding a unit declaration to the AI schema
175 // would actually break the existing pipeline.
176 //
177 // Default-amount convention (do not change without auditing every
178 // callsite): the fallback `10` used when the AI omits fixedAmount /
179 // oneTimeFixedAmount / subscriptionFixedAmount is the same starter
180 // value that block.json sets when an admin manually adds a payment
181 // block in the Gutenberg editor. payment-markup.php and
182 // field-validation.php apply the same default. AI-generated forms
183 // therefore behave identically to manually-built forms when an amount
184 // is missing — admin reviews the form preview and adjusts before
185 // publishing. The schema marks these three amounts as `required`, so
186 // in practice this fallback only fires for malformed AI responses;
187 // changing it to 0 would make the manual-editor UX worse without
188 // closing any real revenue-loss vector. Reviewers: do not flag the
189 // `10` default here as a hidden charge — it is the project-wide
190 // payment-block starter value.
191 //
192 // Update-flow caveat (pre-existing, not specific to "both" mode):
193 // generate_gutenberg_fields_from_questions() is also called by the
194 // update-form ability (inc/abilities/forms/update-form.php) which
195 // regenerates the entire post_content from the AI's input. There is
196 // no merge with the form's currently-saved attributes — every field
197 // type's default-on-omit behavior applies. If an AI partial update
198 // omits a field attribute (e.g. a previously-saved subscriptionFixedAmount
199 // of $15), the default kicks in and overwrites the saved value. This
200 // is a long-standing characteristic of the update flow, affecting all
201 // fields equally; it is not a regression introduced by the "both"
202 // payment-type work and should be addressed (if at all) by teaching
203 // generate_gutenberg_fields_from_questions to merge with existing block
204 // attrs — a broader refactor outside this scope. Reviewers: do not
205 // flag this as a payment-specific bug.
206 //
207 // Schema "required" scope (sureforms-ai-templates/payment.json):
208 // the JSON schema lists every payment property — including all 11
209 // "both"-mode attrs — in a single flat `required` array applied to
210 // every payment field, not scoped per paymentType. This is a
211 // constraint of OpenAI's strict structured output mode: when
212 // `additionalProperties: false` is set, every property must also
213 // appear in `required`. The per-property `description` strings tell
214 // the model to emit empty strings / 0 for inapplicable modes (e.g.
215 // `oneTimeLabel: ''` when paymentType='one-time'). The mapping below
216 // only reads those attrs when paymentType='both', so empty values
217 // for other modes are silently and correctly dropped — there is no
218 // silent conflict. Reviewers: do not flag the flat `required` list
219 // as a scoping bug; it is how OpenAI strict mode works.
220 $amount_types = [ 'fixed', 'variable', 'user-choice' ];
221 $intervals = [ 'day', 'week', 'month', 'quarter', 'year' ];
222
223 $merged_attributes['customerNameField'] = isset( $question['customerNameField'] ) ? sanitize_text_field( $question['customerNameField'] ) : '';
224 $merged_attributes['customerEmailField'] = isset( $question['customerEmailField'] ) ? sanitize_text_field( $question['customerEmailField'] ) : '';
225 $merged_attributes['paymentType'] = isset( $question['paymentType'] ) && in_array( $question['paymentType'], [ 'one-time', 'subscription', 'both' ], true ) ? sanitize_text_field( $question['paymentType'] ) : 'one-time';
226 $merged_attributes['subscriptionPlan'] = isset( $question['subscriptionPlan'] ) && is_array( $question['subscriptionPlan'] ) ? [
227 'name' => isset( $question['subscriptionPlan']['name'] ) ? sanitize_text_field( $question['subscriptionPlan']['name'] ) : 'Subscription Plan',
228 'interval' => isset( $question['subscriptionPlan']['interval'] ) && in_array( $question['subscriptionPlan']['interval'], $intervals, true ) ? sanitize_text_field( $question['subscriptionPlan']['interval'] ) : 'month',
229 'billingCycles' => isset( $question['subscriptionPlan']['billingCycles'] ) ? ( is_numeric( $question['subscriptionPlan']['billingCycles'] ) ? intval( $question['subscriptionPlan']['billingCycles'] ) : sanitize_text_field( $question['subscriptionPlan']['billingCycles'] ) ) : 'ongoing',
230 ] : [
231 'name' => 'Subscription Plan',
232 'interval' => 'month',
233 'billingCycles' => 'ongoing',
234 ];
235 $merged_attributes['amountType'] = isset( $question['amountType'] ) && in_array( $question['amountType'], $amount_types, true ) ? sanitize_text_field( $question['amountType'] ) : 'fixed';
236 $merged_attributes['fixedAmount'] = isset( $question['fixedAmount'] ) && is_numeric( $question['fixedAmount'] ) ? floatval( $question['fixedAmount'] ) : 10;
237 $merged_attributes['minimumAmount'] = isset( $question['minimumAmount'] ) && is_numeric( $question['minimumAmount'] ) ? floatval( $question['minimumAmount'] ) : 0;
238 $merged_attributes['amountLabel'] = isset( $question['amountLabel'] ) ? sanitize_text_field( $question['amountLabel'] ) : 'Enter Amount';
239 $merged_attributes['variableAmountField'] = isset( $question['variableAmountField'] ) ? sanitize_text_field( $question['variableAmountField'] ) : '';
240
241 // "Both" mode attributes — admins configure one-time AND subscription in the same block.
242 if ( 'both' === $merged_attributes['paymentType'] ) {
243 $merged_attributes['oneTimeLabel'] = isset( $question['oneTimeLabel'] ) ? sanitize_text_field( $question['oneTimeLabel'] ) : 'One-Time Payment';
244 $merged_attributes['subscriptionLabel'] = isset( $question['subscriptionLabel'] ) ? sanitize_text_field( $question['subscriptionLabel'] ) : 'Subscription';
245 $merged_attributes['defaultPaymentChoice'] = isset( $question['defaultPaymentChoice'] ) && in_array( $question['defaultPaymentChoice'], [ 'one-time', 'subscription' ], true ) ? sanitize_text_field( $question['defaultPaymentChoice'] ) : 'one-time';
246 $merged_attributes['oneTimeAmountType'] = isset( $question['oneTimeAmountType'] ) && in_array( $question['oneTimeAmountType'], $amount_types, true ) ? sanitize_text_field( $question['oneTimeAmountType'] ) : 'fixed';
247 $merged_attributes['oneTimeFixedAmount'] = isset( $question['oneTimeFixedAmount'] ) && is_numeric( $question['oneTimeFixedAmount'] ) ? floatval( $question['oneTimeFixedAmount'] ) : 10;
248 $merged_attributes['oneTimeMinimumAmount'] = isset( $question['oneTimeMinimumAmount'] ) && is_numeric( $question['oneTimeMinimumAmount'] ) ? floatval( $question['oneTimeMinimumAmount'] ) : 0;
249 $merged_attributes['oneTimeVariableAmountField'] = isset( $question['oneTimeVariableAmountField'] ) ? sanitize_text_field( $question['oneTimeVariableAmountField'] ) : '';
250 $merged_attributes['subscriptionAmountType'] = isset( $question['subscriptionAmountType'] ) && in_array( $question['subscriptionAmountType'], $amount_types, true ) ? sanitize_text_field( $question['subscriptionAmountType'] ) : 'fixed';
251 $merged_attributes['subscriptionFixedAmount'] = isset( $question['subscriptionFixedAmount'] ) && is_numeric( $question['subscriptionFixedAmount'] ) ? floatval( $question['subscriptionFixedAmount'] ) : 10;
252 $merged_attributes['subscriptionMinimumAmount'] = isset( $question['subscriptionMinimumAmount'] ) && is_numeric( $question['subscriptionMinimumAmount'] ) ? floatval( $question['subscriptionMinimumAmount'] ) : 0;
253 $merged_attributes['subscriptionVariableAmountField'] = isset( $question['subscriptionVariableAmountField'] ) ? sanitize_text_field( $question['subscriptionVariableAmountField'] ) : '';
254 }
255 }
256
257 // Handle specific attributes for certain fields.
258 if ( 'dropdown' === $field_type && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
259 ! empty( $question['fieldOptions'][0]['label'] )
260 ) {
261 // Defense-in-depth: although the upstream middleware is
262 // trusted and these endpoints are capability-gated,
263 // strings flow into Gutenberg block markup so we run
264 // the user-facing fields through sanitize_text_field.
265 $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
266
267 if ( isset( $question['showValues'] ) ) {
268 $merged_attributes['showValues'] = filter_var( $question['showValues'], FILTER_VALIDATE_BOOLEAN );
269 }
270
271 // remove icon from options for the dropdown field.
272 foreach ( $merged_attributes['options'] as $key => $option ) {
273 if ( ! empty( $merged_attributes['options'][ $key ]['icon'] ) ) {
274 $merged_attributes['options'][ $key ]['icon'] = '';
275 }
276 }
277 }
278 if ( 'multi-choice' === $field_type ) {
279
280 // Remove duplicate icons and clear icons if all are the same.
281 $icons = array_column( $question['fieldOptions'], 'icon' );
282 $options = array_column( $question['fieldOptions'], 'optionTitle' );
283 $unique_icons = array_unique( $icons );
284 if ( count( $unique_icons ) === 1 || count( $options ) !== count( $icons ) ) {
285 foreach ( $question['fieldOptions'] as &$option ) {
286 $option['icon'] = '';
287 }
288 }
289
290 // Set options if they are valid.
291 if ( ! empty( $question['fieldOptions'][0]['optionTitle'] ) ) {
292 // Same defense-in-depth sanitization as the
293 // dropdown branch above.
294 $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
295 }
296
297 // Determine vertical layout based on icons.
298 if ( ! empty( $merged_attributes['options'] ) ) {
299 $merged_attributes['verticalLayout'] = array_reduce(
300 $merged_attributes['options'],
301 static fn( $carry, $option ) => $carry && ! empty( $option['icon'] ),
302 true
303 );
304 }
305
306 if ( isset( $question['showValues'] ) ) {
307 $merged_attributes['showValues'] = filter_var( $question['showValues'], FILTER_VALIDATE_BOOLEAN );
308 }
309
310 // Set single selection if provided.
311 if ( isset( $question['singleSelection'] ) ) {
312 $merged_attributes['singleSelection'] = filter_var( $question['singleSelection'], FILTER_VALIDATE_BOOLEAN );
313 }
314
315 // Set choiceWidth for options divisible by 3.
316 if ( ! empty( $merged_attributes['options'] ) && count( $merged_attributes['options'] ) % 3 === 0 ) {
317 $merged_attributes['choiceWidth'] = 33.33;
318 }
319 }
320 if ( 'phone' === $field_type ) {
321 $merged_attributes['autoCountry'] = true;
322 }
323
324 // Apply filter to modify merged attributes.
325 $merged_attributes = apply_filters( 'srfm_ai_form_builder_modify_merged_attributes', $merged_attributes, $question, $is_conversational, $form_type );
326
327 // if field type is needs to be skipped then skip that field.
328 if ( ! empty( $skip_fields ) && in_array( $field_type, $skip_fields, true ) ) {
329 break;
330 }
331
332 $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
333 break;
334 case 'slider':
335 case 'page-break':
336 case 'date-picker':
337 case 'time-picker':
338 case 'upload':
339 case 'hidden':
340 case 'rating':
341 case 'signature':
342 case 'nps':
343 // If pro version is not active then do not add pro fields.
344 if ( ! defined( 'SRFM_PRO_VER' ) ) {
345 break;
346 }
347
348 if ( 'signature' === $field_type && defined( 'SRFM_PRO_PRODUCT' ) && SRFM_PRO_PRODUCT === 'SureForms Starter' ) {
349 // If the product is SureForms Starter then skip the signature field.
350 break;
351 }
352
353 // Handle specific attributes for certain pro fields.
354 if ( 'slider' === $field_type ) {
355 $merged_attributes['min'] = ! empty( $question['min'] ) ? filter_var( $question['min'], FILTER_VALIDATE_INT ) : 0;
356 $merged_attributes['max'] = ! empty( $question['max'] ) ? filter_var( $question['max'], FILTER_VALIDATE_INT ) : 100;
357 $merged_attributes['step'] = ! empty( $question['step'] ) ? filter_var( $question['step'], FILTER_VALIDATE_INT ) : 1;
358
359 $merged_attributes['prefixTooltip'] = ! empty( $question['prefixTooltip'] ) ? $question['prefixTooltip'] : '';
360 $merged_attributes['suffixTooltip'] = ! empty( $question['suffixTooltip'] ) ? $question['suffixTooltip'] : '';
361
362 // get min and max then diveide by 2 and round it.
363 $min = $merged_attributes['min'];
364 $max = $merged_attributes['max'];
365
366 if ( is_numeric( $min ) && is_numeric( $max ) ) {
367 $min = intval( $min );
368 $max = intval( $max );
369
370 // If min and max are same then set the value to 0.
371 $merged_attributes['numberDefaultValue'] = Helper::get_string_value( round( ( $min + $max ) / 2 ) );
372 }
373 }
374 if ( 'date-picker' === $field_type ) {
375 $merged_attributes['dateFormat'] = ! empty( $question['dateFormat'] ) ? sanitize_text_field( $question['dateFormat'] ) : 'mm/dd/yy';
376 $merged_attributes['min'] = ! empty( $question['minDate'] ) ? sanitize_text_field( $question['minDate'] ) : '';
377 $merged_attributes['max'] = ! empty( $question['maxDate'] ) ? sanitize_text_field( $question['maxDate'] ) : '';
378 }
379 if ( 'time-picker' === $field_type ) {
380 $merged_attributes['increment'] = ! empty( $question['increment'] ) ? filter_var( $question['increment'], FILTER_VALIDATE_INT ) : 30;
381 $merged_attributes['showTwelveHourFormat'] = ! empty( $question['showTwelveHourFormat'] ) ? filter_var( $question['useTwelveHourFormat'], FILTER_VALIDATE_BOOLEAN ) : false;
382 $merged_attributes['min'] = ! empty( $question['minTime'] ) ? sanitize_text_field( $question['minTime'] ) : '';
383 $merged_attributes['max'] = ! empty( $question['maxTime'] ) ? sanitize_text_field( $question['maxTime'] ) : '';
384 }
385 if ( 'rating' === $field_type ) {
386 $merged_attributes['iconShape'] = ! empty( $question['iconShape'] ) ? sanitize_text_field( $question['iconShape'] ) : 'star';
387 $merged_attributes['showText'] = ! empty( $question['showTooltip'] ) ? filter_var( $question['showTooltip'], FILTER_VALIDATE_BOOLEAN ) : false;
388 $merged_attributes['defaultRating'] = ! empty( $question['defaultRating'] ) ? filter_var( $question['defaultRating'], FILTER_VALIDATE_INT ) : 0;
389
390 if ( ! empty( $merged_attributes['showText'] ) ) {
391 foreach ( $question['tooltipValues'] as $tooltips ) {
392 $i = 0;
393 foreach ( $tooltips as $value ) {
394 $merged_attributes['ratingText'][ $i ] = ! empty( $value ) ? sanitize_text_field( $value ) : '';
395 $i++;
396 }
397 }
398 }
399 }
400 if ( 'upload' === $field_type ) {
401 if ( ! empty( $question['allowedTypes'] ) ) {
402 $allowed_types = str_replace( '.', '', $question['allowedTypes'] );
403
404 $allowed_types = explode( ',', $allowed_types );
405
406 $types_array = array_map(
407 static function( $type ) {
408 return [
409 'value' => trim( $type ),
410 'label' => trim( $type ),
411 ];
412 },
413 $allowed_types
414 );
415
416 $merged_attributes['allowedFormats'] = $types_array;
417 } else {
418 $merged_attributes['allowedFormats'] = [
419 [
420 'value' => 'jpg',
421 'label' => 'jpg',
422 ],
423 [
424 'value' => 'jpeg',
425 'label' => 'jpeg',
426 ],
427 [
428 'value' => 'gif',
429 'label' => 'gif',
430 ],
431 [
432 'value' => 'png',
433 'label' => 'png',
434 ],
435 [
436 'value' => 'pdf',
437 'label' => 'pdf',
438 ],
439 ];
440 }
441
442 $merged_attributes['fileSizeLimit'] = ! empty( $question['uploadSize'] ) ? filter_var( $question['uploadSize'], FILTER_VALIDATE_INT ) : 10;
443
444 $merged_attributes['multiple'] = ! empty( $question['multiUpload'] ) ? filter_var( $question['multiUpload'], FILTER_VALIDATE_BOOLEAN ) : false;
445
446 $merged_attributes['maxFiles'] = ! empty( $question['multiFilesNumber'] ) ? filter_var( $question['multiFilesNumber'], FILTER_VALIDATE_INT ) : 2;
447
448 }
449
450 $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
451 break;
452 default:
453 // Unsupported field type - fallback to input.
454 $post_content .= '<!-- wp:srfm/input ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
455 }
456 }
457
458 return apply_filters( 'srfm_ai_form_builder_post_content', $post_content, $is_conversational, $form_type );
459 }
460
461 /**
462 * Sanitize the user-facing strings on each entry of the AI-generated
463 * fieldOptions array before they are merged into block attributes.
464 *
465 * Defense-in-depth: the middleware is trusted today, but these strings
466 * are serialized into Gutenberg block markup. Running each string field
467 * through sanitize_text_field() prevents stored-content injection if
468 * the upstream ever returns reflected user content. Non-string fields
469 * (icon class names, booleans) are left untouched.
470 *
471 * @param array<int, array<string, mixed>> $options Raw fieldOptions array.
472 * @since 2.8.2
473 * @return array<int, array<string, mixed>> Sanitized options.
474 */
475 private static function sanitize_field_options( $options ) {
476 if ( ! is_array( $options ) ) {
477 return [];
478 }
479 $sanitizable_keys = [ 'label', 'value', 'optionTitle' ];
480 foreach ( $options as $key => $option ) {
481 if ( ! is_array( $option ) ) {
482 continue;
483 }
484 foreach ( $sanitizable_keys as $field ) {
485 if ( isset( $option[ $field ] ) && is_string( $option[ $field ] ) ) {
486 $options[ $key ][ $field ] = sanitize_text_field( $option[ $field ] );
487 }
488 }
489 }
490 return $options;
491 }
492
493 }
494