PluginProbe
The Innovative Form Builder – IvyForms / 1.4.1
The Innovative Form Builder – IvyForms v1.4.1
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 / AiFormMultipageNormalizer.php

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

351 lines 9.7 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\Translations\BackendStrings;
12
13 /**
14 * Builds multipage structure and assigns field pageIds for AI-generated forms.
15 */
16 class AiFormMultipageNormalizer
17 {
18 /**
19 * @param array<string, mixed> $raw
20 *
21 * @return array{pages: list<array<string, mixed>>, idMap: array<string, string>}|null
22 */
23 public function normalizePages(array $raw, string $formType): ?array
24 {
25 if ($formType === Sanitizer::FORM_TYPE_CONVERSATIONAL) {
26 return null;
27 }
28
29 if (!AiAvailability::canUseMultipage()) {
30 return null;
31 }
32
33 $pagesInput = $raw['pages'] ?? null;
34 if (!is_array($pagesInput) || count($pagesInput) < 2) {
35 return null;
36 }
37
38 $draftPages = $this->buildDraftPages($pagesInput);
39 if (count($draftPages) < 2) {
40 return null;
41 }
42
43 return $this->finalizePages($draftPages);
44 }
45
46 /**
47 * @param list<array<string, mixed>> $fields
48 * @param list<array<string, mixed>> $pages
49 * @param array<string, string> $idMap AI page id aliases → preserved page id
50 *
51 * @return list<array<string, mixed>>
52 */
53 public function ensureFieldPageIds(array $fields, array $pages, array $idMap = []): array
54 {
55 $validIds = $this->collectPageIds($pages);
56 if ($validIds === []) {
57 return $fields;
58 }
59
60 $fields = $this->remapFieldPageIds($fields, $idMap);
61
62 $defaultPageId = $validIds[0];
63 $parentCount = $this->countParentFields($fields);
64 $perPage = max(1, (int) ceil($parentCount / count($validIds)));
65
66 $pageIdByFieldIndex = $this->assignParentPageIds(
67 $fields,
68 $validIds,
69 $defaultPageId,
70 $perPage
71 );
72
73 return $this->assignChildPageIds($fields, $pageIdByFieldIndex, $validIds, $defaultPageId);
74 }
75
76 /**
77 * @param array<int, mixed> $pagesInput
78 *
79 * @return list<array<string, mixed>>
80 */
81 private function buildDraftPages(array $pagesInput): array
82 {
83 $pages = [];
84 foreach (array_values($pagesInput) as $index => $pageInput) {
85 if (!is_array($pageInput)) {
86 continue;
87 }
88
89 $label = sanitize_text_field((string) ($pageInput['label'] ?? ''));
90 if ($label === '') {
91 $label = sprintf(
92 BackendStrings::getAiStrings()['ai_default_page_label'],
93 $index + 1
94 );
95 }
96
97 $requestedId = sanitize_text_field((string) ($pageInput['id'] ?? ''));
98 $pageId = $requestedId !== '' ? $requestedId : sprintf('page_%d', $index + 1);
99
100 $pages[] = [
101 'id' => $pageId,
102 'label' => $label,
103 ];
104 }
105
106 return $pages;
107 }
108
109 /**
110 * Keep AI-emitted page ids so field pageIds stay aligned. Only rewrite on
111 * collision / empty; Pro sanitize later remaps to page_N for persistence.
112 *
113 * @param list<array<string, mixed>> $draftPages
114 *
115 * @return array{pages: list<array<string, mixed>>, idMap: array<string, string>}
116 */
117 private function finalizePages(array $draftPages): array
118 {
119 $normalized = [];
120 $idMap = [];
121 $usedIds = [];
122
123 foreach ($draftPages as $index => $page) {
124 $oldId = sanitize_text_field((string) ($page['id'] ?? ''));
125 $pageId = $this->allocateUniquePageId($oldId, $index, $usedIds);
126 $usedIds[strtolower($pageId)] = true;
127
128 // Keep $idMap empty on collision rewrites so fields on the first
129 // valid page are not remapped onto the renamed duplicate.
130
131 $normalized[] = [
132 'id' => $pageId,
133 'label' => $page['label'],
134 'closable' => $index > 0,
135 'fields' => [],
136 ];
137 }
138
139 return [
140 'pages' => $normalized,
141 'idMap' => $idMap,
142 ];
143 }
144
145 /**
146 * @param array<string, true> $usedIds lowercase id → true
147 */
148 private function allocateUniquePageId(string $requestedId, int $index, array $usedIds): string
149 {
150 $candidate = $requestedId !== '' ? $requestedId : sprintf('page_%d', $index + 1);
151 if (!isset($usedIds[strtolower($candidate)])) {
152 return $candidate;
153 }
154
155 $fallback = sprintf('page_%d', $index + 1);
156 $unique = isset($usedIds[strtolower($fallback)])
157 ? sprintf('%s_%d', $fallback, $index + 1)
158 : $fallback;
159
160 return $unique;
161 }
162
163 /**
164 * @param list<array<string, mixed>> $fields
165 * @param array<string, string> $idMap
166 *
167 * @return list<array<string, mixed>>
168 */
169 private function remapFieldPageIds(array $fields, array $idMap): array
170 {
171 if ($idMap === []) {
172 return $fields;
173 }
174
175 $lookup = [];
176 foreach ($idMap as $from => $to) {
177 $lookup[strtolower((string) $from)] = $to;
178 }
179
180 foreach ($fields as &$field) {
181 $pageId = sanitize_text_field((string) ($field['pageId'] ?? ''));
182 if ($pageId === '') {
183 continue;
184 }
185
186 $mapped = $lookup[strtolower($pageId)] ?? null;
187 if ($mapped === null) {
188 continue;
189 }
190
191 $this->applyPageId($field, $mapped);
192 }
193 unset($field);
194
195 return $fields;
196 }
197
198 /**
199 * @param list<array<string, mixed>> $pages
200 *
201 * @return list<string>
202 */
203 private function collectPageIds(array $pages): array
204 {
205 $validIds = [];
206 foreach ($pages as $page) {
207 $id = sanitize_text_field((string) ($page['id'] ?? ''));
208 if ($id !== '') {
209 $validIds[] = $id;
210 }
211 }
212
213 return $validIds;
214 }
215
216 /**
217 * @param list<array<string, mixed>> $fields
218 */
219 private function countParentFields(array $fields): int
220 {
221 $parentCount = 0;
222 foreach ($fields as $field) {
223 if (($field['parentId'] ?? null) === null) {
224 $parentCount++;
225 }
226 }
227
228 return $parentCount;
229 }
230
231 /**
232 * @param list<array<string, mixed>> $fields
233 * @param list<string> $validIds
234 *
235 * @return array<int, string>
236 */
237 private function assignParentPageIds(
238 array &$fields,
239 array $validIds,
240 string $defaultPageId,
241 int $perPage
242 ): array {
243 $parentOrdinal = 0;
244 $pageIdByFieldIndex = [];
245
246 foreach ($fields as &$field) {
247 if (($field['parentId'] ?? null) !== null) {
248 continue;
249 }
250
251 $rawPageId = (string) ($field['pageId'] ?? '');
252 $pageId = $rawPageId === ''
253 ? $validIds[(int) min(
254 count($validIds) - 1,
255 floor($parentOrdinal / $perPage)
256 )]
257 : $this->resolveValidPageId($rawPageId, $validIds, $defaultPageId);
258
259 $parentOrdinal++;
260 $fieldIndex = (int) ($field['fieldIndex'] ?? 0);
261 $pageIdByFieldIndex[$fieldIndex] = $pageId;
262 $this->applyPageId($field, $pageId);
263 }
264 unset($field);
265
266 return $pageIdByFieldIndex;
267 }
268
269 /**
270 * @param list<array<string, mixed>> $fields
271 * @param array<int, string> $pageIdByFieldIndex
272 * @param list<string> $validIds
273 *
274 * @return list<array<string, mixed>>
275 */
276 private function assignChildPageIds(
277 array $fields,
278 array $pageIdByFieldIndex,
279 array $validIds,
280 string $defaultPageId
281 ): array {
282 foreach ($fields as &$field) {
283 if (($field['parentId'] ?? null) === null) {
284 continue;
285 }
286
287 $fieldIndex = (int) ($field['fieldIndex'] ?? 0);
288 $pageId = $pageIdByFieldIndex[$fieldIndex]
289 ?? $this->resolveValidPageId(
290 (string) ($field['pageId'] ?? ''),
291 $validIds,
292 $defaultPageId
293 );
294 $this->applyPageId($field, $pageId);
295 }
296 unset($field);
297
298 return $fields;
299 }
300
301 /**
302 * @param array<string, mixed> $field
303 */
304 private function applyPageId(array &$field, string $pageId): void
305 {
306 $field['pageId'] = $pageId;
307 $settings = is_array($field['settings'] ?? null) ? $field['settings'] : [];
308 $settings['pageId'] = $pageId;
309 $field['settings'] = $settings;
310 }
311
312 /**
313 * @param list<string> $validIds
314 */
315 private function resolveValidPageId(string $pageId, array $validIds, string $defaultPageId): string
316 {
317 $pageId = sanitize_text_field($pageId);
318 if ($pageId === '') {
319 return $defaultPageId;
320 }
321
322 $matched = $this->matchPageId($pageId, $validIds);
323 if ($matched !== null) {
324 return $matched;
325 }
326
327 // Last-resort ordinal guess when the model uses page_N casing variants.
328 if (preg_match('/^page_(\d+)$/i', $pageId, $matches) === 1) {
329 $idx = max(0, ((int) $matches[1]) - 1);
330
331 return $validIds[min($idx, count($validIds) - 1)];
332 }
333
334 return $defaultPageId;
335 }
336
337 /**
338 * @param list<string> $validIds
339 */
340 private function matchPageId(string $pageId, array $validIds): ?string
341 {
342 foreach ($validIds as $validId) {
343 if (strcasecmp($pageId, $validId) === 0) {
344 return $validId;
345 }
346 }
347
348 return null;
349 }
350 }
351