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 / Common / Helpers / Import / WsFormNameFieldExtractor.php

WsFormNameFieldExtractor.php in The Innovative Form Builder – IvyForms 1.4, at backend/src/Common/Helpers/Import/WsFormNameFieldExtractor.php

256 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace IvyForms\Common\Helpers\Import;
4
5 use IvyForms\Services\Translations\BackendStrings;
6
7 /**
8 * Merge adjacent WS Form text fields into an IvyForms composite name field.
9 *
10 * WS Form has no name element; forms collect names through separate text fields.
11 * IvyForms models a name as a parent `name` field plus `text` sub-fields, so the
12 * parts must be detected and merged during import.
13 *
14 * Detection prefers the explicit `autocomplete` token and falls back to exact
15 * label matching. Both signals are deliberately strict: a run is only merged
16 * when it holds a first and a last part, so unrelated text fields keep their
17 * original shape rather than being collapsed on a guess.
18 */
19 class WsFormNameFieldExtractor
20 {
21 /**
22 * HTML autocomplete tokens to IvyForms name sub-field types.
23 *
24 * @var array<string, string>
25 */
26 private const AUTOCOMPLETE_MAP = [
27 'given-name' => 'nameField1',
28 'family-name' => 'nameField2',
29 'additional-name' => 'nameField3',
30 ];
31
32 /**
33 * Autocomplete values that carry no field-purpose information.
34 *
35 * @var array<int, string>
36 */
37 private const NEUTRAL_AUTOCOMPLETE = ['', 'on', 'off'];
38
39 /**
40 * Normalized labels to IvyForms name sub-field types.
41 *
42 * Matching is exact so labels such as "Company name" or "Product name" are
43 * never treated as name parts.
44 *
45 * @var array<string, string>
46 */
47 private const LABEL_MAP = [
48 'first name' => 'nameField1',
49 'firstname' => 'nameField1',
50 'given name' => 'nameField1',
51 'given names' => 'nameField1',
52 'forename' => 'nameField1',
53 'first' => 'nameField1',
54 'last name' => 'nameField2',
55 'lastname' => 'nameField2',
56 'family name' => 'nameField2',
57 'surname' => 'nameField2',
58 'last' => 'nameField2',
59 'middle name' => 'nameField3',
60 'middlename' => 'nameField3',
61 'middle initial' => 'nameField3',
62 'middle' => 'nameField3',
63 ];
64
65 /**
66 * Collect the run of adjacent WS Form fields that together form a name.
67 *
68 * Returns null when the fields starting at $startIndex are not a name group.
69 *
70 * @param array<int, array<string, mixed>> $wsFields
71 * @param int $startIndex
72 * @return array<int, array<string, mixed>>|null
73 */
74 public static function collectRun(array $wsFields, int $startIndex): ?array
75 {
76 $total = count($wsFields);
77 $run = [];
78 $usedTypes = [];
79
80 for ($index = $startIndex; $index < $total; $index++) {
81 $nameFieldType = self::resolveNameFieldType($wsFields[$index]);
82 if ($nameFieldType === null || isset($usedTypes[$nameFieldType])) {
83 break;
84 }
85
86 $usedTypes[$nameFieldType] = true;
87 $run[] = [
88 'nameFieldType' => $nameFieldType,
89 'wsField' => $wsFields[$index],
90 ];
91 }
92
93 if (!isset($usedTypes['nameField1'], $usedTypes['nameField2'])) {
94 return null;
95 }
96
97 return $run;
98 }
99
100 /**
101 * Append a composite name field (parent plus sub-fields) for a detected run.
102 *
103 * @param array<int, array<string, mixed>> $run
104 * @param array<int, array<string, mixed>> $fields
105 * @param array<string, int> $context
106 * @return void
107 */
108 public static function append(array $run, array &$fields, array &$context): void
109 {
110 $parentIndex = $context['fieldIndex']++;
111 $fields[] = [
112 'id' => $context['id']++,
113 'fieldIndex' => $parentIndex,
114 'type' => 'name',
115 'label' => self::parentLabel(),
116 'placeholder' => '',
117 'required' => self::runHasRequiredPart($run),
118 'defaultValue' => '',
119 'parentId' => null,
120 'position' => $context['position']++,
121 'rowIndex' => $context['rowIndex']++,
122 'columnIndex' => 0,
123 'width' => 100,
124 ];
125
126 $subPosition = 1;
127 foreach ($run as $part) {
128 $fields[] = self::buildSubField($part, $parentIndex, $subPosition, $context);
129 $subPosition++;
130 }
131 }
132
133 /**
134 * Whether any part of the run is required.
135 *
136 * @param array<int, array<string, mixed>> $run
137 * @return bool
138 */
139 private static function runHasRequiredPart(array $run): bool
140 {
141 foreach ($run as $part) {
142 $wsField = is_array($part['wsField'] ?? null) ? $part['wsField'] : [];
143 if (self::isRequired($wsField)) {
144 return true;
145 }
146 }
147
148 return false;
149 }
150
151 /**
152 * Build a single name sub-field from a WS Form text field.
153 *
154 * @param array<string, mixed> $part
155 * @param int $parentIndex
156 * @param int $subPosition
157 * @param array<string, int> $context
158 * @return array<string, mixed>
159 */
160 private static function buildSubField(
161 array $part,
162 int $parentIndex,
163 int $subPosition,
164 array &$context
165 ): array {
166 $wsField = is_array($part['wsField'] ?? null) ? $part['wsField'] : [];
167 $meta = is_array($wsField['meta'] ?? null) ? $wsField['meta'] : [];
168 $nameFieldType = (string) ($part['nameFieldType'] ?? '');
169
170 return [
171 'id' => $context['id']++,
172 'fieldIndex' => $parentIndex,
173 'type' => 'text',
174 'label' => sanitize_text_field((string) ($wsField['label'] ?? '')),
175 'placeholder' => sanitize_text_field((string) ($meta['placeholder'] ?? '')),
176 'description' => sanitize_text_field((string) ($meta['help'] ?? '')),
177 'required' => self::isRequired($wsField),
178 'requiredMessage' => sanitize_text_field((string) ($meta['invalid_feedback'] ?? '')),
179 'defaultValue' => sanitize_text_field((string) ($meta['default_value'] ?? '')),
180 'parentId' => $parentIndex,
181 'position' => $subPosition,
182 'settings' => wp_json_encode(['nameFieldType' => $nameFieldType]) ?: '',
183 ];
184 }
185
186 /**
187 * Resolve the IvyForms name sub-field type of a WS Form field.
188 *
189 * @param array<string, mixed> $wsField
190 * @return string|null
191 */
192 private static function resolveNameFieldType(array $wsField): ?string
193 {
194 if ((string) ($wsField['type'] ?? '') !== 'text') {
195 return null;
196 }
197
198 $meta = is_array($wsField['meta'] ?? null) ? $wsField['meta'] : [];
199 $autocomplete = strtolower(trim((string) ($meta['autocomplete'] ?? '')));
200
201 if (isset(self::AUTOCOMPLETE_MAP[$autocomplete])) {
202 return self::AUTOCOMPLETE_MAP[$autocomplete];
203 }
204
205 // An autocomplete token for a different purpose (organization, email, …)
206 // rules the field out even when its label looks like a name part.
207 if (!in_array($autocomplete, self::NEUTRAL_AUTOCOMPLETE, true)) {
208 return null;
209 }
210
211 $label = self::normalizeLabel((string) ($wsField['label'] ?? ''));
212
213 return self::LABEL_MAP[$label] ?? null;
214 }
215
216 /**
217 * Normalize a WS Form label for exact matching.
218 *
219 * @param string $label
220 * @return string
221 */
222 private static function normalizeLabel(string $label): string
223 {
224 $label = strtolower(wp_strip_all_tags($label));
225 $label = str_replace(['*', ':'], ' ', $label);
226 $label = preg_replace('/\s+/', ' ', $label);
227
228 return trim((string) $label);
229 }
230
231 /**
232 * Whether a WS Form field is marked required.
233 *
234 * @param array<string, mixed> $wsField
235 * @return bool
236 */
237 private static function isRequired(array $wsField): bool
238 {
239 $meta = is_array($wsField['meta'] ?? null) ? $wsField['meta'] : [];
240
241 return ($meta['required'] ?? '') === 'on';
242 }
243
244 /**
245 * Resolve the label for the generated parent name field.
246 *
247 * @return string
248 */
249 private static function parentLabel(): string
250 {
251 $strings = BackendStrings::getNewFormStrings();
252
253 return sanitize_text_field((string) ($strings['name'] ?? __('Name', 'ivyforms')));
254 }
255 }
256