PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Core/Util/FieldValueHandler.php +552 -75 V3.0.2 → V3.3.2 View file →
@@ -6,9 +6,17 @@
6 6 use BitCode\BitForm\Core\Form\FormManager;
7 7
8 8 final class FieldValueHandler
9 9 {
10 - public static function replaceFieldWithValue($stringToReplaceField, $fieldValues, $formID = null)
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)
11 19 {
12 20 if (empty($stringToReplaceField)) {
13 21 return $stringToReplaceField;
14 22 }
@@ -16,8 +24,12 @@
16 24 $stringToReplaceField = wp_json_encode($stringToReplaceField);
17 25 }
18 26 $fieldValues = $formID ? self::sortValueBasedOnLayout($formID, $fieldValues) : $fieldValues;
19 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 +
20 32 if ($formID) {
21 33 $stringToReplaceField = self::replaceValueOfBf_all_data($stringToReplaceField, $fieldValues, $formID);
22 34 $stringToReplaceField = self::replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID);
23 35 }
@@ -35,10 +47,14 @@
35 47 $fieldName = substr($value, 2, strlen($value) - 3);
36 48 $fieldValue = null;
37 49 if (isset($fieldValues[$fieldName])) {
38 50 $targetFieldValue = isset($fieldValues[$fieldName]['value']) ? $fieldValues[$fieldName]['value'] : $fieldValues[$fieldName];
39 - if ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue)) {
40 - foreach ((array) $targetFieldValue as $singleTargetVal) {
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) {
41 57 if (isset($fieldValue)) {
42 58 if (is_numeric($fieldValue) && is_numeric($singleTargetVal)) {
43 59 $fieldValue = $fieldValue + $singleTargetVal;
44 60 } else {
@@ -51,8 +67,12 @@
51 67 // $fieldValue = wp_json_encode($targetFieldValue);
52 68 } else {
53 69 $fieldValue = strval($targetFieldValue);
54 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 + }
55 75 $stringToReplaceField = str_replace($value, $fieldValue, $stringToReplaceField);
56 76 } else {
57 77 $stringToReplaceField = str_replace($value, '', $stringToReplaceField);
58 78 }
@@ -198,8 +218,318 @@
198 218 }
199 219 return false;
200 220 }
201 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 +
202 532 public static function formatFieldValueForMail($fields, $fieldValues = [])
203 533 {
204 534 $formattedFldValues = $fieldValues;
205 535 $file_upload_types = Helpers::$file_upload_types;
@@ -207,9 +537,9 @@
207 537 foreach ($fields as $fldKey => $fldData) {
208 538 if (in_array($fldData->typ, $file_upload_types)) {
209 539 continue;
210 540 }
211 - if (array_key_exists($fldKey, $fieldValues)) {
541 + if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
212 542 $value = $fieldValues[$fldKey];
213 543 // if (is_array($value)) {
214 544 // $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
215 545 // } else {
@@ -317,8 +647,13 @@
317 647 '/<img\s+[^>]*src=[\'"]([^\'"]*)[\'"][^>]*>/i',
318 648 function ($matches) use ($path, $allowedMimeTypes) {
319 649 $src = $matches[1];
320 650
651 + // Already-embedded inline images (cid:) must be left untouched.
652 + if (0 === stripos($src, 'cid:')) {
653 + return $matches[0];
654 + }
655 +
321 656 if (filter_var($src, FILTER_VALIDATE_URL)) {
322 657 return $matches[0];
323 658 }
324 659
@@ -364,13 +699,16 @@
364 699
365 700 public static function sortValueBasedOnLayout($formId, $fieldValues)
366 701 {
367 702 $formManager = FormManager::getInstance($formId);
368 - $layout = $formManager->getFormLayout();
369 703 $formLayout = $formManager->getFlatenFormLayout(); // returns all layouts (lg, md, sm)
370 - $fieldKeyOrderbasedOnLayout = array_map(function ($fld) {
371 - return $fld->i;
372 - }, $formLayout->lg);
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 + });
373 711 $ordered = [];
374 712
375 713 foreach ($fieldKeyOrderbasedOnLayout as $key) {
376 714 if (array_key_exists($key, $fieldValues)) {
@@ -397,18 +735,20 @@
397 735 $formManager = FormManager::getInstance($formId);
398 736 $formFields = $formManager->getFields();
399 737 $orderedFormFields = $formManager->getFieldsBasedOnLayout(); // ordered form fields based on layout(lg) order
400 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.
401 741 switch ($match) {
402 742 case '${bf_all_data}':
403 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
404 - $table = self::generateTable($fieldValues, $orderedFormFields);
743 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
744 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
405 745 $stringToReplaceField = str_replace('${bf_all_data}', $table, $stringToReplaceField);
406 746 break;
407 747
408 748 case '${bf_all_data.onlyValues}':
409 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
410 - $table = self::generateTable($fieldValues, $orderedFormFields);
749 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
750 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
411 751 $stringToReplaceField = str_replace('${bf_all_data.onlyValues}', $table, $stringToReplaceField);
412 752 break;
413 753 default:
414 754 Log::debug_log([
@@ -511,10 +851,11 @@
511 851 }
512 852
513 853 // Skip processing for hidden or empty fields only when $isOnlyValues is true
514 854 if ($isOnlyValues) {
515 - // Check if the value is strictly an empty string or null, but allow 0
516 - if (!isset($formData[$key]) || '' === $formData[$key] || null === $formData[$key]) {
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])) {
517 858 return $fieldNewData;
518 859 }
519 860
520 861 if (isset($field['valid']['hide']) && $field['valid']['hide']) {
@@ -550,9 +891,9 @@
550 891 return $fieldNewData;
551 892 }, []);
552 893 }
553 894
554 - private static function generateTable($fieldValues, $formFields)
895 + private static function generateTable($fieldValues, $formFields, $formId = null)
555 896 {
556 897 if (empty($fieldValues)) {
557 898 Log::debug_log([
558 899 'status' => 'error',
@@ -578,10 +919,12 @@
578 919 if (is_array($value)) {
579 920 if ('repeater' === $fieldType) {
580 921 $table .= "<table style='width: 100%; border-collapse: collapse;'>";
581 922
923 + $subKeys = self::repeaterColumnKeys($value, $fk, $formId);
924 +
582 925 $table .= '<tr>';
583 - foreach (array_keys($value[0]) as $subKey) {
926 + foreach ($subKeys as $subKey) {
584 927 $subLabel = self::getLabel($formFields, $subKey) ?? $subKey;
585 928 $table .= "<th style='border: 1px solid #dddddd; padding: 8px; background-color: #f2f2f2;'>" . $subLabel . '</th>';
586 929 }
587 930 $table .= '</tr>';
@@ -586,10 +929,16 @@
586 929 }
587 930 $table .= '</tr>';
588 931
589 932 foreach ($value as $row) {
933 + if (!is_array($row)) {
934 + continue;
935 + }
590 936 $table .= '<tr>';
591 - foreach ($row as $subKey => $subValue) {
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] : '';
592 941 $subFieldType = self::getFldType($subKey, $formFields);
593 942 if (is_array($subValue)) {
594 943 if (self::isCompositeFieldType($subFieldType)) {
595 944 $subValue = self::joinCompositeFieldValue($subValue, $subFieldType);
@@ -634,12 +983,13 @@
634 983 }
635 984 } elseif (self::isCompositeFieldType($fieldType)) {
636 985 $table .= self::joinCompositeFieldValue($value, $fieldType);
637 986 } elseif ('signature' === $fieldType) {
638 - if ('signature-failed.png' === $subValue) {
639 - $table .= '';
640 - } else {
641 - $table .= self::imgMarkup($value);
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);
642 992 }
643 993 }
644 994 } else {
645 995 $table .= $value;
@@ -664,16 +1014,32 @@
664 1014 }
665 1015
666 1016 private static function imgMarkup($filename)
667 1017 {
668 - return "<img src='{$filename}' alt='{$filename}' width='250'/>";
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'/>";
669 1024 }
670 1025
671 1026 private static function anchorMarkup($filename)
672 1027 {
673 - return "<a href='{$filename}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$filename}</a>";
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>';
674 1034 }
675 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 +
676 1042 public static function replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID)
677 1043 {
678 1044 if (!is_string($stringToReplaceField) || empty($stringToReplaceField)) {
679 1045 return $stringToReplaceField; // Return as-is if nothing to replace
@@ -695,9 +1061,9 @@
695 1061 foreach ($matches[1] as $fk) {
696 1062 $repeaterFieldKey = $fk;
697 1063 $fieldType = isset($formFields[$repeaterFieldKey]['type']) && !empty($formFields[$repeaterFieldKey]['type']) ? $formFields[$repeaterFieldKey]['type'] : null;
698 1064 if ('repeater' === $fieldType) {
699 - $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey);
1065 + $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey, $formID);
700 1066 $stringToReplaceField = str_replace('${' . $fk . '}', $repeaterMarkup, $stringToReplaceField);
701 1067 } else {
702 1068 if ('signature' === $fieldType) {
703 1069 $stringToReplaceField = self::replaceImgTagForRepeatedSignature($stringToReplaceField, $flatFieldData[$repeaterFieldKey], $repeaterFieldKey);
@@ -833,17 +1199,11 @@
833 1199 * @param array|null $allFieldData
834 1200 * @param array|null $formFields
835 1201 * @return string
836 1202 */
837 - public static function safeFlatString($data, $fldType, $fieldKey = null, $allFieldData = null, $formFields = null, $resolveFromParent = true): string
1203 + public static function safeFlatString($data, $fldType, $fieldKey = null, $allFieldData = null, $formFields = null): string
838 1204 {
839 1205 $newData = self::decodeIfJson($data);
840 - if ($resolveFromParent) {
841 - $resolvedChildValues = self::extractRepeaterChildFieldValues($fieldKey, $allFieldData, $formFields);
842 - if (is_array($resolvedChildValues)) {
843 - $newData = $resolvedChildValues;
844 - }
845 - }
846 1206
847 1207 if (is_array($newData)) {
848 1208 if (self::isCompositeFieldType($fldType)) {
849 1209 return self::joinCompositeFieldValue($newData, $fldType);
@@ -861,9 +1221,9 @@
861 1221 } else {
862 1222 return $itm;
863 1223 }
864 1224 }, $item)) . ']'
865 - : self::safeFlatString($item, $fldType, $fieldKey, $allFieldData, $formFields, false);
1225 + : self::safeFlatString($item, $fldType, $fieldKey, $allFieldData, $formFields);
866 1226 }, $newData));
867 1227 }
868 1228
869 1229 if (is_object($data)) {
@@ -918,13 +1278,55 @@
918 1278 return $formFields[$fldKey]['type'];
919 1279 }
920 1280 }
921 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 +
922 1303 private static function isCompositeFieldType($fieldType)
923 1304 {
924 1305 return in_array($fieldType, ['name', 'address'], true);
925 1306 }
926 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 +
927 1329 private static function joinCompositeFieldValue($value, $fieldType)
928 1330 {
929 1331 if (!is_array($value)) {
930 1332 return (string) $value;
@@ -929,8 +1331,10 @@
929 1331 if (!is_array($value)) {
930 1332 return (string) $value;
931 1333 }
932 1334
1335 + $value = self::stripMetaSubfields($value);
1336 +
933 1337 $parts = [];
934 1338 array_walk_recursive($value, function ($item) use (&$parts) {
935 1339 if (is_null($item)) {
936 1340 return;
@@ -946,48 +1350,8 @@
946 1350
947 1351 return implode('address' === $fieldType ? ', ' : ' ', $parts);
948 1352 }
949 1353
950 - private static function extractRepeaterChildFieldValues($fieldKey, $allFieldData, $formFields)
951 - {
952 - if (empty($fieldKey) || !is_array($allFieldData) || !is_array($formFields) || !isset($formFields[$fieldKey])) {
953 - return null;
954 - }
955 -
956 - $fieldData = $formFields[$fieldKey];
957 - if (empty($fieldData['parentFieldKey']) || empty($fieldData['name'])) {
958 - return null;
959 - }
960 -
961 - $parentFieldKey = $fieldData['parentFieldKey'];
962 - $parentFieldName = $formFields[$parentFieldKey]['name'] ?? null;
963 -
964 - if (!isset($allFieldData[$parentFieldKey]) || !is_array($allFieldData[$parentFieldKey])) {
965 - return null;
966 - }
967 -
968 - $childFieldName = $fieldData['name'];
969 - $childFieldName = str_replace(['[', ']', $parentFieldName], '', $childFieldName);
970 -
971 - $childValues = [];
972 - foreach ($allFieldData[$parentFieldKey] as $row) {
973 - if (!is_array($row)) {
974 - continue;
975 - }
976 -
977 - if (array_key_exists($childFieldName, $row)) {
978 - $childValues[] = $row[$childFieldName];
979 - continue;
980 - }
981 -
982 - if (array_key_exists($fieldKey, $row)) {
983 - $childValues[] = $row[$fieldKey];
984 - }
985 - }
986 -
987 - return $childValues;
988 - }
989 -
990 1354 /**
991 1355 * Return true is it's file type value by checking with extension
992 1356 *
993 1357 * @param string $filename
@@ -1022,10 +1386,54 @@
1022 1386 return true;
1023 1387 }
1024 1388 }
1025 1389
1026 - private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey)
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)
1027 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 + {
1028 1436 $repeaterFieldData = self::decodeIfJson($repeaterFieldData);
1029 1437
1030 1438 if (!is_array($repeaterFieldData) || !isset($repeaterFieldData[0]) || !is_array($repeaterFieldData[0])) {
1031 1439 return ''; // Safely return empty if not a valid repeater structure
@@ -1036,9 +1444,9 @@
1036 1444 // $table .= '</tr>';
1037 1445 // $table .= '<td style="border: 1px solid #dddddd; text-align: left; padding: 8px;">';
1038 1446 // $table .= '<table style="width: 100%; border-collapse: collapse;">';
1039 1447
1040 - $headers = array_keys($repeaterFieldData[0]);
1448 + $headers = self::repeaterColumnKeys($repeaterFieldData, $repeaterFieldKey, $formId);
1041 1449 $table .= '<tr>'; // open tr (for column header)
1042 1450 foreach ($headers as $fk) {
1043 1451 $table .= '<th style="border: 1px solid #dddddd; padding: 8px; ">' . self::getLabel($formFields, $fk) . '</th>';
1044 1452 }
@@ -1044,10 +1452,17 @@
1044 1452 }
1045 1453 $table .= '</tr>'; // close tr (for column header)
1046 1454
1047 1455 foreach ($repeaterFieldData as $row) {
1456 + if (!is_array($row)) {
1457 + continue;
1458 + }
1048 1459 $table .= '<tr>'; // open tr (for table data row)
1049 - foreach ($row as $k=>$value) {
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] : '';
1050 1465 $fldTyp = self::getFldType($k, $formFields);
1051 1466 if (is_array($value)) {
1052 1467 if (in_array($fldTyp, ['advanced-file-up', 'file-up'])) {
1053 1468 $newValue = self::unorderedAnchorListMarkup($value);
@@ -1082,6 +1497,68 @@
1082 1497
1083 1498 private static function getLabel($formFields, $key)
1084 1499 {
1085 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;
1086 1563 }
1087 1564 }