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