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
← All changes | inc/ai-form-builder/field-mapping.php +312 -46 0.0.10 → 2.12.8 View file →
@@ -7,9 +7,11 @@
7 7 */
8 8
9 9 namespace SRFM\Inc\AI_Form_Builder;
10 10
11 +use SRFM\Inc\Helper;
11 12 use SRFM\Inc\Traits\Get_Instance;
13 +use WP_Error;
12 14
13 15 // Exit if accessed directly.
14 16 if ( ! defined( 'ABSPATH' ) ) {
15 17 exit;
@@ -24,9 +26,9 @@
24 26 /**
25 27 * Generate Gutenberg Fields from AI data.
26 28 *
27 29 * @param \WP_REST_Request $request Full details about the request.
28 - * @return string
30 + * @return string|WP_Error
29 31 */
30 32 public static function generate_gutenberg_fields_from_questions( $request ) {
31 33
32 34 // Get params from request.
@@ -32,38 +34,67 @@
32 34 // Get params from request.
33 35 $params = $request->get_params();
34 36
35 37 // check parama is empty or not and is an array and consist form_data key.
36 - if ( empty( $params ) || ! is_array( $params ) || ! isset( $params['form_data'] ) || 0 === count( $params['form_data'] ) ) {
37 - return '';
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 + );
38 48 }
39 49
40 50 // Get questions from form data.
41 51 $form_data = $params['form_data'];
42 52 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
43 - return '';
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 + );
44 58 }
45 59
46 - $form = $form_data['form'];
60 + $form = $form_data['form'] ?? null;
47 61 if ( empty( $form ) || ! is_array( $form ) ) {
48 - return '';
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 + );
49 67 }
50 68
51 - $form_fields = $form['formFields'];
52 - // if questions is empty then return empty string.
53 - if ( empty( $form_fields ) || ! is_array( $form ) ) {
54 - return '';
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 + );
55 76 }
56 77
57 78 // Initialize post content string.
58 79 $post_content = '';
59 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 +
60 87 // Loop through questions.
61 88 foreach ( $form_fields as $question ) {
62 89
63 90 // Check if question is empty then continue to next question.
64 91 if ( empty( $question ) || ! is_array( $question ) ) {
65 - return '';
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 + );
66 97 }
67 98
68 99 // Initialize common attributes.
69 100 $common_attributes = [
@@ -76,14 +107,49 @@
76 107 $common_attributes,
77 108 [
78 109 'label' => sanitize_text_field( $question['label'] ),
79 110 'required' => filter_var( $question['required'], FILTER_VALIDATE_BOOLEAN ),
80 - 'help' => sanitize_text_field( $question['helpText'] ),
111 + 'help' => isset( $question['helpText'] ) ? sanitize_text_field( $question['helpText'] ) : '',
112 + 'slug' => isset( $question['slug'] ) ? sanitize_text_field( $question['slug'] ) : '',
81 113 ]
82 114 );
83 115
84 - // Determine field type based on fieldType.
85 - switch ( $question['fieldType'] ) {
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 ) {
86 152 case 'input':
87 153 case 'email':
88 154 case 'number':
89 155 case 'textarea':
@@ -94,40 +160,181 @@
94 160 case 'gdpr':
95 161 case 'multi-choice':
96 162 case 'url':
97 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 +
98 261 // Handle specific attributes for certain fields.
99 - if ( 'textarea' === $question['fieldType'] && ! empty( $question['helpText'] ) ) {
100 - $merged_attributes['textAreaHelpText'] = sanitize_text_field( $question['helpText'] );
101 - }
102 - if ( 'dropdown' === $question['fieldType'] && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
262 + if ( 'dropdown' === $field_type && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
103 263 ! empty( $question['fieldOptions'][0]['label'] )
104 264 ) {
105 - $merged_attributes['options'] = $question['fieldOptions'];
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 + }
106 281 }
107 - if ( 'multi-choice' === $question['fieldType'] ) {
108 - if ( ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] )
109 - && ! empty( $question['fieldOptions'][0]['optionTitle'] )
110 - ) {
111 - $merged_attributes['options'] = $question['fieldOptions'];
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 + }
112 292 }
113 - if ( ! empty( $question['singleSelection'] ) ) {
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'] ) ) {
114 316 $merged_attributes['singleSelection'] = filter_var( $question['singleSelection'], FILTER_VALIDATE_BOOLEAN );
115 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 + }
116 323 }
117 - // if checkbox then map help to checkboxHelpText.
118 - if ( 'checkbox' === $question['fieldType'] && ! empty( $question['helpText'] ) ) {
119 - $merged_attributes['checkboxHelpText'] = sanitize_text_field( $question['helpText'] );
324 + if ( 'phone' === $field_type ) {
325 + $merged_attributes['autoCountry'] = true;
120 326 }
121 - // if gdpr then map help to gdprHelpText.
122 - if ( 'gdpr' === $question['fieldType'] && ! empty( $question['helpText'] ) ) {
123 - $merged_attributes['gdprHelpText'] = sanitize_text_field( $question['helpText'] );
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;
124 334 }
125 - if ( 'phone' === $question['fieldType'] ) {
126 - $merged_attributes['autoCountry'] = true;
127 - }
128 335
129 - $post_content .= '<!-- wp:srfm/' . $question['fieldType'] . ' ' . wp_json_encode( $merged_attributes ) . ' /-->' . PHP_EOL;
336 + $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
130 337 break;
131 338 case 'slider':
132 339 case 'page-break':
133 340 case 'date-picker':
@@ -134,26 +341,53 @@
134 341 case 'time-picker':
135 342 case 'upload':
136 343 case 'hidden':
137 344 case 'rating':
345 + case 'signature':
346 + case 'nps':
138 347 // If pro version is not active then do not add pro fields.
139 348 if ( ! defined( 'SRFM_PRO_VER' ) ) {
140 349 break;
141 350 }
142 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 +
143 357 // Handle specific attributes for certain pro fields.
144 - if ( 'date-picker' === $question['fieldType'] ) {
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 ) {
145 379 $merged_attributes['dateFormat'] = ! empty( $question['dateFormat'] ) ? sanitize_text_field( $question['dateFormat'] ) : 'mm/dd/yy';
146 380 $merged_attributes['min'] = ! empty( $question['minDate'] ) ? sanitize_text_field( $question['minDate'] ) : '';
147 381 $merged_attributes['max'] = ! empty( $question['maxDate'] ) ? sanitize_text_field( $question['maxDate'] ) : '';
148 382 }
149 - if ( 'time-picker' === $question['fieldType'] ) {
383 + if ( 'time-picker' === $field_type ) {
150 384 $merged_attributes['increment'] = ! empty( $question['increment'] ) ? filter_var( $question['increment'], FILTER_VALIDATE_INT ) : 30;
151 385 $merged_attributes['showTwelveHourFormat'] = ! empty( $question['showTwelveHourFormat'] ) ? filter_var( $question['useTwelveHourFormat'], FILTER_VALIDATE_BOOLEAN ) : false;
152 386 $merged_attributes['min'] = ! empty( $question['minTime'] ) ? sanitize_text_field( $question['minTime'] ) : '';
153 387 $merged_attributes['max'] = ! empty( $question['maxTime'] ) ? sanitize_text_field( $question['maxTime'] ) : '';
154 388 }
155 - if ( 'rating' === $question['fieldType'] ) {
389 + if ( 'rating' === $field_type ) {
156 390 $merged_attributes['iconShape'] = ! empty( $question['iconShape'] ) ? sanitize_text_field( $question['iconShape'] ) : 'star';
157 391 $merged_attributes['showText'] = ! empty( $question['showTooltip'] ) ? filter_var( $question['showTooltip'], FILTER_VALIDATE_BOOLEAN ) : false;
158 392 $merged_attributes['defaultRating'] = ! empty( $question['defaultRating'] ) ? filter_var( $question['defaultRating'], FILTER_VALIDATE_INT ) : 0;
159 393
@@ -159,9 +393,9 @@
159 393
160 394 if ( ! empty( $merged_attributes['showText'] ) ) {
161 395 foreach ( $question['tooltipValues'] as $tooltips ) {
162 396 $i = 0;
163 - foreach ( $tooltips as $key => $value ) {
397 + foreach ( $tooltips as $value ) {
164 398 $merged_attributes['ratingText'][ $i ] = ! empty( $value ) ? sanitize_text_field( $value ) : '';
165 399 $i++;
166 400 }
167 401 }
@@ -166,9 +400,9 @@
166 400 }
167 401 }
168 402 }
169 403 }
170 - if ( 'upload' === $question['fieldType'] ) {
404 + if ( 'upload' === $field_type ) {
171 405 if ( ! empty( $question['allowedTypes'] ) ) {
172 406 $allowed_types = str_replace( '.', '', $question['allowedTypes'] );
173 407
174 408 $allowed_types = explode( ',', $allowed_types );
@@ -173,12 +407,12 @@
173 407
174 408 $allowed_types = explode( ',', $allowed_types );
175 409
176 410 $types_array = array_map(
177 - function( $type ) {
411 + static function( $type ) {
178 412 return [
179 - 'value' => $type,
180 - 'label' => $type,
413 + 'value' => trim( $type ),
414 + 'label' => trim( $type ),
181 415 ];
182 416 },
183 417 $allowed_types
184 418 );
@@ -216,16 +450,48 @@
216 450 $merged_attributes['maxFiles'] = ! empty( $question['multiFilesNumber'] ) ? filter_var( $question['multiFilesNumber'], FILTER_VALIDATE_INT ) : 2;
217 451
218 452 }
219 453
220 - $post_content .= '<!-- wp:srfm/' . $question['fieldType'] . ' ' . wp_json_encode( $merged_attributes ) . ' /-->' . PHP_EOL;
454 + $post_content .= '<!-- wp:srfm/' . $field_type . ' ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
221 455 break;
222 456 default:
223 457 // Unsupported field type - fallback to input.
224 - $post_content .= '<!-- wp:srfm/input ' . wp_json_encode( $merged_attributes ) . ' /-->' . PHP_EOL;
458 + $post_content .= '<!-- wp:srfm/input ' . Helper::encode_json( $merged_attributes ) . ' /-->' . PHP_EOL;
225 459 }
226 460 }
227 461
228 - return $post_content;
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;
229 495 }
230 496
231 497 }