PluginProbe
The Innovative Form Builder – IvyForms / 1.4
The Innovative Form Builder – IvyForms v1.4
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / Ai / AiFormSchemaNormalizer.php

AiFormSchemaNormalizer.php in The Innovative Form Builder – IvyForms 1.4, at backend/src/Services/Ai/AiFormSchemaNormalizer.php

426 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See LICENCE.md for license details.
6 */
7
8 namespace IvyForms\Services\Ai;
9
10 use IvyForms\Common\Sanitizer\Sanitizer;
11 use IvyForms\Services\Field\FieldType;
12 use IvyForms\Services\Template\TemplateDefaults;
13 use IvyForms\Services\Translations\BackendStrings;
14
15 /**
16 * Normalizes AI-generated form JSON into a create-ready IvyForms payload.
17 */
18 class AiFormSchemaNormalizer
19 {
20 /**
21 * Default AI-friendly field types (lite). Pro can extend via
22 * `ivyforms/ai/allowed_field_types`. Final list is intersected with
23 * FieldType::getAllowedTypes() so license/plan gates still apply.
24 */
25 private const BASE_AI_FIELD_TYPES = [
26 'text',
27 'textarea',
28 'email',
29 'phone',
30 'number',
31 'website',
32 'password',
33 'select',
34 'multi-select',
35 'radio',
36 'checkbox',
37 'date',
38 'time',
39 'html',
40 'gdpr',
41 'rating',
42 'slider',
43 'name',
44 'address',
45 'file-upload',
46 ];
47
48 private const OPTION_FIELD_TYPES = [
49 'select',
50 'multi-select',
51 'radio',
52 'checkbox',
53 ];
54
55 private const COMPOUND_FIELD_TYPES = [
56 'name',
57 'address',
58 'likert',
59 ];
60
61 private AiFormCompoundFieldExpander $compoundExpander;
62 private AiFormMultipageNormalizer $multipageNormalizer;
63
64 public function __construct(
65 ?AiFormCompoundFieldExpander $compoundExpander = null,
66 ?AiFormMultipageNormalizer $multipageNormalizer = null
67 ) {
68 $this->compoundExpander = $compoundExpander ?? new AiFormCompoundFieldExpander();
69 $this->multipageNormalizer = $multipageNormalizer ?? new AiFormMultipageNormalizer();
70 }
71
72 /**
73 * Field types the AI may emit for the current site (after Pro/plan filters).
74 *
75 * @return list<string>
76 */
77 public function getAllowedFieldTypes(): array
78 {
79 $types = self::BASE_AI_FIELD_TYPES;
80
81 /**
82 * Extend or restrict field types the AI may generate.
83 *
84 * @since 0.1.0
85 *
86 * @param string[] $types Candidate AI field type strings.
87 * @return string[]
88 */
89 $filtered = apply_filters('ivyforms/ai/allowed_field_types', $types);
90
91 if (!is_array($filtered)) {
92 $filtered = $types;
93 }
94
95 $normalized = [];
96 foreach ($filtered as $type) {
97 if (is_string($type) && $type !== '') {
98 $normalized[sanitize_key($type)] = true;
99 }
100 }
101
102 $allowedToSave = array_fill_keys(FieldType::getAllowedTypes(), true);
103
104 return array_values(array_filter(
105 array_keys($normalized),
106 static function (string $type) use ($allowedToSave): bool {
107 return isset($allowedToSave[$type]);
108 }
109 ));
110 }
111
112 /**
113 * @param array<string, mixed> $raw
114 *
115 * @return array<string, mixed>
116 */
117 public function normalize(array $raw): array
118 {
119 $name = sanitize_text_field((string) ($raw['name'] ?? ''));
120 if ($name === '') {
121 $name = BackendStrings::getSettingsFormBuilderStrings()['blank_form'];
122 }
123
124 $description = sanitize_textarea_field((string) ($raw['description'] ?? ''));
125 $fieldsInput = $raw['fields'] ?? [];
126 if (!is_array($fieldsInput)) {
127 $fieldsInput = [];
128 }
129
130 $fields = $this->normalizeFields($fieldsInput);
131 if ($fields === []) {
132 $fields = [$this->fallbackTextField()];
133 }
134
135 $formType = Sanitizer::sanitizeFormType($raw['formType'] ?? 'classic');
136 $multipage = $this->multipageNormalizer->normalizePages($raw, $formType);
137 $pages = $multipage !== null ? $multipage['pages'] : null;
138 $fields = $this->applyMultipageToFields($fields, $multipage);
139
140 $formData = [
141 'name' => $name,
142 'description' => $description,
143 'published' => 1,
144 'showTitle' => 1,
145 'showDescription' => $description !== '' ? 1 : 0,
146 'storeEntries' => 1,
147 'formType' => $formType,
148 'integrationSettings' => TemplateDefaults::getDefaultIntegrationSettings(),
149 'fields' => $fields,
150 ];
151
152 if ($pages !== null) {
153 $formData['pages'] = $pages;
154 $formData['progressIndicator'] = [
155 'type' => 'progress-bar',
156 'showPageTitles' => true,
157 'hidePageNumbers' => false,
158 'hideConnectingLines' => false,
159 ];
160 }
161
162 /**
163 * Mutate the normalized AI form payload before sanitize/create.
164 *
165 * @since 0.1.0
166 *
167 * @param array<string, mixed> $formData Normalized form data.
168 * @param array<string, mixed> $raw Raw AI JSON.
169 * @return array<string, mixed>
170 */
171 $filtered = apply_filters('ivyforms/ai/normalized_form_data', $formData, $raw);
172 if (!is_array($filtered)) {
173 return $formData;
174 }
175
176 return $filtered;
177 }
178
179 /**
180 * @param array<int, mixed> $fieldsInput
181 *
182 * @return list<array<string, mixed>>
183 */
184 private function normalizeFields(array $fieldsInput): array
185 {
186 $allowedTypes = $this->getAllowedFieldTypes();
187 $fields = [];
188 $nextId = 1;
189 $position = 1;
190
191 foreach ($fieldsInput as $fieldInput) {
192 if (!is_array($fieldInput)) {
193 continue;
194 }
195
196 $type = sanitize_key((string) ($fieldInput['type'] ?? 'text'));
197 if (!in_array($type, $allowedTypes, true)) {
198 continue;
199 }
200
201 $built = $this->buildNormalizedField($fieldInput, $type, $position, $nextId);
202 $fields[] = $built['field'];
203 $nextId = $built['nextId'];
204
205 if (in_array($type, self::COMPOUND_FIELD_TYPES, true)) {
206 $expanded = $this->compoundExpander->expand(
207 $type,
208 $position,
209 !empty($built['field']['required']),
210 $nextId,
211 isset($built['field']['pageId']) ? (string) $built['field']['pageId'] : null
212 );
213 foreach ($expanded['children'] as $child) {
214 $fields[] = $child;
215 }
216 $nextId = $expanded['nextId'];
217 }
218
219 $position++;
220 }
221
222 return $fields;
223 }
224
225 /**
226 * @param array<string, mixed> $fieldInput
227 *
228 * @return array{field: array<string, mixed>, nextId: int}
229 */
230 private function buildNormalizedField(
231 array $fieldInput,
232 string $type,
233 int $position,
234 int $nextId
235 ): array {
236 $label = sanitize_text_field((string) ($fieldInput['label'] ?? ''));
237 if ($label === '') {
238 $label = ucfirst(str_replace('-', ' ', $type));
239 }
240
241 $field = [
242 'id' => $nextId++,
243 'fieldIndex' => $position,
244 'type' => $type,
245 'label' => $label,
246 'required' => !empty($fieldInput['required']),
247 'parentId' => null,
248 'defaultValue' => sanitize_text_field((string) ($fieldInput['defaultValue'] ?? '')),
249 'placeholder' => sanitize_text_field((string) ($fieldInput['placeholder'] ?? '')),
250 'position' => $position,
251 'rowIndex' => $position - 1,
252 'columnIndex' => 0,
253 'width' => 100,
254 'description' => sanitize_textarea_field((string) ($fieldInput['description'] ?? '')),
255 'hideLabel' => !empty($fieldInput['hideLabel']),
256 ];
257
258 $pageId = sanitize_text_field((string) ($fieldInput['pageId'] ?? ''));
259 if ($pageId !== '' && AiAvailability::canUseMultipage()) {
260 $field['pageId'] = $pageId;
261 $field['settings'] = ['pageId' => $pageId];
262 }
263
264 if ($type === 'textarea') {
265 $rows = isset($fieldInput['rows']) ? (int) $fieldInput['rows'] : 3;
266 $field['rows'] = max(2, min($rows, 12));
267 }
268
269 if (in_array($type, self::OPTION_FIELD_TYPES, true)) {
270 $optionsResult = $this->normalizeChoices(
271 $fieldInput['fieldOptions'] ?? [],
272 $nextId
273 );
274 $field['fieldOptions'] = $optionsResult['choices'];
275 $nextId = $optionsResult['nextId'];
276 }
277
278 return [
279 'field' => $field,
280 'nextId' => $nextId,
281 ];
282 }
283
284 /**
285 * Attach validated pageIds when multipage applies; otherwise strip any leaked ones.
286 *
287 * @param list<array<string, mixed>> $fields
288 * @param array{pages: list<array<string, mixed>>, idMap: array<string, string>}|null $multipage
289 *
290 * @return list<array<string, mixed>>
291 */
292 private function applyMultipageToFields(array $fields, ?array $multipage): array
293 {
294 if ($multipage === null) {
295 return $this->stripFieldPageIds($fields);
296 }
297
298 return $this->multipageNormalizer->ensureFieldPageIds(
299 $fields,
300 $multipage['pages'],
301 $multipage['idMap']
302 );
303 }
304
305 /**
306 * Drop pageId from fields when multipage structure was not applied
307 * (disabled entitlement, conversational, or invalid pages payload).
308 *
309 * @param list<array<string, mixed>> $fields
310 *
311 * @return list<array<string, mixed>>
312 */
313 private function stripFieldPageIds(array $fields): array
314 {
315 foreach ($fields as &$field) {
316 unset($field['pageId']);
317 if (!isset($field['settings']) || !is_array($field['settings'])) {
318 continue;
319 }
320
321 unset($field['settings']['pageId']);
322 if ($field['settings'] === []) {
323 unset($field['settings']);
324 }
325 }
326 unset($field);
327
328 return $fields;
329 }
330
331 /**
332 * @return array<string, mixed>
333 */
334 private function fallbackTextField(): array
335 {
336 return [
337 'id' => 1,
338 'fieldIndex' => 1,
339 'type' => 'text',
340 'label' => BackendStrings::getCommonStrings()['text'],
341 'required' => false,
342 'parentId' => null,
343 'defaultValue' => '',
344 'placeholder' => '',
345 'position' => 1,
346 'rowIndex' => 0,
347 'columnIndex' => 0,
348 'width' => 100,
349 'description' => '',
350 'hideLabel' => false,
351 ];
352 }
353
354 /**
355 * @param mixed $optionsInput
356 *
357 * @return array{choices: list<array<string, mixed>>, nextId: int}
358 */
359 private function normalizeChoices($optionsInput, int $nextId): array
360 {
361 if (!is_array($optionsInput)) {
362 $optionsInput = [];
363 }
364
365 $choices = [];
366 $position = 1;
367
368 foreach ($optionsInput as $optionInput) {
369 if (!is_array($optionInput)) {
370 continue;
371 }
372
373 $label = sanitize_text_field((string) ($optionInput['label'] ?? ''));
374 if ($label === '') {
375 continue;
376 }
377
378 $value = sanitize_text_field((string) ($optionInput['value'] ?? ''));
379 if ($value === '') {
380 $value = sanitize_title($label);
381 }
382
383 $choices[] = [
384 'id' => $nextId++,
385 'label' => $label,
386 'value' => $value,
387 'isDefault' => !empty($optionInput['isDefault']),
388 'position' => $position++,
389 ];
390 }
391
392 if ($choices === []) {
393 $choices = $this->defaultChoices($nextId);
394 $nextId += 2;
395 }
396
397 return [
398 'choices' => $choices,
399 'nextId' => $nextId,
400 ];
401 }
402
403 /**
404 * @return list<array<string, mixed>>
405 */
406 private function defaultChoices(int $nextId): array
407 {
408 return [
409 [
410 'id' => $nextId,
411 'label' => 'Option 1',
412 'value' => 'option_1',
413 'isDefault' => false,
414 'position' => 1,
415 ],
416 [
417 'id' => $nextId + 1,
418 'label' => 'Option 2',
419 'value' => 'option_2',
420 'isDefault' => false,
421 'position' => 2,
422 ],
423 ];
424 }
425 }
426