PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Helpers / Helper.php

Helper.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Helpers/Helper.php

1,955 lines 66.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Helpers;
4
5 use FluentForm\App\Models\EntryDetails;
6 use FluentForm\App\Models\Form;
7 use FluentForm\App\Models\FormMeta;
8 use FluentForm\App\Models\Submission;
9 use FluentForm\App\Models\SubmissionMeta;
10 use FluentForm\App\Services\FormBuilder\Components\SelectCountry;
11 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
12 use FluentForm\Framework\Helpers\ArrayHelper;
13 use FluentForm\App\Helpers\Traits\GlobalDefaultMessages;
14 use FluentForm\Framework\Support\Arr;
15
16 class Helper
17 {
18 use GlobalDefaultMessages;
19
20 public static $tabIndex = 0;
21
22 public static $formInstance = 0;
23
24 public static $loadedForms = [];
25
26 public static $tabIndexStatus = 'na';
27
28 protected static $formMetaCache = [];
29
30
31 /**
32 * Sanitize form inputs recursively.
33 *
34 * @param $input
35 *
36 * @return string $input
37 */
38 public static function sanitizer($input, $attribute = null, $fields = [])
39 {
40 if (is_string($input)) {
41 if ('textarea' === ArrayHelper::get($fields, $attribute . '.element')) {
42 $input = sanitize_textarea_field($input);
43 } else {
44 $input = sanitize_text_field($input);
45 }
46 } elseif (is_array($input)) {
47 foreach ($input as $key => &$value) {
48 // Local var: mutating $attribute here would collapse every sibling
49 // after the first onto a bare key, resolving nested inputs to the wrong element.
50 $childAttribute = $attribute ? $attribute . '[' . $key . ']' : $key;
51
52 $value = static::sanitizer($value, $childAttribute, $fields);
53 }
54 unset($value);
55 }
56
57 return $input;
58 }
59
60 /**
61 * Flatten a request value into a plain, printable string.
62 *
63 * Request values are string or array. Walks nested arrays so a crafted
64 * param[][] cannot raise an "Array to string conversion" notice, and the
65 * caller can escape the result in one pass instead of branching on shape.
66 *
67 * @param mixed $value
68 *
69 * @return string
70 */
71 public static function flattenRequestValue($value)
72 {
73 if (!is_array($value)) {
74 return is_scalar($value) ? (string) $value : '';
75 }
76
77 $flat = [];
78
79 array_walk_recursive($value, function ($item) use (&$flat) {
80 if (is_scalar($item)) {
81 $flat[] = (string) $item;
82 }
83 });
84
85 return implode(', ', $flat);
86 }
87
88 public static function isOptionGroup($option)
89 {
90 return is_array($option)
91 && ArrayHelper::get($option, 'type') === 'group'
92 && is_array(ArrayHelper::get($option, 'options'));
93 }
94
95 /*
96 * Int or nothing. Persisting free text here would hand the one user class
97 * this sanitizer exists to contain an arbitrary string in form_fields, for a
98 * value with no PHP consumer at all — it only keys the editor's Vue list.
99 * A non-numeric id collapses to 0, and ensureUniqueIds() regenerates it on
100 * load. Numerics must stay numeric for the same reason: as the string "0" it
101 * would be JS-truthy, and ensureUniqueIds() only regenerates ids that are
102 * falsy or already seen.
103 */
104 public static function sanitizeOptionId($value)
105 {
106 return is_numeric($value) ? (int) $value : 0;
107 }
108
109 /*
110 * sanitize_title, not sanitize_key: Pro writes inventory slugs with
111 * sanitize_title (InventoryController), which percent-encodes non-ASCII.
112 * sanitize_key strips '%', producing a slug that no longer exists — and a
113 * truthy-but-missing slug resolves to quantity 0 in InventoryValidation, so
114 * the option fails closed as permanently stocked out. sanitize_title is
115 * idempotent over its own output and still neutralises markup.
116 *
117 * The is_scalar guard is ours: unlike sanitize_key, sanitize_title has no
118 * guard of its own and raises a TypeError on PHP 8 for an array value.
119 */
120 public static function sanitizeOptionSlug($value)
121 {
122 return is_scalar($value) ? sanitize_title($value) : '';
123 }
124
125 /**
126 * A key is unsafe when it opens a handler, or when it carries a character that
127 * ENDS an attribute name in the HTML tokeniser -- whitespace, quote, slash,
128 * equals or angle bracket. `esc_attr()` leaves those intact, so `x onclick`
129 * renders as two attributes and the second one is live.
130 *
131 * Deny those characters rather than allow-list a charset: an allow-list also
132 * rejects the legal-but-unusual keys real sites carry (leading underscore,
133 * non-Latin names, framework prefixes) and silently drops working markup.
134 *
135 * @param string|int $key
136 * @return bool
137 */
138 public static function isSafeAttributeKey($key)
139 {
140 $key = (string) $key;
141
142 if ('' === $key || preg_match('/^on[a-z]/i', $key)) {
143 return false;
144 }
145
146 return !preg_match('/[\s"\'\/=<>`]|[\x00-\x1F\x7F]/', $key);
147 }
148
149 /*
150 * Keys the whitelist above drops but the editor and Pro Inventory need back.
151 * They are sanitized rather than passed through, since preserving unknown
152 * keys verbatim would defeat the whitelist for exactly the users this path
153 * protects. Absent keys stay absent: an invented `quantity => 0` reads as
154 * "stock out" to InventoryValidation.
155 */
156 protected static function optionPassthroughMap()
157 {
158 return [
159 'id' => [self::class, 'sanitizeOptionId'],
160 'quantity' => 'intval',
161 'global_inventory' => [self::class, 'sanitizeOptionSlug'],
162 ];
163 }
164
165 public static function sanitizeAdvancedOptions($options, $depth = 0)
166 {
167 if (!is_array($options)) {
168 return [];
169 }
170
171 $sanitized = [];
172
173 foreach ($options as $option) {
174 if (!is_array($option)) {
175 continue;
176 }
177
178 if (self::isOptionGroup($option)) {
179 $groupOptions = self::sanitizeAdvancedOptions(
180 ArrayHelper::get($option, 'options', []),
181 $depth + 1
182 );
183
184 if ($depth > 0) {
185 $sanitized = array_merge($sanitized, $groupOptions);
186 continue;
187 }
188
189 $groupLabel = ArrayHelper::get($option, 'label', '');
190
191 $group = [
192 'type' => 'group',
193 'label' => wp_kses_post(is_scalar($groupLabel) ? $groupLabel : ''),
194 'options' => $groupOptions,
195 ];
196
197 // Groups are keyed on group.id in the editor exactly like leaf
198 // options, and is_open is user-visible collapse state.
199 if (array_key_exists('id', $option)) {
200 $group['id'] = self::sanitizeOptionId($option['id']);
201 }
202 if (array_key_exists('is_open', $option)) {
203 $group['is_open'] = (bool) $option['is_open'];
204 }
205
206 $sanitized[] = $group;
207 continue;
208 }
209
210 /*
211 * Non-scalars are flattened to '' before the WP sanitisers see them:
212 * wp_kses_post() and sanitize_url() have no guard of their own and
213 * raise a TypeError on PHP 8 for an array, which would be an uncaught
214 * 500 on save for exactly the users this whitelist protects.
215 */
216 $scalar = function ($key) use ($option) {
217 $value = ArrayHelper::get($option, $key, '');
218
219 return is_scalar($value) ? $value : '';
220 };
221
222 $clean = [
223 'label' => wp_kses_post($scalar('label')),
224 'value' => sanitize_text_field($scalar('value')),
225 'image' => sanitize_url($scalar('image')),
226 'calc_value' => sanitize_text_field($scalar('calc_value')),
227 'disabled' => ArrayHelper::isTrue($option, 'disabled'),
228 ];
229
230 foreach (self::optionPassthroughMap() as $key => $sanitizer) {
231 if (array_key_exists($key, $option)) {
232 $clean[$key] = call_user_func($sanitizer, $option[$key]);
233 }
234 }
235
236 $sanitized[] = $clean;
237 }
238
239 return $sanitized;
240 }
241
242 public static function flattenAdvancedOptions($options)
243 {
244 if (!is_array($options)) {
245 return [];
246 }
247
248 $flattened = [];
249
250 foreach ($options as $option) {
251 if (self::isOptionGroup($option)) {
252 $flattened = array_merge(
253 $flattened,
254 self::flattenAdvancedOptions(ArrayHelper::get($option, 'options', []))
255 );
256 continue;
257 }
258
259 if (!is_array($option)) {
260 continue;
261 }
262
263 $flattened[] = $option;
264 }
265
266 return $flattened;
267 }
268
269 public static function advancedOptionsValueLabelMap($options)
270 {
271 $formatted = [];
272
273 foreach (self::flattenAdvancedOptions($options) as $option) {
274 $formatted[ArrayHelper::get($option, 'value')] = ArrayHelper::get($option, 'label');
275 }
276
277 return $formatted;
278 }
279
280 public static function makeMenuUrl($page = 'fluent_forms_settings', $component = null)
281 {
282 $baseUrl = admin_url('admin.php?page=' . $page);
283
284 $hash = ArrayHelper::get($component, 'hash', '');
285 if ($hash) {
286 $baseUrl = $baseUrl . '#' . $hash;
287 }
288
289 $query = ArrayHelper::get($component, 'query');
290
291 if ($query) {
292 $paramString = http_build_query($query);
293 if ($hash) {
294 $baseUrl .= '?' . $paramString;
295 } else {
296 $baseUrl .= '&' . $paramString;
297 }
298 }
299
300 return $baseUrl;
301 }
302
303 public static function getHtmlElementClass($value1, $value2, $class = 'active', $default = '')
304 {
305 return $value1 === $value2 ? $class : $default;
306 }
307
308 /**
309 * Determines if the given string is a valid json.
310 *
311 * @param $string
312 *
313 * @return bool
314 */
315 public static function isJson($string)
316 {
317 json_decode($string);
318
319 return JSON_ERROR_NONE === json_last_error();
320 }
321
322 public static function isSlackEnabled()
323 {
324 $globalModules = get_option('fluentform_global_modules_status');
325
326 return $globalModules && isset($globalModules['slack']) && 'yes' == $globalModules['slack'];
327 }
328
329 public static function getEntryStatuses($form_id = false)
330 {
331 $statuses = [
332 'unread' => __('Unread', 'fluentform'),
333 'read' => __('Read', 'fluentform'),
334 'favorites' => __('Favorites', 'fluentform'),
335 ];
336
337 $statuses = apply_filters_deprecated(
338 'fluentform_entry_statuses_core',
339 [
340 $statuses,
341 $form_id,
342 ],
343 FLUENTFORM_FRAMEWORK_UPGRADE,
344 'fluentform/entry_statuses_core',
345 'Use fluentform/entry_statuses_core instead of fluentform_entry_statuses_core.'
346 );
347
348 $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
349
350 $statuses['spam'] = __('Spam', 'fluentform');
351
352 $statuses['trashed'] = __('Trashed', 'fluentform');
353
354 return $statuses;
355 }
356
357 // Statuses a caller may write by hand; add-ons withhold the ones they own as workflow steps.
358 public static function getMutableEntryStatuses($form_id = false, $submission_id = null)
359 {
360 return apply_filters('fluentform/entry_statuses_for_mutation', static::getEntryStatuses($form_id), $form_id, $submission_id);
361 }
362
363 public static function getReportableInputs()
364 {
365 $data = [
366 'select',
367 'input_radio',
368 'input_checkbox',
369 'ratings',
370 'net_promoter',
371 'select_country',
372 'net_promoter_score',
373 ];
374
375 $data = apply_filters_deprecated(
376 'fluentform_reportable_inputs',
377 [
378 $data,
379 ],
380 FLUENTFORM_FRAMEWORK_UPGRADE,
381 'fluentform/reportable_inputs',
382 'Use fluentform/reportable_inputs instead of fluentform_reportable_inputs.'
383 );
384
385 return apply_filters('fluentform/reportable_inputs', $data);
386 }
387
388 public static function getSubFieldReportableInputs()
389 {
390 $grid = apply_filters_deprecated(
391 'fluentform_subfield_reportable_inputs',
392 [
393 ['tabular_grid'],
394 ],
395 FLUENTFORM_FRAMEWORK_UPGRADE,
396 'fluentform/subfield_reportable_inputs',
397 'Use fluentform/subfield_reportable_inputs instead of fluentform_subfield_reportable_inputs.'
398 );
399
400 return apply_filters('fluentform/subfield_reportable_inputs', $grid);
401 }
402
403 public static function getFormMeta($formId, $metaKey, $default = '', $forced = false)
404 {
405 $formattedValues = self::$formMetaCache[$formId] ?? [];
406
407 if (!isset(self::$formMetaCache[$formId]) || $forced) {
408 $formMetas = FormMeta::where('form_id', $formId)
409 ->get();
410
411 $formattedValues = [];
412 foreach ($formMetas as $formMeta) {
413 $value = $formMeta->value;
414
415 $decoded = json_decode($value ?? '', true);
416 if (is_array($decoded)) {
417 $value = $decoded;
418 }
419
420 $formattedValues[$formMeta->meta_key] = $value;
421 }
422 self::$formMetaCache[$formId] = $formattedValues;
423 }
424
425 return Arr::get($formattedValues, $metaKey, $default);
426 }
427
428
429 public static function setFormMeta($formId, $metaKey, $value)
430 {
431 if ($meta = FormMeta::persist($formId, $metaKey, $value)) {
432 // Update the cache with the new value
433 if (!isset(self::$formMetaCache[$formId])) {
434 self::$formMetaCache[$formId] = [];
435 }
436 self::$formMetaCache[$formId][$metaKey] = $value;
437
438 return $meta->id;
439 }
440 return null;
441 }
442
443 public static function deleteFormMeta($formId, $metaKey)
444 {
445 try {
446 FormMeta::remove($formId, $metaKey);
447 return true;
448 } catch (\Exception $ex) {
449 return null;
450 }
451 }
452
453 /**
454 * Resolve an entry's column => value map regardless of whether the entry is
455 * a stdClass DB row (columns are real properties) or a WPFluent Model
456 * (columns live in an internal attribute bag reached via __get).
457 *
458 * @param object|array $entry
459 * @return array
460 */
461 public static function getEntryColumns($entry)
462 {
463 if (is_object($entry) && method_exists($entry, 'getAttributes')) {
464 return $entry->getAttributes();
465 }
466
467 return (array) $entry;
468 }
469
470 public static function getSubmissionMeta($submissionId, $metaKey, $default = false)
471 {
472 return SubmissionMeta::retrieve($metaKey, $submissionId, $default);
473 }
474
475 public static function setSubmissionMeta($submissionId, $metaKey, $value, $formId = false)
476 {
477 if ($meta = SubmissionMeta::persist($submissionId, $metaKey, $value, $formId)) {
478 return $meta->id;
479 }
480 return null;
481 }
482
483 public static function setSubmissionMetaAsArrayPush($submissionId, $metaKey, $value, $formId = false)
484 {
485 if ($meta = SubmissionMeta::persistArray($submissionId, $metaKey, $value, $formId)) {
486 return $meta->id;
487 }
488 return null;
489 }
490
491 public static function isEntryAutoDeleteEnabled($formId)
492 {
493 if (
494 'yes' == ArrayHelper::get(static::getFormMeta($formId, 'formSettings', []), 'delete_entry_on_submission',
495 '')
496 ) {
497 return true;
498 }
499 return false;
500 }
501
502 public static function formExtraCssClass($form)
503 {
504 if (!$form->settings) {
505 $formSettings = static::getFormMeta($form->id, 'formSettings');
506 } else {
507 $formSettings = $form->settings;
508 }
509
510 if (!$formSettings) {
511 return '';
512 }
513
514 if ($extraClass = ArrayHelper::get($formSettings, 'form_extra_css_class')) {
515 return esc_attr($extraClass);
516 }
517
518 return '';
519 }
520
521 public static function getNextTabIndex($increment = 1)
522 {
523 if (static::isTabIndexEnabled()) {
524 static::$tabIndex += $increment;
525
526 return static::$tabIndex;
527 }
528
529 return '';
530 }
531
532 public static function getFormInstaceClass($formId)
533 {
534 static::$formInstance++;
535
536 return 'ff_form_instance_' . $formId . '_' . static::$formInstance;
537 }
538
539 public static function resetTabIndex()
540 {
541 static::$tabIndex = 0;
542 }
543
544 public static function isFluentAdminPage()
545 {
546 $fluentPages = [
547 'fluent_forms',
548 'fluent_forms_all_entries',
549 'fluent_forms_transfer',
550 'fluent_forms_settings',
551 'fluent_forms_add_ons',
552 'fluent_forms_docs',
553 'fluent_forms_payment_entries',
554 'fluent_forms_reports',
555 ];
556
557 $status = true;
558
559 $page = wpFluentForm('request')->get('page');
560
561 if (!$page || !in_array($page, $fluentPages)) {
562 $status = false;
563 }
564
565 $status = apply_filters_deprecated(
566 'fluentform_is_admin_page',
567 [
568 $status,
569 ],
570 FLUENTFORM_FRAMEWORK_UPGRADE,
571 'fluentform/is_admin_page',
572 'Use fluentform/is_admin_page instead of fluentform_is_admin_page.'
573 );
574
575 return apply_filters('fluentform/is_admin_page', $status);
576 }
577
578 public static function getShortCodeIds($content, $tag = 'fluentform', $selector = 'id')
579 {
580 if (false === strpos($content, '[')) {
581 return [];
582 }
583
584 preg_match_all('/' . get_shortcode_regex() . '/', $content, $matches, PREG_SET_ORDER);
585 if (empty($matches)) {
586 return [];
587 }
588
589 $ids = [];
590 $attributes = [];
591
592 foreach ($matches as $shortcode) {
593 if (count($shortcode) >= 2 && $tag === $shortcode[2]) {
594 // Replace braces with empty string.
595 $parsedCode = str_replace(['[', ']', '&#91;', '&#93;'], '', $shortcode[0]);
596
597 $result = shortcode_parse_atts($parsedCode);
598
599 if (!empty($result[$selector])) {
600 if ('fluentform' == $tag && !empty($result['type']) && 'conversational' == $result['type']) {
601 continue;
602 }
603
604 $ids[$result[$selector]] = $result[$selector];
605
606 $theme = ArrayHelper::get($result, 'theme');
607
608 if ($theme) {
609 $attributes[] = [
610 'formId' => $result[$selector],
611 'theme' => $theme,
612 ];
613 }
614 }
615 }
616 }
617
618 if ($attributes) {
619 $ids['attributes'] = $attributes;
620 }
621
622 return $ids;
623 }
624
625 public static function getFormsIdsFromBlocks($content)
626 {
627 $ids = [];
628 $attributes = [];
629
630 if (!function_exists('parse_blocks')) {
631 return $ids;
632 }
633
634 $has_block = false !== strpos($content, '<!-- wp:fluentfom/guten-block ');
635
636 if (!$has_block) {
637 return $ids;
638 }
639
640 $parsedBlocks = parse_blocks($content);
641 foreach ($parsedBlocks as $block) {
642 if (!ArrayHelper::exists($block, 'blockName') || !ArrayHelper::get($block, 'attrs.formId')) {
643 continue;
644 }
645
646 $hasBlock = strpos($block['blockName'], 'fluentfom/guten-block') === 0;
647 if ($hasBlock) {
648 $formId = (int) $block['attrs']['formId'];
649
650 $ids[] = $formId;
651
652 $theme = ArrayHelper::get($block, 'attrs.themeStyle');
653
654 if ($theme) {
655 $attributes[] = [
656 'formId' => $formId,
657 'theme' => $theme,
658 ];
659 }
660 }
661 }
662
663 if ($attributes) {
664 $ids['attributes'] = $attributes;
665 }
666
667 return $ids;
668 }
669
670 public static function isTabIndexEnabled()
671 {
672 if ('na' == static::$tabIndexStatus) {
673 $globalSettings = get_option('_fluentform_global_form_settings');
674 static::$tabIndexStatus = 'yes' == ArrayHelper::get($globalSettings, 'misc.tabIndex');
675 }
676
677 return static::$tabIndexStatus;
678 }
679
680 public static function isMultiStepForm($formOrId)
681 {
682 // Accept both form object and form ID to avoid re-querying
683 if (is_object($formOrId)) {
684 $form = $formOrId;
685 } else {
686 $form = Form::find($formOrId);
687 }
688
689 if (!$form) {
690 return false;
691 }
692
693 $fieldsJson = (string) ($form->form_fields ?? '');
694 if ('' === $fieldsJson) {
695 return false;
696 }
697
698 $fields = json_decode($fieldsJson, true);
699 if (!is_array($fields)) {
700 return false;
701 }
702
703 return (bool) ArrayHelper::get($fields, 'stepsWrapper');
704 }
705
706 public static function hasFormElement($formId, $elementName)
707 {
708 $form = Form::find($formId);
709 $fieldsJson = $form->form_fields;
710
711 return false != strpos($fieldsJson, '"element":"' . $elementName . '"');
712 }
713
714 public static function isUniqueValidation($validation, $field, $formData, $fields, $form)
715 {
716 if ('yes' == ArrayHelper::get($field, 'raw.settings.is_unique')) {
717 $fieldName = ArrayHelper::get($field, 'name');
718 if ($inputValue = ArrayHelper::get($formData, $fieldName)) {
719 $exist = EntryDetails::where('form_id', $form->id)
720 ->where('field_name', $fieldName)
721 ->where('field_value', $inputValue)
722 ->exists();
723
724 // if form has pending payment then the value doesn't exist in EntryDetails table
725 // further checking on Submission table if the value exists
726 if (!$exist && $form->has_payment) {
727 $escapedKey = wp_json_encode($fieldName);
728 $escapedValue = wp_json_encode($inputValue);
729 $searchPattern = trim($escapedKey, '"') . '":' . $escapedValue;
730 $searchPattern = addcslashes($searchPattern, '%_');
731
732 $exist = Submission::where('form_id', $form->id)
733 ->where('response', 'LIKE', '%' . $searchPattern . '%')
734 ->exists();
735 }
736
737 if ($exist) {
738 $typeName = ArrayHelper::get($field, 'element', 'input_text');
739 return [
740 'unique' => apply_filters('fluentform/validation_message_unique_' . $typeName,
741 ArrayHelper::get($field, 'raw.settings.unique_validation_message'), $field),
742 ];
743 }
744 }
745 }
746
747 return $validation;
748 }
749
750
751 public static function hasPartialEntries($formId)
752 {
753 static $cache = [];
754 if (isset($cache[$formId])) {
755 return $cache[$formId];
756 }
757
758 $cache[$formId] = 'yes' == static::getFormMeta($formId, 'form_save_state_status');
759
760 return $cache[$formId];
761 }
762
763 public static function getNumericFormatters()
764 {
765 $data = [
766 'none' => [
767 'value' => '',
768 'label' => 'None',
769 ],
770 'comma_dot_style' => [
771 'value' => 'comma_dot_style',
772 'label' => __('US Style with Decimal (EX: 123,456.00)', 'fluentform'),
773 'settings' => [
774 'decimal' => '.',
775 'separator' => ',',
776 'precision' => 2,
777 'symbol' => '',
778 ],
779 ],
780 'dot_comma_style_zero' => [
781 'value' => 'dot_comma_style_zero',
782 'label' => __('US Style without Decimal (Ex: 123,456,789)', 'fluentform'),
783 'settings' => [
784 'decimal' => '.',
785 'separator' => ',',
786 'precision' => 0,
787 'symbol' => '',
788 ],
789 ],
790 'dot_comma_style' => [
791 'value' => 'dot_comma_style',
792 'label' => __('EU Style with Decimal (Ex: 123.456,00)', 'fluentform'),
793 'settings' => [
794 'decimal' => ',',
795 'separator' => '.',
796 'precision' => 2,
797 'symbol' => '',
798 ],
799 ],
800 'comma_dot_style_zero' => [
801 'value' => 'comma_dot_style_zero',
802 'label' => __('EU Style without Decimal (EX: 123.456.789)', 'fluentform'),
803 'settings' => [
804 'decimal' => ',',
805 'separator' => '.',
806 'precision' => 0,
807 'symbol' => '',
808 ],
809 ],
810 ];
811
812 $data = apply_filters_deprecated(
813 'fluentform_numeric_styles',
814 [
815 $data,
816 ],
817 FLUENTFORM_FRAMEWORK_UPGRADE,
818 'fluentform/numeric_styles',
819 'Use fluentform/numeric_styles instead of fluentform_numeric_styles.'
820 );
821
822 return apply_filters('fluentform/numeric_styles', $data);
823 }
824
825 public static function getNumericValue($input, $formatterName)
826 {
827 $formatters = static::getNumericFormatters();
828 if (empty($formatters[$formatterName]['settings'])) {
829 return $input;
830 }
831 $settings = $formatters[$formatterName]['settings'];
832 $number = floatval(str_replace($settings['decimal'], '.',
833 preg_replace('/[^-?\d' . preg_quote($settings['decimal']) . ']/', '', $input)));
834
835 return number_format($number, $settings['precision'], '.', '');
836 }
837
838 public static function getNumericFormatted($input, $formatterName)
839 {
840 if (!is_numeric($input)) {
841 return $input;
842 }
843 $formatters = static::getNumericFormatters();
844 if (empty($formatters[$formatterName]['settings'])) {
845 return $input;
846 }
847 $settings = $formatters[$formatterName]['settings'];
848
849 return number_format($input, $settings['precision'], $settings['decimal'], $settings['separator']);
850 }
851
852 public static function getDuplicateFieldNames($fields)
853 {
854 $fields = json_decode($fields, true);
855 $items = $fields['fields'];
856 $inputNames = static::getFieldNamesStatuses($items);
857 $uniqueNames = array_unique($inputNames);
858
859 if (count($inputNames) == count($uniqueNames)) {
860 return [];
861 }
862
863 return array_diff_assoc($inputNames, $uniqueNames);
864 }
865
866 public static function getRankingFieldsWithDuplicateOptionValues($fields)
867 {
868 if (is_string($fields)) {
869 $fields = json_decode($fields, true);
870 }
871
872 if (!is_array($fields)) {
873 return [];
874 }
875
876 $items = ArrayHelper::get($fields, 'fields', []);
877
878 if (!is_array($items)) {
879 return [];
880 }
881
882 return static::collectRankingFieldsWithDuplicateOptionValues($items);
883 }
884
885 protected static function getFieldNamesStatuses($fields)
886 {
887 $names = [];
888
889 foreach ($fields as $field) {
890 if ('container' == ArrayHelper::get($field, 'element')) {
891 $columns = ArrayHelper::get($field, 'columns', []);
892 foreach ($columns as $column) {
893 $columnInputs = static::getFieldNamesStatuses(ArrayHelper::get($column, 'fields', []));
894 $names = array_merge($names, $columnInputs);
895 }
896 } elseif ($name = ArrayHelper::get($field, 'attributes.name')) {
897 $names[] = $name;
898 }
899 }
900
901 return $names;
902 }
903
904 protected static function collectRankingFieldsWithDuplicateOptionValues($fields)
905 {
906 $duplicates = [];
907
908 foreach ($fields as $field) {
909 if ('container' === ArrayHelper::get($field, 'element')) {
910 $columns = ArrayHelper::get($field, 'columns', []);
911 foreach ($columns as $column) {
912 $columnFields = ArrayHelper::get($column, 'fields', []);
913 $duplicates = array_merge(
914 $duplicates,
915 static::collectRankingFieldsWithDuplicateOptionValues($columnFields)
916 );
917 }
918 continue;
919 }
920
921 if (!empty($field['fields']) && is_array($field['fields'])) {
922 $duplicates = array_merge(
923 $duplicates,
924 static::collectRankingFieldsWithDuplicateOptionValues($field['fields'])
925 );
926 continue;
927 }
928
929 if ('input_ranking' !== ArrayHelper::get($field, 'element')) {
930 continue;
931 }
932
933 $formattedOptions = ArrayHelper::get($field, 'settings.advanced_options', []);
934 if (!$formattedOptions) {
935 $formattedOptions = [];
936 foreach (ArrayHelper::get($field, 'options', []) as $value => $label) {
937 $formattedOptions[] = [
938 'label' => $label,
939 'value' => $value,
940 ];
941 }
942 }
943
944 $optionValues = array_values(array_filter(array_map(
945 'sanitize_text_field',
946 array_column(static::flattenAdvancedOptions($formattedOptions), 'value')
947 ), function ($value) {
948 return '' !== $value;
949 }));
950
951 if (count($optionValues) !== count(array_unique($optionValues))) {
952 $fieldLabel = ArrayHelper::get($field, 'settings.admin_field_label');
953 if (!$fieldLabel) {
954 $fieldLabel = ArrayHelper::get($field, 'settings.label');
955 }
956 if (!$fieldLabel) {
957 $fieldLabel = ArrayHelper::get($field, 'attributes.name');
958 }
959 $duplicates[] = $fieldLabel ? $fieldLabel : __('Ranking Field', 'fluentform');
960 }
961 }
962
963 return $duplicates;
964 }
965
966 public static function isConversionForm($formId)
967 {
968 static $cache = [];
969 if (isset($cache[$formId])) {
970 return $cache[$formId];
971 }
972
973 $cache[$formId] = 'yes' == static::getFormMeta($formId, 'is_conversion_form');
974
975 return $cache[$formId];
976 }
977
978 public static function getPreviewUrl($formId, $type = '')
979 {
980 if ('conversational' == $type) {
981 return static::getConversionUrl($formId);
982 } elseif ('classic' == $type) {
983 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
984 } elseif (static::isConversionForm($formId)) {
985 return static::getConversionUrl($formId);
986 }
987
988 return site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId) . '#ff_preview';
989 }
990
991 public static function getFormAdminPermalink($route, $form)
992 {
993 $baseUrl = admin_url('admin.php?page=fluent_forms');
994
995 return $baseUrl . '&route=' . $route . '&form_id=' . $form->id;
996 }
997
998 public static function getFormSettingsUrl($form)
999 {
1000 $baseUrl = admin_url('admin.php?page=fluent_forms');
1001
1002 return $baseUrl . '&form_id=' . $form->id . '&route=settings&sub_route=form_settings#basic_settings';
1003 }
1004
1005 private static function getConversionUrl($formId)
1006 {
1007 $meta = static::getFormMeta($formId, 'ffc_form_settings_meta', []);
1008 $key = ArrayHelper::get($meta, 'share_key', '');
1009
1010 $slug = apply_filters_deprecated(
1011 'fluentform_conversational_url_slug',
1012 [
1013 'fluent-form',
1014 ],
1015 FLUENTFORM_FRAMEWORK_UPGRADE,
1016 'fluentform/conversational_url_slug',
1017 'Use fluentform/conversational_url_slug instead of fluentform_conversational_url_slug.'
1018 );
1019
1020 $paramKey = apply_filters('fluentform/conversational_url_slug', $slug);
1021
1022 if ('form' == $paramKey) {
1023 $paramKey = 'fluent-form';
1024 }
1025 if ($key) {
1026 return static::getFrontendFacingUrl('?' . $paramKey . '=' . $formId . '&form=' . $key);
1027 }
1028 return static::getFrontendFacingUrl('?' . $paramKey . '=' . $formId);
1029 }
1030
1031 public static function fileUploadLocations()
1032 {
1033 $locations = [
1034 [
1035 'value' => 'default',
1036 'label' => __('Fluent Forms Default', 'fluentform'),
1037 ],
1038 [
1039 'value' => 'wp_media',
1040 'label' => __('Media Library', 'fluentform'),
1041 ],
1042 ];
1043
1044 $locations = apply_filters_deprecated(
1045 'fluentform_file_upload_options',
1046 [
1047 $locations,
1048 ],
1049 FLUENTFORM_FRAMEWORK_UPGRADE,
1050 'fluentform/file_upload_options',
1051 'Use fluentform/file_upload_options instead of fluentform_file_upload_options'
1052 );
1053
1054 return apply_filters('fluentform/file_upload_options', $locations);
1055 }
1056
1057 public static function unreadCount($formId)
1058 {
1059 return Submission::where('status', 'unread')
1060 ->where('form_id', $formId)
1061 ->count();
1062 }
1063
1064 public static function getForms()
1065 {
1066 $ff_list = Form::select(['id', 'title'])->orderBy('id', 'DESC')->get();
1067 $forms = [];
1068
1069 if (count($ff_list) > 0) {
1070 $forms[0] = esc_html__('Select a Fluent Forms', 'fluentform');
1071 foreach ($ff_list as $form) {
1072 $forms[$form->id] = esc_html($form->title) . ' (' . $form->id . ')';
1073 }
1074 } else {
1075 $forms[0] = esc_html__('Create a Form First', 'fluentform');
1076 }
1077
1078 return $forms;
1079 }
1080
1081 public static function replaceBrTag($content, $with = '')
1082 {
1083 if (is_array($content)) {
1084 foreach ($content as $key => $value) {
1085 $content[$key] = static::replaceBrTag($value, $with);
1086 }
1087 } elseif (static::hasBrTag($content)) {
1088 $content = str_replace('<br />', $with, $content);
1089 }
1090
1091 return $content;
1092 }
1093
1094 public static function hasBrTag($content)
1095 {
1096 return is_string($content) && false !== strpos($content, '<br />');
1097 }
1098
1099 public static function sanitizeForCSV($content)
1100 {
1101 $formulas = ['=', '-', '+', '@', "\t", "\r", "\n"];
1102
1103 $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
1104
1105 if (Str::startsWith($content, $formulas)) {
1106 $content = "'" . $content;
1107 }
1108
1109 return $content;
1110 }
1111
1112 public static function sanitizeOrderValue($orderType = '')
1113 {
1114 $orderBys = ['ASC', 'DESC'];
1115
1116 $orderType = trim(strtoupper($orderType));
1117
1118 return in_array($orderType, $orderBys) ? $orderType : 'DESC';
1119 }
1120
1121 public static function getForm($id)
1122 {
1123 return Form::where('id', $id)->first();
1124 }
1125
1126 public static function shouldHidePassword($formId)
1127 {
1128 $isTruncate = apply_filters_deprecated(
1129 'fluentform_truncate_password_values',
1130 [
1131 true,
1132 $formId,
1133 ],
1134 FLUENTFORM_FRAMEWORK_UPGRADE,
1135 'fluentform/truncate_password_values',
1136 'Use fluentform/truncate_password_values instead of fluentform_truncate_password_values.'
1137 );
1138
1139 return apply_filters('fluentform/truncate_password_values', $isTruncate, $formId) &&
1140 (
1141 (defined('FLUENTFORM_RENDERING_ENTRIES') && FLUENTFORM_RENDERING_ENTRIES) ||
1142 (defined('FLUENTFORM_RENDERING_ENTRY') && FLUENTFORM_RENDERING_ENTRY) ||
1143 (defined('FLUENTFORM_EXPORTING_ENTRIES') && FLUENTFORM_EXPORTING_ENTRIES)
1144 );
1145 }
1146
1147 // make tabular-grid value markdown format
1148 public static function getTabularGridFormatValue(
1149 $girdData,
1150 $field,
1151 $rowJoiner = '<br />',
1152 $colJoiner = ', ',
1153 $type = ''
1154 ) {
1155 if (!$girdData || !$field) {
1156 return '';
1157 }
1158 $girdRows = ArrayHelper::get($field, 'raw.settings.grid_rows', []);
1159 $girdRows = fluentFormSanitizer($girdRows);
1160 $girdCols = ArrayHelper::get($field, 'raw.settings.grid_columns', []);
1161 $girdCols = fluentFormSanitizer($girdCols);
1162
1163 $value = '';
1164 $lastRow = key(array_slice($girdData, -1, 1, true));
1165 foreach ($girdData as $row => $column) {
1166 $_row = $row;
1167 if ($girdRows && isset($girdRows[$row])) {
1168 $row = $girdRows[$row];
1169 }
1170 if ('markdown' === $type) {
1171 $value .= '- *' . $row . '* : ';
1172 } else {
1173 $value .= $row . ': ';
1174 }
1175 if (is_array($column)) {
1176 foreach ($column as $index => $item) {
1177 $_colJoiner = $colJoiner;
1178 if ($girdCols && isset($girdCols[$item])) {
1179 $item = $girdCols[$item];
1180 }
1181 if ((count($column) - 1) == $index) {
1182 $_colJoiner = '';
1183 }
1184 $value .= $item . $_colJoiner;
1185 }
1186 } else {
1187 if ($girdCols && isset($girdCols[$column])) {
1188 $column = $girdCols[$column];
1189 }
1190 $value .= $column;
1191 }
1192 if ($_row != $lastRow) {
1193 $value .= $rowJoiner;
1194 }
1195 }
1196
1197 return $value;
1198 }
1199
1200 public static function getInputNameFromShortCode($value)
1201 {
1202 preg_match('/{+(.*?)}/', $value, $matches);
1203 if ($matches && false !== strpos($matches[1], 'inputs.')) {
1204 return substr($matches[1], strlen('inputs.'));
1205 }
1206
1207 return '';
1208 }
1209
1210 public static function getRestInfo()
1211 {
1212 $config = wpFluentForm('config');
1213
1214 $namespace = $config->get('app.rest_namespace');
1215 $version = $config->get('app.rest_version');
1216 $restUrl = rest_url($namespace . '/' . $version);
1217 $restUrl = rtrim($restUrl, '/\\');
1218
1219 return [
1220 'base_url' => esc_url_raw(rest_url()),
1221 'url' => $restUrl,
1222 'nonce' => wp_create_nonce('wp_rest'),
1223 'namespace' => $namespace,
1224 'version' => $version,
1225 ];
1226 }
1227
1228 public static function getLogInitiator($action, $type = 'log')
1229 {
1230 if ('log' === $type) {
1231 $title = ucwords(implode(' ', preg_split('/(?=[A-Z])/', $action)));
1232 } else {
1233 $title = ucwords(
1234 str_replace(
1235 ['fluentform/integration_notify_', 'fluentform_', '_notification_feed', '_'],
1236 ['', '', '', ' '],
1237 $action
1238 )
1239 );
1240 }
1241
1242 return $title;
1243 }
1244
1245 public static function getIpinfo()
1246 {
1247 return ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.geo_provider_token');
1248 }
1249
1250 public static function isAutoloadCaptchaEnabled()
1251 {
1252 return ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.autoload_captcha');
1253 }
1254
1255 public static function isAutosaveEnabled()
1256 {
1257 $autosaveEnabled = ArrayHelper::get(get_option('_fluentform_global_form_settings'), 'misc.autosave_enabled', 'no');
1258 return 'yes' === $autosaveEnabled;
1259 }
1260
1261 public static function maybeDecryptUrl($url)
1262 {
1263 $uploadDir = str_replace('/', '\/', FLUENTFORM_UPLOAD_DIR . '/temp');
1264 $pattern = "/(?<={$uploadDir}\/).*$/";
1265 preg_match($pattern, $url, $match);
1266 if (!empty($match)) {
1267 $url = str_replace($match[0], Protector::decrypt($match[0]), $url);
1268 }
1269 return $url;
1270 }
1271
1272 public static function arrayFilterRecursive($arrayItems)
1273 {
1274 foreach ($arrayItems as $key => $item) {
1275 is_array($item) && $arrayItems[$key] = self::arrayFilterRecursive($item);
1276 if (empty($arrayItems[$key])) {
1277 unset($arrayItems[$key]);
1278 }
1279 }
1280 return $arrayItems;
1281 }
1282
1283 public static function isBlockEditor()
1284 {
1285 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1286 return defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context'];
1287 }
1288
1289 public static function resolveValidationRulesGlobalOption(&$field)
1290 {
1291 if (isset($field['fields']) && is_array($field['fields'])) {
1292 foreach ($field['fields'] as &$subField) {
1293 static::resolveValidationRulesGlobalOption($subField);
1294 }
1295 } elseif (ArrayHelper::get($field, 'settings.validation_rules')) {
1296 foreach ($field['settings']['validation_rules'] as $key => &$rule) {
1297 if (!isset($rule['global'])) {
1298 $rule['global'] = false;
1299 }
1300 $rule['global_message'] = static::getGlobalDefaultMessage($key);
1301 }
1302 }
1303 }
1304
1305 /**
1306 * Validate form input value against database values
1307 *
1308 * @param $field array Form Field
1309 * @param $formData array From Data
1310 * @param $form object From
1311 * @param $fieldName string optional
1312 * @param $inputValue mixed optional
1313 *
1314 * @return string
1315 * Return Error message on fail. Otherwise, return empty string
1316 */
1317 public static function validateInput($field, $formData, $form, $fieldName = '', $inputValue = [])
1318 {
1319 $error = '';
1320 if (!$fieldName) {
1321 $fieldName = ArrayHelper::get($field, 'name');
1322 }
1323 if (!$fieldName) {
1324 return $error;
1325 }
1326 if (!$inputValue) {
1327 $inputValue = ArrayHelper::get($formData, $fieldName);
1328 }
1329 if ($inputValue) {
1330 $rawField = ArrayHelper::get($field, 'raw');
1331 if (!$rawField) {
1332 $rawField = $field;
1333 }
1334 $fieldType = ArrayHelper::get($rawField, 'element');
1335 $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1336 $options = [];
1337 $otherPrefix = '';
1338 if ('net_promoter_score' === $fieldType) {
1339 $options = array_flip(ArrayHelper::get($rawField, 'options', []));
1340 } elseif ('ratings' == $fieldType) {
1341 $options = array_keys(ArrayHelper::get($rawField, 'options', []));
1342 } elseif ('gdpr_agreement' == $fieldType) {
1343 $options = ['on'];
1344 } elseif ('terms_and_condition' == $fieldType) {
1345 $options = ['on', 'off'];
1346 } elseif (in_array($fieldType, ['input_radio', 'select', 'input_checkbox', 'input_ranking'])) {
1347 if (ArrayHelper::isTrue($rawField, 'attributes.multiple')) {
1348 $fieldType = 'multi_select';
1349 }
1350 $formattedOptions = ArrayHelper::get($rawField, 'settings.advanced_options', []);
1351 if (!$formattedOptions) {
1352 $formattedOptions = [];
1353 foreach (ArrayHelper::get($rawField, 'options', []) as $value => $label) {
1354 $formattedOptions[] = [
1355 'label' => $label,
1356 'value' => $value,
1357 ];
1358 }
1359 // @todo : Update all reference in form templates
1360 }
1361
1362 $options = array_column(self::flattenAdvancedOptions($formattedOptions), 'value');
1363
1364 // Add field-specific __ff_other__ to options if "Other" option is enabled
1365 if (in_array($fieldType, ['input_checkbox', 'input_radio']) &&
1366 ArrayHelper::get($rawField, 'settings.enable_other_option') === 'yes') {
1367 $fieldName = sanitize_key(str_replace(['[', ']'], '', ArrayHelper::get($rawField, 'attributes.name', '')));
1368 $options[] = '__ff_other_' . $fieldName . '__';
1369 $otherPrefix = static::getOtherOptionValuePrefix($rawField);
1370 }
1371 } elseif ('dynamic_field' == $fieldType) {
1372 $dynamicFetchValue = 'yes' == ArrayHelper::get($rawField, 'settings.dynamic_fetch');
1373 if ($dynamicFetchValue) {
1374 $rawField = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $rawField);
1375 }
1376 $dfElementType = ArrayHelper::get($rawField, 'attributes.type');
1377 if (in_array($dfElementType, ['radio', 'select', 'checkbox'])) {
1378 $fieldType = 'dynamic_field_options';
1379 $options = array_column(
1380 ArrayHelper::get($rawField, 'settings.advanced_options', []),
1381 'value'
1382 );
1383 }
1384 }
1385
1386 if ($options) {
1387 $options = array_map('sanitize_text_field', $options);
1388 }
1389
1390 $isValid = true;
1391 switch ($fieldType) {
1392 case 'input_ranking':
1393 $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1394 if ($skipValidationInputsWithOptions) {
1395 break;
1396 }
1397
1398 if (!is_array($inputValue)) {
1399 $isValid = false;
1400 break;
1401 }
1402
1403 $filteredValues = array_values(array_filter(array_map('sanitize_text_field', $inputValue), function ($value) {
1404 return '' !== $value;
1405 }));
1406
1407 $normalizedOptions = array_values(array_filter(array_map('sanitize_text_field', $options), function ($value) {
1408 return '' !== $value;
1409 }));
1410
1411 sort($filteredValues);
1412 sort($normalizedOptions);
1413
1414 $isValid = count($inputValue) === count($options)
1415 && count($filteredValues) === count(array_unique($filteredValues))
1416 && $filteredValues === $normalizedOptions;
1417 break;
1418 case 'input_radio':
1419 case 'select':
1420 case 'net_promoter_score':
1421 case 'ratings':
1422 case 'gdpr_agreement':
1423 case 'terms_and_condition':
1424 case 'input_checkbox':
1425 case 'multi_select':
1426 case 'dynamic_field_options':
1427 $skipValidationInputsWithOptions = apply_filters('fluentform/skip_validation_inputs_with_options', false, $fieldType, $form, $formData);
1428 if ($skipValidationInputsWithOptions) {
1429 break;
1430 }
1431 if (is_array($inputValue)) {
1432 // Skip "Other" values — raw, localized or legacy English prefix
1433 $filteredValues = array_filter($inputValue, function ($value) use ($otherPrefix) {
1434 return !preg_match('/^__ff_other_.*__$/', $value) &&
1435 !preg_match('/^Other:\s/', $value) &&
1436 !($otherPrefix && 0 === strpos($value, $otherPrefix));
1437 });
1438 $isValid = array_diff($filteredValues, $options);
1439 $isValid = empty($isValid);
1440 } elseif (preg_match('/^__ff_other_.*__$/', $inputValue) ||
1441 preg_match('/^Other:\s/', $inputValue) ||
1442 ($otherPrefix && 0 === strpos($inputValue, $otherPrefix))) {
1443 // Accept "Other" values — raw, localized or legacy English prefix
1444 $isValid = true;
1445 } else {
1446 $isValid = in_array($inputValue, $options);
1447 }
1448 break;
1449 case 'input_number':
1450 if (is_array($inputValue)) {
1451 $hasNonNumricValue = in_array(false, array_map('is_numeric', $inputValue));
1452 if ($hasNonNumricValue) {
1453 $isValid = false;
1454 }
1455 } else {
1456 $isValid = is_numeric($inputValue);
1457 }
1458 break;
1459 case 'select_country':
1460 $fieldData = ArrayHelper::get($field, 'raw');
1461 $data = (new SelectCountry())->loadCountries($fieldData);
1462 $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
1463 $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
1464 $isValid = in_array($inputValue, $validCountries);
1465 break;
1466 case 'repeater_field':
1467 case 'repeater_container':
1468 foreach (ArrayHelper::get($rawField, 'fields', []) as $index => $repeaterField) {
1469 $repeaterFieldValue = array_filter(array_column($inputValue, $index));
1470 if ($repeaterFieldValue && $error = static::validateInput($repeaterField, $formData, $form,
1471 $fieldName, $repeaterFieldValue)) {
1472 $isValid = false;
1473 break;
1474 }
1475 }
1476 break;
1477 case 'tabular_grid':
1478 $rows = array_keys(ArrayHelper::get($rawField, 'settings.grid_rows', []));
1479 $rows = array_map(function ($row) {
1480 return trim(sanitize_text_field($row));
1481 }, $rows);
1482
1483 $submittedRows = array_keys(ArrayHelper::get($formData, $fieldName, []));
1484 $submittedRows = array_map('trim', $submittedRows);
1485
1486 $rowDiff = array_diff($submittedRows, $rows);
1487
1488 $isValid = empty($rowDiff);
1489 if ($isValid) {
1490 $columns = array_keys(ArrayHelper::get($rawField, 'settings.grid_columns', []));
1491 $columns = array_map(function ($column) {
1492 return trim(sanitize_text_field($column));
1493 }, $columns);
1494 $submittedCols = ArrayHelper::flatten(ArrayHelper::get($formData, $fieldName, []));
1495 $submittedCols = array_map('trim', $submittedCols);
1496 $colDiff = array_diff($submittedCols, $columns);
1497 $isValid = empty($colDiff);
1498 }
1499 break;
1500 default:
1501 break;
1502 }
1503 if (!$isValid) {
1504 $error = __('The given data was invalid', 'fluentform');
1505 }
1506 }
1507 return $error;
1508 }
1509
1510 /**
1511 * Enforce how many options a field allows the user to pick.
1512 *
1513 * A field with no selections is left alone, so a floor never turns an
1514 * optional field into a required one — that is what `required` is for.
1515 *
1516 * @param array $rawField
1517 * @param mixed $inputValue
1518 * @return array rule name => message, empty when within the limits
1519 */
1520 public static function validateSelectionLimits($rawField, $inputValue)
1521 {
1522 // Distinct choices, not array entries. The same option repeated is one
1523 // answer: counting entries lets a crafted post satisfy a floor of two by
1524 // sending one option twice, and lets padding manufacture a ceiling breach
1525 // the visitor never made. No UI can produce a duplicate, so this only
1526 // ever arrives crafted. A filled-in "Other" is a single element and still
1527 // counts once.
1528 $selected = is_array($inputValue) ? count(array_unique(array_filter($inputValue, function ($value) {
1529 return '' !== $value && null !== $value;
1530 }))) : (('' === $inputValue || null === $inputValue) ? 0 : 1);
1531
1532 if (!$selected) {
1533 return [];
1534 }
1535
1536 $rules = ArrayHelper::get($rawField, 'settings.validation_rules', []);
1537
1538 $limits = [
1539 'min_selection' => ArrayHelper::get($rules, 'min_selection.value'),
1540 'max_selection' => static::resolveMaxSelection($rawField),
1541 ];
1542
1543 $errors = [];
1544
1545 foreach ($limits as $rule => $limit) {
1546 // '' is how "no limit" ships, so it must never mean a limit of zero.
1547 if ('' === $limit || null === $limit || !is_numeric($limit)) {
1548 continue;
1549 }
1550
1551 $limit = (int) $limit;
1552
1553 if ($limit < 1) {
1554 continue;
1555 }
1556
1557 $breached = 'min_selection' === $rule ? $selected < $limit : $selected > $limit;
1558
1559 if ($breached) {
1560 $errors[$rule] = static::getSelectionLimitMessage($rules, $rule);
1561 }
1562 }
1563
1564 return $errors;
1565 }
1566
1567 /**
1568 * The effective ceiling for a field, preferring the rule over the legacy
1569 * `settings.max_selection`.
1570 *
1571 * The rule takes ownership as soon as its KEY exists, empty value included.
1572 * Falling back on an empty value instead would make the limit unremovable:
1573 * nothing writes to the legacy setting any more, so clearing the box in the
1574 * editor would silently drop back to whatever was frozen there.
1575 *
1576 * @param array $field
1577 * @return mixed
1578 */
1579 public static function resolveMaxSelection($field)
1580 {
1581 $rules = ArrayHelper::get($field, 'settings.validation_rules', []);
1582
1583 if (is_array($rules) && array_key_exists('max_selection', $rules)) {
1584 return ArrayHelper::get($rules, 'max_selection.value');
1585 }
1586
1587 return ArrayHelper::get($field, 'settings.max_selection');
1588 }
1589
1590 /**
1591 * The field's own wording for a breached limit, or the site-wide default.
1592 *
1593 * @param array $rules
1594 * @param string $rule
1595 * @return string
1596 */
1597 public static function getSelectionLimitMessage($rules, $rule)
1598 {
1599 // `global_message` is a copy taken when the field was last saved, so it
1600 // goes stale the moment Global Settings change — resolve the live value
1601 // the way every other rule does.
1602 $message = ArrayHelper::isTrue($rules, $rule . '.global')
1603 ? static::getGlobalDefaultMessage($rule)
1604 : ArrayHelper::get($rules, $rule . '.message');
1605
1606 if (!$message) {
1607 $message = static::getGlobalDefaultMessage($rule);
1608 }
1609
1610 return apply_filters('fluentform/selection_limit_message', $message, $rule, $rules);
1611 }
1612
1613 /**
1614 * Prefix used to store a checkable field's "Other" option value,
1615 * built from the field's own (translated) label. Pass $form to run
1616 * the field through the rendering filter (translation plugins) first.
1617 *
1618 * @param array $rawField
1619 * @param object|null $form
1620 * @return string
1621 */
1622 public static function getOtherOptionValuePrefix($rawField, $form = null)
1623 {
1624 $fieldType = ArrayHelper::get($rawField, 'element');
1625 if ($form && $fieldType) {
1626 $rawField = apply_filters('fluentform/rendering_field_data_' . $fieldType, $rawField, $form);
1627 }
1628
1629 $label = trim((string) ArrayHelper::get($rawField, 'settings.other_option_label'));
1630
1631 if ('' === $label) {
1632 $label = __('Other', 'fluentform');
1633 }
1634
1635 // Avoid "::" when the label already ends with a colon
1636 return ':' === substr($label, -1) ? $label . ' ' : $label . ': ';
1637 }
1638
1639 public static function getWhiteListedFields($formId)
1640 {
1641 $whiteListedFields = [
1642 '__fluent_form_embded_post_id',
1643 '_fluentform_' . $formId . '_fluentformnonce',
1644 '_wp_http_referer',
1645 'g-recaptcha-response',
1646 'h-captcha-response',
1647 'cf-turnstile-response',
1648 '__stripe_payment_method_id',
1649 '__ff_all_applied_coupons',
1650 '__entry_intermediate_hash',
1651 '__square_payment_method_id',
1652 '__square_verify_buyer_id',
1653 'ct_bot_detector_event_token',
1654 'ff_ct_form_load_time',
1655 ];
1656
1657 return apply_filters('fluentform/white_listed_fields', $whiteListedFields, $formId);
1658 }
1659
1660 /**
1661 * Shortcode parse on validation message
1662 *
1663 * @param string $message
1664 * @param object $form
1665 * @param string $fieldName
1666 * @return string
1667 */
1668 public static function shortCodeParseOnValidationMessage($message, $form, $fieldName)
1669 {
1670 // Return early if form is null to prevent errors
1671 if (null === $form) {
1672 return $message;
1673 }
1674
1675 // For validation message there is no entry & form data
1676 // Add 'current_field' name as data array to resolve {labels.current_field} shortcode if it has
1677 return ShortCodeParser::parse(
1678 $message,
1679 (object) ['response' => '', 'form_id' => $form->id],
1680 ['current_field' => $fieldName],
1681 $form
1682 );
1683 }
1684
1685 public static function getAjaxUrl()
1686 {
1687 return apply_filters('fluentform/ajax_url', admin_url('admin-ajax.php'));
1688 }
1689
1690 public static function getDefaultDateTimeFormatForMoment()
1691 {
1692 $phpFormat = get_option('date_format') . ' ' . get_option('time_format');
1693
1694 $replacements = [
1695 'A' => 'A', // for the sake of escaping below
1696 'a' => 'a', // for the sake of escaping below
1697 'B' => '', // Swatch internet time (.beats), no equivalent
1698 'c' => 'YYYY-MM-DD[T]HH:mm:ssZ', // ISO 8601
1699 'D' => 'ddd',
1700 'd' => 'DD',
1701 'e' => 'zz', // deprecated since version 1.6.0 of moment.js
1702 'F' => 'MMMM',
1703 'G' => 'H',
1704 'g' => 'h',
1705 'H' => 'HH',
1706 'h' => 'hh',
1707 'I' => '', // Daylight Saving Time? => moment().isDST();
1708 'i' => 'mm',
1709 'j' => 'D',
1710 'L' => '', // Leap year? => moment().isLeapYear();
1711 'l' => 'dddd',
1712 'M' => 'MMM',
1713 'm' => 'MM',
1714 'N' => 'E',
1715 'n' => 'M',
1716 'O' => 'ZZ',
1717 'o' => 'YYYY',
1718 'P' => 'Z',
1719 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ', // RFC 2822
1720 'S' => 'o',
1721 's' => 'ss',
1722 'T' => 'z', // deprecated since version 1.6.0 of moment.js
1723 't' => '', // days in the month => moment().daysInMonth();
1724 'U' => 'X',
1725 'u' => 'SSSSSS', // microseconds
1726 'v' => 'SSS', // milliseconds (from PHP 7.0.0)
1727 'W' => 'W', // for the sake of escaping below
1728 'w' => 'e',
1729 'Y' => 'YYYY',
1730 'y' => 'YY',
1731 'Z' => '', // time zone offset in minutes => moment().zone();
1732 'z' => 'DDD',
1733 ];
1734
1735 // Converts escaped characters.
1736 foreach ($replacements as $from => $to) {
1737 $replacements['\\' . $from] = '[' . $from . ']';
1738 }
1739
1740 $format = strtr($phpFormat, $replacements);
1741
1742 return apply_filters('fluentform/moment_date_time_format', $format);
1743 }
1744
1745 public static function isDefaultWPDateEnabled()
1746 {
1747 $globalSettings = get_option('_fluentform_global_form_settings');
1748 return 'wp_default' === ArrayHelper::get($globalSettings, 'misc.default_admin_date_time');
1749 }
1750
1751 public static function isPaymentCompatible()
1752 {
1753 if (!self::hasPro()) {
1754 return true;
1755 } else {
1756 return version_compare(FLUENTFORMPRO_VERSION, FLUENTFORM_MINIMUM_PRO_VERSION, '>=');
1757 }
1758 }
1759
1760 /**
1761 * Determine pro payment script is compatible or not
1762 * Script is compatible if pro version meets the minimum required version
1763 *
1764 * @return bool
1765 */
1766 public static function isProPaymentScriptCompatible()
1767 {
1768 if (self::hasPro()) {
1769 return version_compare(FLUENTFORMPRO_VERSION, FLUENTFORM_MINIMUM_PRO_VERSION, '>=');
1770 }
1771 return false;
1772 }
1773
1774 public static function hasPro()
1775 {
1776 return defined('FLUENTFORMPRO');
1777 }
1778
1779 public static function utmUrl($baseUrl, $utmContent = '', $utmCampaign = 'upgrade_pro')
1780 {
1781 $params = [
1782 'utm_source' => 'fluent-forms',
1783 'utm_medium' => self::hasPro() ? 'pro_plugin' : 'free_plugin',
1784 'utm_campaign' => $utmCampaign,
1785 'utm_term' => FLUENTFORM_VERSION,
1786 'theme_style' => fluentform_get_active_theme_slug(),
1787 ];
1788
1789 if ($utmContent) {
1790 $params['utm_content'] = $utmContent;
1791 }
1792
1793 return add_query_arg($params, $baseUrl);
1794 }
1795
1796 public static function getLandingPageEnabledForms()
1797 {
1798 if (class_exists(\FluentFormPro\classes\SharePage\SharePage::class)) {
1799 if (method_exists(\FluentFormPro\classes\SharePage\SharePage::class, 'getLandingPageFormIds')) {
1800 $sharePage = new \FluentFormPro\classes\SharePage\SharePage();
1801 return $sharePage->getLandingPageFormIds();
1802 }
1803 }
1804 return [];
1805 }
1806
1807 public static function sanitizeArrayKeysAndValues($values)
1808 {
1809 if (is_array($values)) {
1810 $sanitized = [];
1811 foreach ($values as $key => $value) {
1812 $trimmedKey = sanitize_text_field(trim($key));
1813 $trimmedValue = sanitize_text_field(trim($value));
1814 $sanitized[$trimmedKey] = $trimmedValue;
1815 }
1816 return $sanitized;
1817 }
1818 return sanitize_text_field(trim($values));
1819 }
1820 public static function getFrontendFacingUrl($args = '')
1821 {
1822 return home_url($args);
1823 }
1824
1825 public static function getCountryCodeFromHeaders($forRestriction = false)
1826 {
1827 // SECURITY (FINDING-26): CDN country headers are client-spoofable. Trust them for analytics
1828 // storage (spoof is cosmetic) but not for restriction enforcement (spoof = bypass). Filterable.
1829 $trustHeaders = apply_filters('fluentform/trust_geo_headers', !$forRestriction);
1830 if (!$trustHeaders) {
1831 return null;
1832 }
1833
1834 $headers = [
1835 // Cloudflare (most common)
1836 'HTTP_CF_IPCOUNTRY',
1837 'CF-IPCountry',
1838
1839 // AWS CloudFront (widely used)
1840 'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
1841 'CloudFront-Viewer-Country',
1842
1843 // Common standard headers
1844 'HTTP_X_COUNTRY_CODE',
1845 'X-Country-Code',
1846 'HTTP_X_FORWARDED_COUNTRY',
1847 'X-Forwarded-Country',
1848
1849 // GeoIP (used by many systems)
1850 'HTTP_GEOIP_COUNTRY_CODE',
1851 'GEOIP_COUNTRY_CODE',
1852 'HTTP_X_GEOIP_COUNTRY',
1853 'X-GeoIP-Country',
1854
1855 // General purpose country headers
1856 'HTTP_X_COUNTRY',
1857 'X-Country',
1858 'HTTP_X_COUNTRY_ISO',
1859 'X-Country-ISO',
1860 ];
1861
1862 foreach ($headers as $header) {
1863 // Try directly from $_SERVER
1864 if (isset($_SERVER[$header])) {
1865 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1866 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$header])));
1867 } elseif (strpos($header, 'HTTP_') !== 0) {
1868 // Try with HTTP_ prefix if not already present
1869 $httpHeader = 'HTTP_' . str_replace('-', '_', strtoupper($header));
1870 if (isset($_SERVER[$httpHeader])) {
1871 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Country code from CDN/proxy header, validated below
1872 $code = trim(sanitize_text_field(wp_unslash($_SERVER[$httpHeader])));
1873 } else {
1874 continue;
1875 }
1876 } else {
1877 continue;
1878 }
1879
1880 // Basic validation - should be 2-letter country code
1881 if (!empty($code) && is_string($code) && 2 === strlen($code) && ctype_alpha($code) && 'XX' !== $code) {
1882 return strtoupper($code);
1883 }
1884 }
1885
1886 return null;
1887 }
1888
1889 /**
1890 * Fixes PHP Object Injection Vulnerability
1891 *
1892 * @param $data
1893 * @return mixed
1894 */
1895 public static function safeUnserialize($data)
1896 {
1897 if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1898 return @unserialize(trim($data), ['allowed_classes' => false]);
1899 }
1900 return $data;
1901 }
1902
1903 /**
1904 * If elementor editor is open
1905 *
1906 * @return bool
1907 */
1908 public static function isElementorEditor()
1909 {
1910 return defined('ELEMENTOR_VERSION') &&
1911 class_exists('\Elementor\Plugin') &&
1912 isset(\Elementor\Plugin::$instance) &&
1913 \Elementor\Plugin::$instance->editor->is_edit_mode();
1914 }
1915
1916 /**
1917 * Check if we're in block editor context (Site Editor, Template Editor, or Post/Page Editor)
1918 * Covers all Gutenberg block editor contexts including mobile/tablet preview iframes
1919 *
1920 * @return bool
1921 */
1922 public static function isSiteEditor()
1923 {
1924 if (!is_admin() || !function_exists('get_current_screen')) {
1925 return false;
1926 }
1927
1928 $screen = get_current_screen();
1929
1930 // Check for Site Editor and Template Editor contexts
1931 if ($screen && in_array($screen->base, ['site-editor', 'edit-site', 'appearance_page_gutenberg-edit-site'], true )) {
1932 return true;
1933 }
1934
1935 // Check for Post/Page block editor contexts
1936 if ($screen && in_array($screen->base, ['post', 'page'], true) && $screen->is_block_editor()) {
1937 return true;
1938 }
1939
1940 // Check for custom post types with block editor
1941 if ($screen && $screen->is_block_editor()) {
1942 return true;
1943 }
1944
1945 // Fallback checks for various block editor contexts
1946 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- REQUEST_URI used for string comparison only
1947 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
1948 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking block editor context
1949 return isset( $_GET['_wp-find-template'] ) ||
1950 strpos( $request_uri, 'site-editor.php' ) !== false ||
1951 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking REST API context
1952 (defined('REST_REQUEST') && REST_REQUEST && !empty($_REQUEST['context']) && 'edit' === $_REQUEST['context']);
1953 }
1954 }
1955