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
bit-form / includes / Core / Util / FieldValueHandler.php

FieldValueHandler.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder V3.3.2, at includes/Core/Util/FieldValueHandler.php

1,565 lines 52.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BitCode\BitForm\Core\Util;
4
5 use BitCode\BitForm\Admin\Form\Helpers;
6 use BitCode\BitForm\Core\Form\FormManager;
7
8 final class FieldValueHandler
9 {
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);
70 }
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);
74 }
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;
175 }
176 }
177 }
178 return $emailAddresses;
179 }
180
181 public static function replaceSmartTagWithValue($contentWithSmartTag)
182 {
183 $fieldPattern = '/(\${_[^{]*?)(?=\})}/';
184 $matchPattern = preg_match_all($fieldPattern, $contentWithSmartTag, $matchedField);
185 if (!$matchPattern) {
186 return $contentWithSmartTag;
187 }
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'/>";
968 }
969 }
970 } else {
971 $subValue = $subValue;
972 }
973 }
974
975 $table .= "<td style='border: 1px solid #dddddd; padding: 8px;'>" . $subValue . '</td>';
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 }
993 }
994 } else {
995 $table .= $value;
996 }
997
998 $table .= '</td></tr>';
999 }
1000
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);
1070 }
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] = [];
1166 }
1167 // Handle nested arrays within entries
1168 $result[$subKey][$entryIndex] = $subValue;
1169 }
1170 }
1171 }
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 }
1564 }
1565