PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Core/Util/FieldValueHandler.php +1542 -44 1.4 → V3.3.2 View file →
@@ -1,66 +1,1564 @@
1 1 <?php
2 +
2 3 namespace BitCode\BitForm\Core\Util;
3 4
5 +use BitCode\BitForm\Admin\Form\Helpers;
6 +use BitCode\BitForm\Core\Form\FormManager;
7 +
4 8 final class FieldValueHandler
5 9 {
6 - public static function replaceFieldWithValue($stringToReplaceField, $fieldValues)
7 - {
8 - if (empty($stringToReplaceField)) {
9 - return $stringToReplaceField;
10 + /**
11 + * @param mixed $stringToReplaceField
12 + * @param mixed $fieldValues
13 + * @param mixed $formID
14 + * @param bool $stripShortcodesFromValues
15 + *
16 + * @return string
17 + */
18 + public static function replaceFieldWithValue($stringToReplaceField, $fieldValues, $formID = null, $stripShortcodesFromValues = false)
19 + {
20 + if (empty($stringToReplaceField)) {
21 + return $stringToReplaceField;
22 + }
23 + if (!is_string($stringToReplaceField)) {
24 + $stringToReplaceField = wp_json_encode($stringToReplaceField);
25 + }
26 + $fieldValues = $formID ? self::sortValueBasedOnLayout($formID, $fieldValues) : $fieldValues;
27 +
28 + // Must run on the raw template: after substitution an empty field and an empty template
29 + // are the same empty string.
30 + $stringToReplaceField = self::resolveConditionalBlocks($stringToReplaceField, $fieldValues, $formID);
31 +
32 + if ($formID) {
33 + $stringToReplaceField = self::replaceValueOfBf_all_data($stringToReplaceField, $fieldValues, $formID);
34 + $stringToReplaceField = self::replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID);
35 + }
36 +
37 + $stringToReplaceField = self::replaceSmartTagWithValue($stringToReplaceField);
38 +
39 + $fieldPattern = '/\${\w[^ ${}]*}/';
40 +
41 + preg_match_all($fieldPattern, $stringToReplaceField, $matchedField);
42 + if (empty($matchedField)) {
43 + return $stringToReplaceField;
44 + }
45 + $uniqueFieldsInStr = array_unique($matchedField[0]);
46 + foreach ($uniqueFieldsInStr as $key => $value) {
47 + $fieldName = substr($value, 2, strlen($value) - 3);
48 + $fieldValue = null;
49 + if (isset($fieldValues[$fieldName])) {
50 + $targetFieldValue = isset($fieldValues[$fieldName]['value']) ? $fieldValues[$fieldName]['value'] : $fieldValues[$fieldName];
51 + $targetFieldType = self::fieldTypeForSmartTag($fieldValues[$fieldName], $fieldName, $formID);
52 + if (self::isCompositeFieldType($targetFieldType) && ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue))) {
53 + // Name/Address parts are words of one value: never summed, joined like everywhere else.
54 + $fieldValue = self::joinCompositeFieldValue((array) $targetFieldValue, $targetFieldType);
55 + } elseif ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue)) {
56 + foreach (self::stripMetaSubfields((array) $targetFieldValue) as $singleTargetVal) {
57 + if (isset($fieldValue)) {
58 + if (is_numeric($fieldValue) && is_numeric($singleTargetVal)) {
59 + $fieldValue = $fieldValue + $singleTargetVal;
60 + } else {
61 + $fieldValue = "$fieldValue, $singleTargetVal";
62 + }
63 + } else {
64 + $fieldValue = $singleTargetVal;
65 + }
66 + }
67 + // $fieldValue = wp_json_encode($targetFieldValue);
68 + } else {
69 + $fieldValue = strval($targetFieldValue);
10 70 }
11 - if (!is_string($stringToReplaceField)) {
12 - $stringToReplaceField = wp_json_encode($stringToReplaceField);
71 + // Neutralize shortcodes in low-trust submitted values before they are merged into a
72 + if ($stripShortcodesFromValues && is_string($fieldValue)) {
73 + $fieldValue = strip_shortcodes($fieldValue);
13 74 }
14 - $fieldPattern = '/\${\w[^ ${}]*}/';
15 - preg_match_all($fieldPattern, $stringToReplaceField, $matchedField);
16 - if (empty($matchedField)) {
17 - return $stringToReplaceField;
75 + $stringToReplaceField = str_replace($value, $fieldValue, $stringToReplaceField);
76 + } else {
77 + $stringToReplaceField = str_replace($value, '', $stringToReplaceField);
78 + }
79 + }
80 +
81 + // check if the string is a function like : "${_bf_calc(${b27-5}*10)}"
82 + // TO DO: Implement the function properly
83 + // if (self::isFunction($stringToReplaceField)) {
84 + // $functionName = self::getFunctionName($stringToReplaceField);
85 +
86 + // switch ($functionName) {
87 + // case '_bf_calc':
88 + // return self::getFunctionParameter($stringToReplaceField);
89 + // case '_bf_count':
90 + // return self::getCountValue($stringToReplaceField);
91 + // default:
92 + // return 0;
93 + // }
94 + // }
95 + return $stringToReplaceField;
96 + }
97 +
98 + public static function replaceBackBtnWithPrevPageUrl($stringToReplaceField)
99 + {
100 + $prevPageUrl = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : home_url();
101 + preg_match_all('/\$?\{back_to_view\}/', $stringToReplaceField, $matches);
102 + $matched = $matches[0];
103 +
104 + if (empty($matched) || !$prevPageUrl) {
105 + return $stringToReplaceField;
106 + }
107 +
108 + foreach ($matched as $m) {
109 + $stringToReplaceField = str_replace($m, $prevPageUrl, $stringToReplaceField);
110 + }
111 + return $stringToReplaceField;
112 + }
113 +
114 + /**
115 + * Summary of getCountValue - get the count value from the function string "${_bf_count(item-1, item-2)}" => 2
116 + *
117 + * @param string $functionString
118 + * @return int
119 + */
120 + private static function getCountValue(string $functionString): int
121 + {
122 + $options = self::getFunctionParameter($functionString);
123 + $option = explode(',', $options);
124 + return count($option);
125 + }
126 +
127 + /**
128 + * Summary of getFunctionParameter - get the function parameter from the function string "${_bf_calc(2*10)}" => 2*10
129 + *
130 + * @param string $functionString
131 + * @return string
132 + */
133 + private static function getFunctionParameter(string $functionString): string
134 + {
135 + $regexPattern = '/\(([^)]*)\)/';
136 + preg_match($regexPattern, $functionString, $matches);
137 + return $matches[1];
138 + }
139 +
140 + /**
141 + * Summary of isFunction - check if the string is a function "${_bf_calc(${b27-5}*10)}" or not "${_bf_date}"
142 + *
143 + * @param string $functionString
144 + * @return bool true if the string is a function else false
145 + */
146 + private static function isFunction(string $functionString): bool
147 + {
148 + $regexPattern = '/\([^)]*\)/';
149 + return preg_match($regexPattern, $functionString);
150 + }
151 +
152 + /**
153 + * Summary of getFunctionName - get the function name from the function string "${_bf_calc(${b27-5}*10)}" => _bf_calc
154 + *
155 + * @param string $functionString
156 + * @return string
157 + */
158 + private static function getFunctionName(string $functionString): string
159 + {
160 + $regexPattern = '/\b([a-zA-Z_][a-zA-Z0-9_]*)\(/';
161 + preg_match($regexPattern, $functionString, $matches);
162 + return $matches[1];
163 + }
164 +
165 + public static function validateMailArry($emailAddresses, $fieldValues)
166 + {
167 + if (!is_array($emailAddresses)) {
168 + return [FieldValueHandler::replaceFieldWithValue($emailAddresses, $fieldValues)];
169 + }
170 + foreach ($emailAddresses as $key => $email) {
171 + if (!is_email($email)) {
172 + $email = FieldValueHandler::replaceFieldWithValue($email, $fieldValues);
173 + if (is_email($email)) {
174 + $emailAddresses[$key] = $email;
18 175 }
19 - $uniqueFieldsInStr = array_unique($matchedField[0]);
20 - foreach ($uniqueFieldsInStr as $key => $value) {
21 - $fieldName = substr($value, 2, strlen($value)-3);
22 - $fieldValue = null;
23 - if (isset($fieldValues[$fieldName])) {
24 - $targetFieldValue = isset($fieldValues[$fieldName]['value']) ? $fieldValues[$fieldName]['value'] : $fieldValues[$fieldName];
25 - if (gettype($targetFieldValue) === 'array' || gettype($targetFieldValue) === 'object') {
26 - foreach ((array)$targetFieldValue as $singleTargetVal) {
27 - if (isset($fieldValue)) {
28 - if (is_numeric($fieldValue) && is_numeric($singleTargetVal)) {
29 - $fieldValue = $fieldValue + $singleTargetVal;
30 - } else {
31 - $fieldValue = "$fieldValue, $singleTargetVal";
32 - }
176 + }
177 + }
178 + return $emailAddresses;
179 + }
33 180
34 - } else {
35 - $fieldValue = $singleTargetVal;
36 - }
181 + public static function replaceSmartTagWithValue($contentWithSmartTag)
182 + {
183 + $fieldPattern = '/(\${_[^{]*?)(?=\})}/';
184 + $matchPattern = preg_match_all($fieldPattern, $contentWithSmartTag, $matchedField);
185 + if (!$matchPattern) {
186 + return $contentWithSmartTag;
187 + }
37 188
189 + $ajaxRequest = false;
190 + // Read-only action name check for routing; CSRF verified upstream in the form submission flow via verifySubmissionNonce().
191 + if (isset($_REQUEST['action']) && 'bitforms_trigger_workflow' === sanitize_text_field(wp_unslash($_REQUEST['action']))) {
192 + $ajaxRequest = true;
193 + }
194 +
195 + foreach (array_unique($matchedField[0]) as $value) {
196 + $fieldName = trim(substr($value, 2, strlen($value) - 3));
197 +
198 + $matches = preg_match('/\("*([^\)]+"*)\)/', $value, $matchCustomFormat);
199 +
200 + $customValue = '';
201 + if ($matches) {
202 + $removeQuote = ["'", '"'];
203 + $customValue = str_replace($removeQuote, '', $matchCustomFormat[1]);
204 + $fieldName = str_replace($matchCustomFormat[0], '', $fieldName);
205 + }
206 +
207 + $tagFieldValues = SmartTags::getSmartTagValue($fieldName, $ajaxRequest, $customValue);
208 +
209 + $contentWithSmartTag = str_replace($value, $tagFieldValues, $contentWithSmartTag);
210 + }
211 + return $contentWithSmartTag;
212 + }
213 +
214 + public static function isEmpty($val)
215 + {
216 + if (empty($val) && !in_array($val, ['0', 0, 0.0], true)) {
217 + return true;
218 + }
219 + return false;
220 + }
221 +
222 + /**
223 + * Whether a resolved value renders as nothing.
224 + *
225 + * Stricter than isEmpty(): whitespace-only is blank (some smart-tag resolvers return a
226 + * single space), so is an all-blank array. `0` / `'0'` never are.
227 + *
228 + * @param mixed $val
229 + *
230 + * @return bool
231 + */
232 + public static function isBlank($val)
233 + {
234 + if (null === $val || false === $val) {
235 + return true;
236 + }
237 + if (is_object($val)) {
238 + $val = (array) $val;
239 + }
240 + if (is_array($val)) {
241 + foreach ($val as $key => $item) {
242 + // Composite meta sub-values (_latitude, …) never render on their own.
243 + if (is_string($key) && 0 === strpos($key, '_')) {
244 + continue;
245 + }
246 + if (!self::isBlank($item)) {
247 + return false;
248 + }
249 + }
250 + return true;
251 + }
252 + if (!is_scalar($val)) {
253 + return true;
254 + }
255 +
256 + return '' === trim(str_replace("\xc2\xa0", '', (string) $val));
257 + }
258 +
259 + /**
260 + * Resolve `${bf_if:…}` … `${bf_endif}` template blocks.
261 + *
262 + * Syntax, operators and traps: docs/template-conditional-blocks.md.
263 + *
264 + * @param string $content
265 + * @param array $fieldValues
266 + * @param mixed $formID needed to reach repeater rows; without it a child key reads blank
267 + *
268 + * @return string
269 + */
270 + private static function resolveConditionalBlocks($content, $fieldValues, $formID = null)
271 + {
272 + if (false === strpos($content, '${bf_if') && false === strpos($content, '${bf_unless')) {
273 + return self::stripConditionalBlockTags($content);
274 + }
275 +
276 + $conditionValues = $fieldValues;
277 + if ($formID) {
278 + $formManager = FormManager::getInstance($formID);
279 + // Repeater children have no top-level key; flatten the rows in, real values still win.
280 + $conditionValues = array_merge(self::restructureRepeaterData($fieldValues, $formManager), $fieldValues);
281 + }
282 +
283 + // Matches a block whose body holds no further opener, i.e. the innermost one.
284 + $innerMost = '/\$\{bf_(if|if_any|if_all|unless):([^{}$]*)\}((?:(?!\$\{bf_(?:if|if_any|if_all|unless):)[\s\S])*?)\$\{bf_endif\}/';
285 +
286 + // Bounded so a malformed template can never spin here.
287 + for ($pass = 0; $pass < 200; $pass++) {
288 + $resolved = preg_replace_callback($innerMost, function ($matches) use ($conditionValues) {
289 + $branches = preg_split('/\$\{bf_else\}/', $matches[3], 2);
290 + $truthy = isset($branches[0]) ? $branches[0] : '';
291 + $falsy = isset($branches[1]) ? $branches[1] : '';
292 +
293 + return self::evaluateBlockCondition($matches[1], $matches[2], $conditionValues) ? $truthy : $falsy;
294 + }, $content, -1, $replacedCount);
295 +
296 + if (null === $resolved) {
297 + break; // preg failure (e.g. backtrack limit): leave the content untouched
298 + }
299 + $content = $resolved;
300 + if (!$replacedCount) {
301 + break;
302 + }
303 + }
304 +
305 + return self::stripConditionalBlockTags($content);
306 + }
307 +
308 + /**
309 + * @param string $type if|if_any|if_all|unless
310 + * @param string $rawKeys comma separated conditions
311 + * @param array $fieldValues
312 + *
313 + * @return bool
314 + */
315 + private static function evaluateBlockCondition($type, $rawKeys, $fieldValues)
316 + {
317 + $conditions = array_filter(array_map('trim', explode(',', (string) $rawKeys)), function ($condition) {
318 + return '' !== $condition;
319 + });
320 + if (empty($conditions)) {
321 + return false;
322 + }
323 +
324 + $results = [];
325 + foreach ($conditions as $condition) {
326 + $results[] = self::conditionHolds($condition, $fieldValues);
327 + }
328 +
329 + if ('if_all' === $type) {
330 + return !in_array(false, $results, true);
331 + }
332 + if ('unless' === $type) {
333 + return !in_array(true, $results, true);
334 + }
335 +
336 + return in_array(true, $results, true);
337 + }
338 +
339 + /**
340 + * `key`, or `key operator value`.
341 + *
342 + * @param string $condition
343 + * @param array $fieldValues
344 + *
345 + * @return bool
346 + */
347 + private static function conditionHolds($condition, $fieldValues)
348 + {
349 + $operators = self::blockOperators();
350 + // Longest name first, or `not_equal` reads as `equal`. Field keys never contain a space.
351 + $pattern = '/^(\S+)\s+(' . implode('|', $operators) . ')(?:\s+([\s\S]*))?$/';
352 +
353 + if (!preg_match($pattern, trim($condition), $parts)) {
354 + return !self::isBlank(self::conditionValue(trim($condition), $fieldValues));
355 + }
356 +
357 + $value = self::conditionValue($parts[1], $fieldValues);
358 + $operator = $parts[2];
359 + $expected = isset($parts[3]) ? trim($parts[3]) : '';
360 +
361 + if ('null' === $operator) {
362 + return self::isBlank($value);
363 + }
364 + if ('not_null' === $operator) {
365 + return !self::isBlank($value);
366 + }
367 +
368 + // Multi-value fields and repeater children arrive as a list.
369 + $candidates = is_array($value) || is_object($value) ? self::stripMetaSubfields((array) $value) : [$value];
370 + $negated = in_array($operator, ['not_equal', 'not_contain'], true);
371 + foreach ($candidates as $candidate) {
372 + if (is_array($candidate) || is_object($candidate)) {
373 + continue;
374 + }
375 + // compareValue answers the positive form, so one match settles either case: it satisfies
376 + // `contain` and rules out `not_contain`.
377 + if (self::compareValue($operator, (string) $candidate, $expected)) {
378 + return !$negated;
379 + }
380 + }
381 +
382 + return $negated;
383 + }
384 +
385 + /**
386 + * @return string[] operator names, longest first
387 + */
388 + private static function blockOperators()
389 + {
390 + return [
391 + 'greater_or_equal',
392 + 'less_or_equal',
393 + 'not_contain',
394 + 'start_with',
395 + 'not_equal',
396 + 'not_null',
397 + 'end_with',
398 + 'contain',
399 + 'greater',
400 + 'equal',
401 + 'less',
402 + 'null',
403 + ];
404 + }
405 +
406 + /**
407 + * @param string $key field key, or a `_bf_*` smart tag
408 + * @param array $fieldValues
409 + *
410 + * @return mixed
411 + */
412 + private static function conditionValue($key, $fieldValues)
413 + {
414 + if (0 === strpos($key, '_')) {
415 + return SmartTags::getSmartTagValue($key, false, '');
416 + }
417 + $value = isset($fieldValues[$key]) ? $fieldValues[$key] : null;
418 + if (is_array($value) && isset($value['value'])) {
419 + $value = $value['value'];
420 + }
421 +
422 + return $value;
423 + }
424 +
425 + /**
426 + * @param string $operator
427 + * @param string $value the submitted value
428 + * @param string $expected the value written in the template
429 + *
430 + * @return bool
431 + */
432 + private static function compareValue($operator, $value, $expected)
433 + {
434 + switch ($operator) {
435 + case 'equal':
436 + case 'not_equal':
437 + return 0 === strcasecmp(trim($value), $expected);
438 + case 'contain':
439 + case 'not_contain':
440 + return '' !== $expected && false !== stripos($value, $expected);
441 + case 'start_with':
442 + return '' !== $expected && 0 === stripos($value, $expected);
443 + case 'end_with':
444 + return '' !== $expected && 0 === strcasecmp($expected, (string) substr($value, -strlen($expected)));
445 + case 'greater':
446 + return self::isNumericPair($value, $expected) && (float) $value > (float) $expected;
447 + case 'less':
448 + return self::isNumericPair($value, $expected) && (float) $value < (float) $expected;
449 + case 'greater_or_equal':
450 + return self::isNumericPair($value, $expected) && (float) $value >= (float) $expected;
451 + case 'less_or_equal':
452 + return self::isNumericPair($value, $expected) && (float) $value <= (float) $expected;
453 + default:
454 + return false;
455 + }
456 + }
457 +
458 + /**
459 + * @param string $value
460 + * @param string $expected
461 + *
462 + * @return bool both sides compare as numbers
463 + */
464 + private static function isNumericPair($value, $expected)
465 + {
466 + return is_numeric(trim($value)) && is_numeric($expected);
467 + }
468 +
469 + /**
470 + * Drop leftover block tags so an unbalanced template never leaks them into the output.
471 + *
472 + * @param string $content
473 + *
474 + * @return string
475 + */
476 + private static function stripConditionalBlockTags($content)
477 + {
478 + return preg_replace('/\$\{bf_(?:if|if_any|if_all|unless):[^{}$]*\}|\$\{bf_(?:else|endif)\}/', '', $content);
479 + }
480 +
481 + /**
482 + * Values a field kept on an entry edit, posted as `<fieldKey>_old` instead of resubmitted.
483 + *
484 + * @param mixed $postData submitted data, keyed by field key
485 + * @param string $fieldKey
486 + *
487 + * @return array retained values, empty when the field kept nothing
488 + */
489 + public static function retainedOldValues($postData, $fieldKey)
490 + {
491 + if (!is_array($postData) || !isset($postData[$fieldKey . '_old'])) {
492 + return [];
493 + }
494 + return self::flattenOldValues($postData[$fieldKey . '_old']);
495 + }
496 +
497 + private static function flattenOldValues($value)
498 + {
499 + if (is_object($value)) {
500 + $value = (array) $value;
501 + }
502 + if (!is_array($value)) {
503 + if (!is_string($value) && !is_numeric($value)) {
504 + return [];
505 + }
506 + $value = trim((string) $value);
507 + if ('' === $value) {
508 + return [];
509 + }
510 + // A repeater posts one JSON list per row, so a list can arrive nested.
511 + $decoded = json_decode($value, true);
512 + if (!is_array($decoded)) {
513 + $retained = [];
514 + foreach (explode(',', $value) as $item) {
515 + $item = trim($item);
516 + if ('' !== $item) {
517 + $retained[] = $item;
518 + }
519 + }
520 + return $retained;
521 + }
522 + $value = $decoded;
523 + }
524 +
525 + $retained = [];
526 + foreach ($value as $item) {
527 + $retained = array_merge($retained, self::flattenOldValues($item));
528 + }
529 + return $retained;
530 + }
531 +
532 + public static function formatFieldValueForMail($fields, $fieldValues = [])
533 + {
534 + $formattedFldValues = $fieldValues;
535 + $file_upload_types = Helpers::$file_upload_types;
536 + $repeated_array_type_data_fields = Helpers::$repeated_array_type_data_fields;
537 + foreach ($fields as $fldKey => $fldData) {
538 + if (in_array($fldData->typ, $file_upload_types)) {
539 + continue;
540 + }
541 + if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
542 + $value = $fieldValues[$fldKey];
543 + // if (is_array($value)) {
544 + // $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
545 + // } else {
546 + // $formattedFldValues[$fldKey] = htmlspecialchars($value);
547 + // }
548 +
549 + // TODO: this code are temporary commented, need to change and remove the comment
550 +
551 + // if (is_array($value)) {
552 + // $arrValue = '';
553 + // foreach ($value as $v) {
554 + // if (is_array($v)) {
555 + // foreach ($v as $k1 => $v1) {
556 + // if (array_key_exists($k1, $repeaterFieldKey)) {
557 + // $oldValue = $repeaterFieldKey[$k1];
558 + // if (is_array($v1) && in_array($fields->{$k1}->typ, $repeated_array_type_data_fields)) {
559 + // $newValues = '[' . implode(', ', $v1) . '] ';
560 + // if (!preg_match('/\[.*\]/', $oldValue)) {
561 + // $oldValue = '[' . $oldValue . '] ';
562 + // }
563 + // } else {
564 + // $newValues = $v1;
565 + // }
566 + // $repeaterFieldKey[$k1] = $oldValue . ', ' . $newValues;
567 + // } else {
568 + // if (!empty($v1) && is_array($v1)) {
569 + // $repeaterFieldKey[$k1] = htmlspecialchars(implode(', ', $v1));
570 + // } else {
571 + // $repeaterFieldKey[$k1] = htmlspecialchars($v1);
572 + // }
573 + // }
574 + // }
575 + // } else {
576 + // $arrValue .= $v . ', ';
577 + // }
578 + // }
579 + // $formattedFldValues[$fldKey] = htmlspecialchars(rtrim($arrValue, ', '));
580 + // $arrValue = '';
581 + // } else {
582 + // $formattedFldValues[$fldKey] = htmlspecialchars($value);
583 + // }
584 + if ('textarea' === $fldData->typ) {
585 + $formattedFldValues[$fldKey] = nl2br(htmlspecialchars($value));
586 + }
587 + if ('date' === $fldData->typ && !empty($value)) {
588 + $formattedFldValues[$fldKey] = date_i18n(get_option('date_format'), strtotime(htmlspecialchars($value)));
589 + }
590 + }
591 + }
592 +
593 + $merge_values = array_merge($fieldValues, $formattedFldValues);
594 + // $merge_values = array_merge($merge_values, $repeaterFieldKey);
595 +
596 + return $merge_values;
597 + }
598 +
599 + public static function changeHrefPathInHTMLString($html_body, $path)
600 + {
601 + if (empty($html_body) || empty($path)) {
602 + return $html_body;
603 + }
604 +
605 + return preg_replace_callback(
606 + '/<a\s+[^>]*href=[\'"]([^\'"]+)[\'"][^>]*>/i',
607 + function ($matches) use ($path) {
608 + $href = $matches[1];
609 +
610 + if (filter_var($href, FILTER_VALIDATE_URL)) {
611 + return $matches[0];
612 + }
613 +
614 + if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $href)) {
615 + return $matches[0];
616 + }
617 + if (preg_match('/\$?\{back_to_view\}/', $matches[0])) {
618 + return $matches[0];
619 + }
620 +
621 + $fullPath = rtrim($path, '/') . '/' . ltrim($href, '/');
622 +
623 + return str_replace(
624 + $href,
625 + htmlspecialchars($fullPath, ENT_QUOTES),
626 + $matches[0]
627 + );
628 + },
629 + $html_body
630 + );
631 + }
632 +
633 + public static function changeImagePathInHTMLString($html_body, $path)
634 + {
635 + if (empty($html_body) || empty($path)) {
636 + return $html_body;
637 + }
638 +
639 + $allowedMimeTypes = [
640 + 'jpg' => ['image/jpeg', 'image/pjpeg'],
641 + 'jpeg' => ['image/jpeg', 'image/pjpeg'],
642 + 'png' => ['image/png'],
643 + 'svg' => ['image/svg+xml']
644 + ];
645 +
646 + return preg_replace_callback(
647 + '/<img\s+[^>]*src=[\'"]([^\'"]*)[\'"][^>]*>/i',
648 + function ($matches) use ($path, $allowedMimeTypes) {
649 + $src = $matches[1];
650 +
651 + // Already-embedded inline images (cid:) must be left untouched.
652 + if (0 === stripos($src, 'cid:')) {
653 + return $matches[0];
654 + }
655 +
656 + if (filter_var($src, FILTER_VALIDATE_URL)) {
657 + return $matches[0];
658 + }
659 +
660 + if (!trim($src)) {
661 + return '';
662 + }
663 +
664 + $fullPath = rtrim($path, '/') . '/' . ltrim($src, '/');
665 +
666 + $extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
667 +
668 + if (!preg_match('/^(http|https):\/\//', $fullPath)) {
669 + if (!file_exists($fullPath) || !isset($allowedMimeTypes[$extension])) {
670 + Log::debug_log([
671 + 'status' => 'error',
672 + 'code' => 'file_not_found',
673 + 'message' => "File not found or unsupported file type: $fullPath",
674 + ]);
675 + return '';
676 + } else {
677 + Log::debug_log([
678 + 'status' => 'success',
679 + 'code' => 'file_found',
680 + 'message' => "File Found => location: {$fullPath}"
681 + ]);
682 + }
683 + $mimeType = mime_content_type($fullPath);
684 + if (!in_array($mimeType, $allowedMimeTypes[$extension], true)) {
685 + Log::debug_log([
686 + 'status' => 'error',
687 + 'code' => 'unsupported_file_type',
688 + 'message' => "Unsupported file type: $mimeType for file: $fullPath",
689 + ]);
690 + return '';
691 + }
692 + }
693 +
694 + return str_replace($src, htmlspecialchars($fullPath, ENT_QUOTES), $matches[0]);
695 + },
696 + $html_body
697 + );
698 + }
699 +
700 + public static function sortValueBasedOnLayout($formId, $fieldValues)
701 + {
702 + $formManager = FormManager::getInstance($formId);
703 + $formLayout = $formManager->getFlatenFormLayout(); // returns all layouts (lg, md, sm)
704 + // A form saved without a layout (or a minimal/legacy form_content) has no ->lg
705 + $lgLayout = isset($formLayout->lg) ? (array) $formLayout->lg : [];
706 + $fieldKeyOrderbasedOnLayout = array_filter(array_map(function ($fld) {
707 + return isset($fld->i) ? $fld->i : null;
708 + }, $lgLayout), function ($key) {
709 + return !is_null($key);
710 + });
711 + $ordered = [];
712 +
713 + foreach ($fieldKeyOrderbasedOnLayout as $key) {
714 + if (array_key_exists($key, $fieldValues)) {
715 + $ordered[$key] = $fieldValues[$key];
716 + }
717 + }
718 +
719 + foreach ($fieldValues as $k=>$v) {
720 + if (!array_key_exists($k, $fieldKeyOrderbasedOnLayout)) {
721 + $ordered[$k] = $fieldValues[$k];
722 + }
723 + }
724 + return $ordered;
725 + }
726 +
727 + public static function replaceValueOfBf_all_data($stringToReplaceField, $fieldValues, $formId)
728 + {
729 + // $pattern = '/\$\{bf_all_data\}/'; // Corrected escaping
730 + $pattern = '/\$\{bf_all_data(?:\.onlyValues)?\}/'; // Corrected escaping
731 +
732 + preg_match_all($pattern, $stringToReplaceField, $matches);
733 + $matchesArray = $matches[0] ?? [];
734 + if (count($matchesArray) > 0) {
735 + $formManager = FormManager::getInstance($formId);
736 + $formFields = $formManager->getFields();
737 + $orderedFormFields = $formManager->getFieldsBasedOnLayout(); // ordered form fields based on layout(lg) order
738 + foreach ($matchesArray as $match) {
739 + // Each tag binds from the untouched submitted values: reusing a filtered result would
740 + // let the first tag in a template starve the second.
741 + switch ($match) {
742 + case '${bf_all_data}':
743 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
744 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
745 + $stringToReplaceField = str_replace('${bf_all_data}', $table, $stringToReplaceField);
746 + break;
747 +
748 + case '${bf_all_data.onlyValues}':
749 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
750 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
751 + $stringToReplaceField = str_replace('${bf_all_data.onlyValues}', $table, $stringToReplaceField);
752 + break;
753 + default:
754 + Log::debug_log([
755 + 'status' => 'error',
756 + 'code' => 'unknown_placeholder',
757 + 'message' => "Unknown placeholder: $match",
758 + ]);
759 + break;
760 + }
761 + }
762 + }
763 + return $stringToReplaceField;
764 + }
765 +
766 + /**
767 + * Ensures an <img> tag with a style attribute exists in the input.
768 + *
769 + * Behavior:
770 + * - If the input is just an image filename (e.g., "1.png"), returns a complete <img> tag with the default style.
771 + * - If the input is HTML with <img> tags:
772 + * - If any <img> has a style, returns the HTML as-is.
773 + * - If <img> exists without style, adds the default style to the first one found.
774 + * - If no <img> tag or image file is found, returns the input unchanged.
775 + *
776 + * @param string $input Image filename or HTML string.
777 + * @param string $defaultStyle Optional. The CSS style to apply if missing. Default: 'max-width: 100%; height: auto;'.
778 + *
779 + * @return string Modified HTML string with styled <img> tag if needed.
780 + */
781 + private static function ensureImgWithStyle($input, $defaultStyle = 'max-width: 100%; height: auto;')
782 + {
783 + $imgTagWithStylePattern = '/<img\b[^>]*\bstyle\s*=\s*["\'][^"\']*["\'][^>]*>/i';
784 + $imgTagPattern = '/<img\b[^>]*>/i';
785 + $filePattern = '/\.(jpg|jpeg|png|gif|webp)$/i';
786 +
787 + if (preg_match($filePattern, trim($input)) && !preg_match('/<img\b/i', $input)) {
788 + return '<img src="' . htmlspecialchars(trim($input)) . '" style="' . $defaultStyle . '" />';
789 + }
790 +
791 + if (preg_match($imgTagWithStylePattern, $input)) {
792 + // <img> already has style, return as is
793 + return $input;
794 + } elseif (preg_match($imgTagPattern, $input, $match)) {
795 + // <img> without style, add style
796 + $updatedImg = preg_replace('/<img\b(.*?)(\/?)>/i', '<img$1 style="' . $defaultStyle . '" $2>', $match[0]);
797 + return str_replace($match[0], $updatedImg, $input);
798 + } else {
799 + // No <img> tag found, return input
800 + return $input;
801 + }
802 + }
803 +
804 + private static function orderRepeaterData($repeaterFldKey, $repeaterData, $formId)
805 + {
806 + if (!$formId) {
807 + return $repeaterData;
808 + }
809 +
810 + $formManager = FormManager::getInstance($formId);
811 + $nestedLayout = $formManager->getFormNestedLayout();
812 + $repeaterLayout = $nestedLayout->{$repeaterFldKey}->lg;
813 + $orderedFldKey = array_map(function ($fld) {
814 + return $fld->i;
815 + }, $repeaterLayout);
816 +
817 + $orderedRepeaterData = [];
818 + foreach ($repeaterData as $rptr) {
819 + $orderedFlds = [];
820 + foreach ($orderedFldKey as $k) {
821 + if (array_key_exists($k, $rptr)) {
822 + $orderedFlds[$k] = $rptr[$k];
823 + }
824 + }
825 +
826 + foreach ($rptr as $ky => $v) {
827 + if (!array_key_exists($ky, $orderedFldKey)) {
828 + $orderedFlds[$ky] = $v;
829 + }
830 + }
831 +
832 + $orderedRepeaterData[] = $orderedFlds;
833 + }
834 + return $orderedRepeaterData;
835 + }
836 +
837 + private static function bindFormData($formFields, $formData, $formId, $isOnlyValues = false)
838 + {
839 + $entryID = isset($formData['entry_id']) ? $formData['entry_id'] : null;
840 + return array_reduce(array_keys($formFields), function ($filteredData, $key) use ($formFields, $formData, $isOnlyValues, $formId) {
841 + $field = $formFields[$key];
842 +
843 + $fieldNewData = $filteredData;
844 +
845 + $ignoreFields = ['button', 'recaptcha', 'html', 'divider', 'spacer', 'section', 'turnstile', 'hcaptcha', 'image'];
846 +
847 + $arrayValueFldType = ['check', 'select', 'image-select'];
848 +
849 + if (in_array($field['type'], $ignoreFields)) {
850 + return $fieldNewData;
851 + }
852 +
853 + // Skip processing for hidden or empty fields only when $isOnlyValues is true
854 + if ($isOnlyValues) {
855 + // Blank means empty string, null, or an array with nothing in it (unchecked
856 + // checkbox group, file field with no upload). 0 is a real value.
857 + if (!isset($formData[$key]) || self::isBlank($formData[$key])) {
858 + return $fieldNewData;
859 + }
860 +
861 + if (isset($field['valid']['hide']) && $field['valid']['hide']) {
862 + return $fieldNewData;
863 + }
864 + }
865 +
866 + if (isset($formData[$key]) && !array_key_exists('parentFieldKey', $field)) {
867 + if ('repeater' === $field['type']) {
868 + $repeater_data = is_string($formData[$key]) ? json_decode($formData[$key], true) : $formData[$key];
869 + // ordering repeater field according to nested repeater layout
870 + $repeater_data = self::orderRepeaterData($key, $repeater_data, $formId);
871 + if ($isOnlyValues) {
872 + $repeater_data = array_filter($repeater_data, function ($sub) {
873 + return array_filter($sub, fn ($value) => '' !== $value);
874 + });
875 + }
876 + $fieldNewData[$key] = $repeater_data;
877 + } elseif ('signature' === $field['type']) {
878 + if ('signature-failed.png' !== $formData[$key]) {
879 + $file_path = strpos($formData[$key], '/') ? $formData[$key] : $formData[$key];
880 + $newPath = $file_path;
881 + $fieldNewData[$key] = self::ensureImgWithStyle($newPath, 'max-width: 100%; height: auto;');
882 + }
883 + } elseif (in_array($field['type'], $arrayValueFldType)) {
884 + $v = is_string($formData[$key]) ? json_decode($formData[$key], true) : $formData[$key];
885 + $fieldNewData[$key] = $v && is_array($v) ? implode(', ', $v) : $formData[$key];
886 + } else {
887 + $fieldNewData[$key] = $formData[$key];
888 + }
889 + }
890 +
891 + return $fieldNewData;
892 + }, []);
893 + }
894 +
895 + private static function generateTable($fieldValues, $formFields, $formId = null)
896 + {
897 + if (empty($fieldValues)) {
898 + Log::debug_log([
899 + 'status' => 'error',
900 + 'code' => 'no_fields_found',
901 + 'type' => 'bf_all_data',
902 + 'message' => 'No fields found for bf_all_data',
903 + 'fields' => $fieldValues,
904 + 'formFields' => $formFields,
905 + ]);
906 + return '<p>No data available.</p>';
907 + }
908 +
909 + $table = "<table style='font-family: arial, sans-serif; border-collapse: collapse; width: 100%;'>";
910 +
911 + foreach ($fieldValues as $fk => $value) {
912 + $value = self::decodeIfJson($value);
913 + $fieldName = self::getLabel($formFields, $fk) ?? $fk;
914 + $fieldType = $formFields[$fk]['type'];
915 + $table .= "<tr>
916 + <td style='border: 1px solid #dddddd; text-align: left; padding: 8px; font-weight: bold;'>{$fieldName}</td>
917 + <td style='border: 1px solid #dddddd; text-align: left; padding: 8px;'>";
918 +
919 + if (is_array($value)) {
920 + if ('repeater' === $fieldType) {
921 + $table .= "<table style='width: 100%; border-collapse: collapse;'>";
922 +
923 + $subKeys = self::repeaterColumnKeys($value, $fk, $formId);
924 +
925 + $table .= '<tr>';
926 + foreach ($subKeys as $subKey) {
927 + $subLabel = self::getLabel($formFields, $subKey) ?? $subKey;
928 + $table .= "<th style='border: 1px solid #dddddd; padding: 8px; background-color: #f2f2f2;'>" . $subLabel . '</th>';
929 + }
930 + $table .= '</tr>';
931 +
932 + foreach ($value as $row) {
933 + if (!is_array($row)) {
934 + continue;
935 + }
936 + $table .= '<tr>';
937 + // Walk the shared column list so a row missing a conditionally hidden
938 + // sub-field still lines up with the header.
939 + foreach ($subKeys as $subKey) {
940 + $subValue = array_key_exists($subKey, $row) ? $row[$subKey] : '';
941 + $subFieldType = self::getFldType($subKey, $formFields);
942 + if (is_array($subValue)) {
943 + if (self::isCompositeFieldType($subFieldType)) {
944 + $subValue = self::joinCompositeFieldValue($subValue, $subFieldType);
945 + } else {
946 + $subValue = self::unorderedAnchorListMarkup($subValue);
947 + }
948 +
949 + // $subValue = implode(', ', array_map(function ($v) {
950 + // if (self::isFileTypeValue($v)) {
951 + // return self::anchorMarkup($v);
952 + // // if (self::isImageTypeValue($v)) {
953 + // // return "<img src='{$v}' alt='{$v}' width='250'/>";
954 + // // } else {
955 + // // return "<a href='{$v}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$v}</a>";
956 + // // }
957 + // } else {
958 + // return $v;
959 + // }
960 + // }, $subValue));
961 + } else {
962 + if (self::isFileTypeValue($subValue)) {
963 + if ('signature' === $subFieldType) {
964 + if ('signature-failed.png' === $subValue) {
965 + $subValue = '';
966 + } else {
967 + $subValue = "<img src='{$subValue}' alt='{$subValue}' width='250'/>";
38 968 }
39 - // $fieldValue = wp_json_encode($targetFieldValue);
969 + }
40 970 } else {
41 - $fieldValue = strval($targetFieldValue);
971 + $subValue = $subValue;
42 972 }
43 - $stringToReplaceField = str_replace($value, $fieldValue, $stringToReplaceField);
44 - } else {
45 - $stringToReplaceField = str_replace($value, '', $stringToReplaceField);
973 + }
974 +
975 + $table .= "<td style='border: 1px solid #dddddd; padding: 8px;'>" . $subValue . '</td>';
46 976 }
977 + $table .= '</tr>';
978 + }
979 + $table .= '</table>';
980 + } elseif ('file-up' === $fieldType || 'advanced-file-up' === $fieldType) {
981 + if (is_array($value)) {
982 + $table .= self::unorderedAnchorListMarkup($value);
983 + }
984 + } elseif (self::isCompositeFieldType($fieldType)) {
985 + $table .= self::joinCompositeFieldValue($value, $fieldType);
986 + } elseif ('signature' === $fieldType) {
987 + // A signature arrives here wrapped in a one-item list; the failed-capture
988 + // placeholder renders nothing.
989 + $signature = reset($value);
990 + if (false !== $signature && 'signature-failed.png' !== $signature) {
991 + $table .= self::imgMarkup($signature);
992 + }
47 993 }
48 - return $stringToReplaceField;
994 + } else {
995 + $table .= $value;
996 + }
997 +
998 + $table .= '</td></tr>';
49 999 }
50 1000
51 - public static function validateMailArry($emailAddresses, $fieldValues)
52 - {
53 - if (!is_array($emailAddresses)) {
54 - return [FieldValueHandler::replaceFieldWithValue($emailAddresses, $fieldValues)];
1001 + $table .= '</table>';
1002 +
1003 + return $table;
1004 + }
1005 +
1006 + private static function unorderedAnchorListMarkup($list)
1007 + {
1008 + $ul = "<ul style='list-style-type: none; padding: 0; margin:0'>";
1009 + foreach ($list as $v) {
1010 + $ul .= '<li >' . self::anchorMarkup($v) . '</li>';
1011 + }
1012 + $ul .= '</ul>';
1013 + return $ul;
1014 + }
1015 +
1016 + private static function imgMarkup($filename)
1017 + {
1018 + if (!is_scalar($filename)) {
1019 + return '';
1020 + }
1021 + $filename = (string) $filename;
1022 +
1023 + return "<img src='" . self::escFileHref($filename) . "' alt='" . esc_attr($filename) . "' width='250'/>";
1024 + }
1025 +
1026 + private static function anchorMarkup($filename)
1027 + {
1028 + if (!is_scalar($filename)) {
1029 + return '';
1030 + }
1031 + $filename = (string) $filename;
1032 +
1033 + return "<a href='" . self::escFileHref($filename) . "' rel='noopener noreferrer' target='_blank' style='color:blue'>" . esc_html($filename) . '</a>';
1034 + }
1035 +
1036 + /** Escape a file reference for an href/src. Not esc_url(): it rewrites a bare file name to `http://<name>`. */
1037 + private static function escFileHref($value)
1038 + {
1039 + return esc_attr(wp_kses_bad_protocol($value, wp_allowed_protocols()));
1040 + }
1041 +
1042 + public static function replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID)
1043 + {
1044 + if (!is_string($stringToReplaceField) || empty($stringToReplaceField)) {
1045 + return $stringToReplaceField; // Return as-is if nothing to replace
1046 + }
1047 +
1048 + $formManager = FormManager::getInstance($formID);
1049 + $formFields = $formManager->getFieldsBasedOnLayout(); // ordered form fields based on layout(lg) order
1050 +
1051 + // Find all placeholders like ${field_key} example: ${b27-5}
1052 + preg_match_all('/\$\{(b\d+-\d+)\}/', $stringToReplaceField, $matches);
1053 +
1054 + if (empty($matches[1])) {
1055 + return $stringToReplaceField;
1056 + }
1057 + // Clean field data
1058 + // $dataCleaning = self::removeEmptyValues($fieldValues);
1059 + $flatFieldData = self::restructureRepeaterData($fieldValues, $formManager);
1060 + // generate table for repeater fields
1061 + foreach ($matches[1] as $fk) {
1062 + $repeaterFieldKey = $fk;
1063 + $fieldType = isset($formFields[$repeaterFieldKey]['type']) && !empty($formFields[$repeaterFieldKey]['type']) ? $formFields[$repeaterFieldKey]['type'] : null;
1064 + if ('repeater' === $fieldType) {
1065 + $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey, $formID);
1066 + $stringToReplaceField = str_replace('${' . $fk . '}', $repeaterMarkup, $stringToReplaceField);
1067 + } else {
1068 + if ('signature' === $fieldType) {
1069 + $stringToReplaceField = self::replaceImgTagForRepeatedSignature($stringToReplaceField, $flatFieldData[$repeaterFieldKey], $repeaterFieldKey);
55 1070 }
56 - foreach ($emailAddresses as $key=>$email) {
57 - if (!is_email($email)) {
58 - $email = FieldValueHandler::replaceFieldWithValue($email, $fieldValues);
59 - if (is_email($email)) {
60 - $emailAddresses[$key] = $email;
61 - }
1071 + $repeaterFieldData = self::safeFlatString(
1072 + $flatFieldData[$repeaterFieldKey] ?? '',
1073 + $fieldType,
1074 + $repeaterFieldKey,
1075 + $flatFieldData,
1076 + $formFields
1077 + );
1078 +
1079 + $stringToReplaceField = str_replace('${' . $fk . '}', $repeaterFieldData, $stringToReplaceField);
1080 + }
1081 + }
1082 + return $stringToReplaceField;
1083 + }
1084 +
1085 + private static function replaceImgTagForRepeatedSignature($stringToReplaceField, $repeaterValue, $fldKey)
1086 + {
1087 + $data = self::decodeIfJson($repeaterValue);
1088 + if (!is_string($stringToReplaceField) || empty($stringToReplaceField) || empty($fldKey)) {
1089 + return $stringToReplaceField;
1090 + }
1091 +
1092 + $pattern = '/<img\s+[^>]*src=[\'"]([^\'"]*' . preg_quote($fldKey, '/') . '[^\'"]*)[\'"][^>]*>/i';
1093 +
1094 + if (!preg_match($pattern, $stringToReplaceField)) {
1095 + return $stringToReplaceField;
1096 + }
1097 +
1098 + $values = [];
1099 + $appendValue = function ($value) use (&$values) {
1100 + if (is_array($value)) {
1101 + foreach ($value as $item) {
1102 + if (is_string($item) && '' !== trim($item) && 'signature-failed.png' !== $item) {
1103 + $values[] = $item;
1104 + }
1105 + }
1106 + return;
1107 + }
1108 +
1109 + if (is_string($value) && '' !== trim($value) && 'signature-failed.png' !== $value) {
1110 + $values[] = $value;
1111 + }
1112 + };
1113 +
1114 + $appendValue($data);
1115 +
1116 + if (empty($values)) {
1117 + return preg_replace($pattern, '', $stringToReplaceField);
1118 + }
1119 +
1120 + return preg_replace_callback($pattern, function ($matches) use ($values) {
1121 + $imgTags = array_map(function ($value) use ($matches) {
1122 + $src = htmlspecialchars($value, ENT_QUOTES);
1123 + $alt = htmlspecialchars($value, ENT_QUOTES);
1124 +
1125 + $tag = $matches[0];
1126 + $tag = preg_replace('/\bsrc\s*=\s*([\'"])(.*?)\1/i', 'src="' . $src . '"', $tag);
1127 +
1128 + if (preg_match('/\balt\s*=\s*([\'"])(.*?)\1/i', $tag)) {
1129 + $tag = preg_replace('/\balt\s*=\s*([\'"])(.*?)\1/i', 'alt="' . $alt . '"', $tag);
1130 + } else {
1131 + $tag = preg_replace('/<img\b/i', '<img alt="' . $alt . '"', $tag, 1);
1132 + }
1133 +
1134 + return $tag;
1135 + }, $values);
1136 +
1137 + return implode('', $imgTags);
1138 + }, $stringToReplaceField);
1139 + }
1140 +
1141 + /**
1142 + * Restructures repeater field data to maintain original structure while
1143 + * aggregating nested repeater values into top-level indexed arrays.
1144 + *
1145 + * @param array $data Original field data structure
1146 + * @return array Restructured data with aggregated arrays
1147 + */
1148 + public static function restructureRepeaterData(array $data, $formManagerInstance): array
1149 + {
1150 + $result = $data;
1151 + // topkey === field Key topValue === field value
1152 + foreach ($data as $topKey => $topValue) {
1153 + if ($formManagerInstance->isRepeaterField($topKey)) {
1154 + $topValue = self::decodeIfJson($topValue);
1155 + //assigning the converted value to repeater field
1156 + $result[$topKey] = $topValue;
1157 + // topvalue here is repeater field value;
1158 + // entryIndex repeater field key , entry == repeater field value
1159 + foreach ($topValue as $entryIndex => $entry) {
1160 + if (!is_array($entry)) {
1161 + continue;
1162 + }
1163 + foreach ($entry as $subKey => $subValue) {
1164 + if (!isset($result[$subKey]) || !is_array($result[$subKey])) {
1165 + $result[$subKey] = [];
62 1166 }
1167 + // Handle nested arrays within entries
1168 + $result[$subKey][$entryIndex] = $subValue;
1169 + }
63 1170 }
64 - return $emailAddresses;
1171 + }
65 1172 }
1173 + return $result;
1174 + }
1175 +
1176 + /**
1177 + * Return decoded data if incoming data is stringified and if it's a plain string (e.g "John Doe") it returns the plain string
1178 + *
1179 + * @param mixed $data
1180 + * @return mixed
1181 + */
1182 + private static function decodeIfJson($data)
1183 + {
1184 + if (!is_string($data)) {
1185 + return $data;
1186 + }
1187 +
1188 + $decoded = json_decode($data, true);
1189 +
1190 + return (JSON_ERROR_NONE === json_last_error()) ? $decoded : $data;
1191 + }
1192 +
1193 + /**
1194 + * Safely converts any type of form value(Specially Repeater Field Value) to string.
1195 + *
1196 + * @param mixed $data
1197 + * @param string $fldType
1198 + * @param string|null $fieldKey
1199 + * @param array|null $allFieldData
1200 + * @param array|null $formFields
1201 + * @return string
1202 + */
1203 + public static function safeFlatString($data, $fldType, $fieldKey = null, $allFieldData = null, $formFields = null): string
1204 + {
1205 + $newData = self::decodeIfJson($data);
1206 +
1207 + if (is_array($newData)) {
1208 + if (self::isCompositeFieldType($fldType)) {
1209 + return self::joinCompositeFieldValue($newData, $fldType);
1210 + }
1211 + return implode(', ', array_map(function ($item) use ($fldType, $fieldKey, $allFieldData, $formFields) {
1212 + return is_array($item)
1213 + ? '[' . implode(', ', array_map(function ($itm) use ($fldType) {
1214 + if (in_array($fldType, ['advanced-file-up', 'file-up']) || self::isFileTypeValue($itm)) {
1215 + return self::anchorMarkup($itm);
1216 + // if (self::isImageTypeValue($itm)) {
1217 + // return "<img src='{$itm}' alt='{$itm}' width='250'/>";
1218 + // } else {
1219 + // return "<a href='{$itm}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$itm}</a>";
1220 + // }
1221 + } else {
1222 + return $itm;
1223 + }
1224 + }, $item)) . ']'
1225 + : self::safeFlatString($item, $fldType, $fieldKey, $allFieldData, $formFields);
1226 + }, $newData));
1227 + }
1228 +
1229 + if (is_object($data)) {
1230 + return method_exists($data, '__toString') ? (string) $data : (json_encode($data) ?: '');
1231 + }
1232 +
1233 + if (is_null($data)) {
1234 + return '';
1235 + }
1236 +
1237 + if (self::isFileTypeValue($data)) {
1238 + if ('signature-failed.png' === $data) {
1239 + return '';
1240 + }
1241 + }
1242 +
1243 + if (in_array($fldType, ['file-up', 'advanced-file-up'])) {
1244 + return self::anchorMarkup($newData);
1245 + }
1246 + if ('signature' === $fldType) {
1247 + return self::imgMarkup($newData);
1248 + }
1249 +
1250 + return (string) $data;
1251 + }
1252 +
1253 + private static function removeEmptyValues($fieldData)
1254 + {
1255 + if (!is_array($fieldData)) {
1256 + return $fieldData;
1257 + }
1258 + // Remove empty values from the array
1259 + return array_filter($fieldData, function ($value) {
1260 + // Check if the value is 0
1261 + if (0 === $value) {
1262 + return '0';
1263 + }
1264 + return !empty($value);
1265 + });
1266 + }
1267 +
1268 + /**
1269 + * Gets the field type by field key
1270 + *
1271 + * @param string $fldKey
1272 + * @param mixed $formField
1273 + * @return string
1274 + */
1275 + private static function getFldType($fldKey, $formFields)
1276 + {
1277 + if (array_key_exists($fldKey, $formFields)) {
1278 + return $formFields[$fldKey]['type'];
1279 + }
1280 + }
1281 +
1282 + /**
1283 + * The type of the field a Smart Tag points at: from the value itself when it carries
1284 + * one, otherwise from the form.
1285 + *
1286 + * @param mixed $fieldValue Entry of $fieldValues for this field
1287 + * @param string $fieldKey
1288 + * @param int|null $formID
1289 + * @return string|null
1290 + */
1291 + private static function fieldTypeForSmartTag($fieldValue, $fieldKey, $formID)
1292 + {
1293 + if (is_array($fieldValue) && isset($fieldValue['type']) && is_string($fieldValue['type'])) {
1294 + return $fieldValue['type'];
1295 + }
1296 + if (!$formID) {
1297 + return null;
1298 + }
1299 + $formFields = FormManager::getInstance($formID)->getFields();
1300 + return is_array($formFields) && isset($formFields[$fieldKey]['type']) ? $formFields[$fieldKey]['type'] : null;
1301 + }
1302 +
1303 + private static function isCompositeFieldType($fieldType)
1304 + {
1305 + return in_array($fieldType, ['name', 'address'], true);
1306 + }
1307 +
1308 + /**
1309 + * Removes internal/meta subfields (keys prefixed with "_", e.g. the address
1310 + * field's _latitude / _longitude) so they never leak into human-readable
1311 + * output (entry views, emails, SmartTags, PDF, exports).
1312 + *
1313 + * @param mixed $value
1314 + * @return mixed
1315 + */
1316 + private static function stripMetaSubfields($value)
1317 + {
1318 + if (!is_array($value)) {
1319 + return $value;
1320 + }
1321 + foreach (array_keys($value) as $key) {
1322 + if (is_string($key) && '' !== $key && '_' === $key[0]) {
1323 + unset($value[$key]);
1324 + }
1325 + }
1326 + return $value;
1327 + }
1328 +
1329 + private static function joinCompositeFieldValue($value, $fieldType)
1330 + {
1331 + if (!is_array($value)) {
1332 + return (string) $value;
1333 + }
1334 +
1335 + $value = self::stripMetaSubfields($value);
1336 +
1337 + $parts = [];
1338 + array_walk_recursive($value, function ($item) use (&$parts) {
1339 + if (is_null($item)) {
1340 + return;
1341 + }
1342 +
1343 + $item = (string) $item;
1344 + if ('' === trim($item) && '0' !== $item) {
1345 + return;
1346 + }
1347 +
1348 + $parts[] = $item;
1349 + });
1350 +
1351 + return implode('address' === $fieldType ? ', ' : ' ', $parts);
1352 + }
1353 +
1354 + /**
1355 + * Return true is it's file type value by checking with extension
1356 + *
1357 + * @param string $filename
1358 + * @return boolean
1359 + */
1360 + private static function isFileTypeValue($fileName)
1361 + {
1362 + if (!is_string($fileName)) {
1363 + return false;
1364 + }
1365 + $ext = pathinfo($fileName, PATHINFO_EXTENSION);
1366 +
1367 + if ('other' !== FileHandler::getFileTypeByExtension($ext)) {
1368 + return true;
1369 + }
1370 + }
1371 +
1372 + /**
1373 + * Return true is it's image type value by checking with extension
1374 + *
1375 + * @param string $filename
1376 + * @return boolean
1377 + */
1378 + private static function isImageTypeValue($fileName)
1379 + {
1380 + if (!is_string($fileName)) {
1381 + return false;
1382 + }
1383 + $ext = pathinfo($fileName, PATHINFO_EXTENSION);
1384 +
1385 + if ('image' === FileHandler::getFileTypeByExtension($ext)) {
1386 + return true;
1387 + }
1388 + }
1389 +
1390 + /**
1391 + * Collect the column keys of a repeater table as the union of every row's keys,
1392 + * not just the first row's. Conditional logic can hide a sub-field in one row and
1393 + * show it in the next; keying off row 0 alone drops that column's header and
1394 + * shifts every later row's cells. Ordering follows the repeater's own nested
1395 + * layout when the form id is known, with any leftover keys appended.
1396 + *
1397 + * @param array $rows
1398 + * @param string $repeaterFieldKey
1399 + * @param int|string|null $formId
1400 + * @return array
1401 + */
1402 + private static function repeaterColumnKeys($rows, $repeaterFieldKey, $formId = null)
1403 + {
1404 + $present = [];
1405 + foreach ($rows as $row) {
1406 + if (!is_array($row)) {
1407 + continue;
1408 + }
1409 + foreach (array_keys($row) as $subKey) {
1410 + $present[$subKey] = true;
1411 + }
1412 + }
1413 +
1414 + if (empty($present)) {
1415 + return [];
1416 + }
1417 +
1418 + $ordered = [];
1419 + if ($formId) {
1420 + $nestedLayout = FormManager::getInstance($formId)->getFormNestedLayout();
1421 + $repeaterLayout = isset($nestedLayout->{$repeaterFieldKey}->lg) ? $nestedLayout->{$repeaterFieldKey}->lg : [];
1422 + foreach ((array) $repeaterLayout as $fld) {
1423 + $subKey = isset($fld->i) ? $fld->i : null;
1424 + if ($subKey && isset($present[$subKey])) {
1425 + $ordered[] = $subKey;
1426 + unset($present[$subKey]);
1427 + }
1428 + }
1429 + }
1430 +
1431 + return array_merge($ordered, array_keys($present));
1432 + }
1433 +
1434 + private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey, $formId = null)
1435 + {
1436 + $repeaterFieldData = self::decodeIfJson($repeaterFieldData);
1437 +
1438 + if (!is_array($repeaterFieldData) || !isset($repeaterFieldData[0]) || !is_array($repeaterFieldData[0])) {
1439 + return ''; // Safely return empty if not a valid repeater structure
1440 + }
1441 + $table = "<table style='font-family: arial, sans-serif; border-collapse: collapse; width: 100%;'>";
1442 + // $table .= '<tr>';
1443 + // $table .= '<th style="border: 1px solid #dddddd; text-align: left; padding: 8px;">' . self::getLabel($formFields, $repeaterFieldKey) . '</th>';
1444 + // $table .= '</tr>';
1445 + // $table .= '<td style="border: 1px solid #dddddd; text-align: left; padding: 8px;">';
1446 + // $table .= '<table style="width: 100%; border-collapse: collapse;">';
1447 +
1448 + $headers = self::repeaterColumnKeys($repeaterFieldData, $repeaterFieldKey, $formId);
1449 + $table .= '<tr>'; // open tr (for column header)
1450 + foreach ($headers as $fk) {
1451 + $table .= '<th style="border: 1px solid #dddddd; padding: 8px; ">' . self::getLabel($formFields, $fk) . '</th>';
1452 + }
1453 + $table .= '</tr>'; // close tr (for column header)
1454 +
1455 + foreach ($repeaterFieldData as $row) {
1456 + if (!is_array($row)) {
1457 + continue;
1458 + }
1459 + $table .= '<tr>'; // open tr (for table data row)
1460 + // Walk the column list, not the row's own keys, so a sub-field hidden by
1461 + // conditional logic in this row renders an empty cell instead of shifting
1462 + // every following cell one column to the left.
1463 + foreach ($headers as $k) {
1464 + $value = array_key_exists($k, $row) ? $row[$k] : '';
1465 + $fldTyp = self::getFldType($k, $formFields);
1466 + if (is_array($value)) {
1467 + if (in_array($fldTyp, ['advanced-file-up', 'file-up'])) {
1468 + $newValue = self::unorderedAnchorListMarkup($value);
1469 + } elseif (self::isCompositeFieldType($fldTyp)) {
1470 + $newValue = self::joinCompositeFieldValue($value, $fldTyp);
1471 + } else {
1472 + $newValue = implode(', ', $value);
1473 + }
1474 + } else {
1475 + if (self::isFileTypeValue($value)) {
1476 + $newValue = 'signature-failed.png' === $value
1477 + ? ''
1478 + : (self::isImageTypeValue($value)
1479 + ? "<img src='{$value}' alt='{$value}' width='250'/>"
1480 + : "<a href='{$value}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$value}</a>");
1481 + } else {
1482 + $newValue = $value;
1483 + }
1484 + }
1485 +
1486 + $table .= '<td style="border: 1px solid #dddddd; padding: 8px;">' . $newValue . '</td>';
1487 + }
1488 + $table .= '</tr>'; // close tr (for table data row)
1489 + }
1490 + // $table .= '</table>';
1491 +
1492 + // $table .= '</td>';
1493 + $table .= '</table>';
1494 +
1495 + return $table;
1496 + }
1497 +
1498 + private static function getLabel($formFields, $key)
1499 + {
1500 + return $formFields[$key]['label'] ?? $key;
1501 + }
1502 +
1503 + /**
1504 + * Derive a composite child field's key name from its bracketed HTML name.
1505 + * e.g. childFieldName "name[first_name]" with parent "name" => "first_name".
1506 + * Falls back to the bracket contents when the parent name is empty.
1507 + */
1508 + public static function deriveChildName($childFieldName, $parentFieldName)
1509 + {
1510 + $childFieldName = (string) $childFieldName;
1511 + if (preg_match('/\[(.*?)\]/', $childFieldName, $matches)) {
1512 + return $matches[1];
1513 + }
1514 +
1515 + return str_replace(['[', ']', (string) $parentFieldName], '', $childFieldName);
1516 + }
1517 +
1518 + /**
1519 + * The HTML name a composite child (Name/Address) renders with: the parent's current name plus
1520 + * the child's own suffix. The child stores a full "parent[suffix]" copy that goes stale when the
1521 + * parent is renamed, and a stale prefix posts the value away from the parent.
1522 + *
1523 + * @param string $childFieldName stored child name, e.g. "address-1[city]"
1524 + * @param string $parentFieldName parent's current name, e.g. "home_address"
1525 + *
1526 + * @return string e.g. "home_address[city]"; the stored name when it has no suffix or the parent has no name
1527 + */
1528 + public static function rebaseChildFieldName($childFieldName, $parentFieldName)
1529 + {
1530 + $childFieldName = (string) $childFieldName;
1531 + $parentFieldName = (string) $parentFieldName;
1532 + if ('' === $parentFieldName || !preg_match('/\[([^\]]+)\]$/', $childFieldName, $matches)) {
1533 + return $childFieldName;
1534 + }
1535 +
1536 + return $parentFieldName . '[' . $matches[1] . ']';
1537 + }
1538 +
1539 + /**
1540 + * Pull a child value out of a parent composite field's nested submitted value.
1541 + * Looks up by the derived child name first, then by the child field key.
1542 + * Returns null when no match is found.
1543 + */
1544 + public static function extractChildValueFromParentValue($parentValue, $childName, $childKey)
1545 + {
1546 + if (is_object($parentValue)) {
1547 + $parentValue = (array) $parentValue;
1548 + }
1549 +
1550 + if (!is_array($parentValue)) {
1551 + return null;
1552 + }
1553 +
1554 + if (array_key_exists($childName, $parentValue)) {
1555 + return $parentValue[$childName];
1556 + }
1557 +
1558 + if (array_key_exists($childKey, $parentValue)) {
1559 + return $parentValue[$childKey];
1560 + }
1561 +
1562 + return null;
1563 + }
66 1564 }