PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
bit-form / includes / Core / WorkFlow / DateConditionComparator.php

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

350 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BitCode\BitForm\Core\WorkFlow;
4
5 /**
6 * Evaluates workflow conditions on date-like fields.
7 *
8 * The builder saves date condition values quoted ('2026-01-01') so the math evaluator does not
9 * turn them into a subtraction, and the submitted value arrives in the field's own shape (Y-m-d,
10 * H:i, Y-Www, Y-m, or a flatpickr format for advanced-datetime). Comparing those raw strings never
11 * matched, so both sides are reduced to a sortable number first. Keep the semantics in step with
12 * bit-helpers/src/bfDateFieldsLogicCheck.js, which runs the same rules in the browser.
13 */
14 final class DateConditionComparator
15 {
16 public const DATE_TYPES = ['date', 'time', 'datetime-local', 'week', 'month', 'advanced-datetime'];
17
18 // flatpickr token => DateTime::createFromFormat() token. null = no parseable equivalent.
19 private const FLATPICKR_TOKENS = [
20 'd' => 'd',
21 'D' => 'D',
22 'l' => 'l',
23 'j' => 'j',
24 'J' => 'jS',
25 'w' => '?',
26 'W' => null,
27 'F' => 'F',
28 'm' => 'm',
29 'n' => 'n',
30 'M' => 'M',
31 'U' => 'U',
32 'u' => null,
33 'y' => 'y',
34 'Y' => 'Y',
35 'Z' => null,
36 'H' => 'H',
37 // flatpickr's h is the unpadded 12-hour clock and G the padded one, the reverse of PHP.
38 'h' => 'g',
39 'G' => 'h',
40 'i' => 'i',
41 'S' => 's',
42 's' => 's',
43 'K' => 'A',
44 ];
45
46 private const COMPARABLE_LOGICS = ['equal', 'not_equal', 'greater', 'less', 'greater_or_equal', 'less_or_equal', 'between', 'contain', 'not_contain'];
47
48 /**
49 * @param mixed $type field type
50 *
51 * @return bool
52 */
53 public static function isDateType($type)
54 {
55 return is_string($type) && in_array($type, self::DATE_TYPES, true);
56 }
57
58 /**
59 * The format an advanced-datetime field actually posts. With Enable Time on and a date-only
60 * Value Format, the runtime appends a time token (bit-advanced-datetime-field.js) so the picked
61 * time is not silently dropped; mirror that here so the posted value still parses.
62 *
63 * @param mixed $config field config object/array
64 *
65 * @return string flatpickr format
66 */
67 public static function advancedValueFormat($config)
68 {
69 $config = is_array($config) ? (object) $config : $config;
70 $format = is_object($config) && !empty($config->dateFormat) && is_string($config->dateFormat) ? $config->dateFormat : 'Y-m-d';
71 if (is_object($config) && !empty($config->enableTime) && !self::hasTimeToken($format)) {
72 $format .= !empty($config->time_24hr) ? ' H:i' : ' h:i K';
73 }
74 return $format;
75 }
76
77 /**
78 * @param string $format flatpickr format
79 *
80 * @return bool
81 */
82 public static function hasTimeToken($format)
83 {
84 // Strip escaped characters first: "\h" is a literal h, not an hour.
85 return 1 === preg_match('/[HhGiSsK]/', (string) preg_replace('/\\\\./', '', (string) $format));
86 }
87
88 /**
89 * @param string $logic condition operator
90 * @param mixed $fieldValue submitted value
91 * @param mixed $conditionValue condition value (quoted string, or {min,max} for between)
92 * @param string $type field type
93 * @param string $format flatpickr value format (advanced-datetime only)
94 *
95 * @return bool|null null when the operator is not a date comparison, so the caller keeps its
96 * generic behaviour
97 */
98 public static function compare($logic, $fieldValue, $conditionValue, $type, $format = '')
99 {
100 $logic = strtolower((string) $logic);
101 if ('null' === $logic) {
102 return self::isEmptyValue($fieldValue);
103 }
104 if ('not_null' === $logic) {
105 return !self::isEmptyValue($fieldValue);
106 }
107 if (!in_array($logic, self::COMPARABLE_LOGICS, true)) {
108 return null;
109 }
110
111 $rawFieldValue = self::stripQuotes(self::scalarValue($fieldValue));
112 $fieldDates = self::parseValues($rawFieldValue, $type, $format);
113
114 if ('between' === $logic) {
115 list($min, $max) = self::betweenBounds($conditionValue, $type, $format);
116 if (empty($fieldDates) || null === $min || null === $max) {
117 return false;
118 }
119 foreach ($fieldDates as $date) {
120 if ($date < $min || $date > $max) {
121 return false;
122 }
123 }
124 return true;
125 }
126
127 $rawConditionValue = self::stripQuotes(self::scalarValue($conditionValue));
128 $target = self::parse($rawConditionValue, $type, $format);
129
130 if (in_array($logic, ['equal', 'not_equal', 'contain', 'not_contain'], true)) {
131 $matches = null !== $target
132 ? in_array($target, $fieldDates, true)
133 : '' !== $rawConditionValue && $rawFieldValue === $rawConditionValue;
134 if (!$matches && in_array($logic, ['contain', 'not_contain'], true)) {
135 $matches = '' !== $rawConditionValue && false !== stripos($rawFieldValue, $rawConditionValue);
136 }
137 return in_array($logic, ['equal', 'contain'], true) ? $matches : !$matches;
138 }
139
140 if (null === $target || empty($fieldDates)) {
141 return false;
142 }
143 // A range or multi-date selection satisfies an ordering only when every picked date does.
144 foreach ($fieldDates as $date) {
145 if (!self::satisfiesOrder($logic, $date, $target)) {
146 return false;
147 }
148 }
149 return true;
150 }
151
152 /**
153 * Reduce one date-like string to a sortable number.
154 *
155 * @param string $value unquoted value
156 * @param string $type field type
157 * @param string $format flatpickr value format (advanced-datetime only)
158 *
159 * @return float|int|null null when the value cannot be read as this type
160 */
161 public static function parse($value, $type, $format = '')
162 {
163 $value = trim((string) $value);
164 if ('' === $value) {
165 return null;
166 }
167
168 switch ($type) {
169 case 'time':
170 if (1 === preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $match)) {
171 return (int) $match[1] * 3600 + (int) $match[2] * 60 + (int) ($match[3] ?? 0);
172 }
173 return null;
174
175 case 'month':
176 if (1 === preg_match('/^(\d{4})-(\d{1,2})$/', $value, $match) && (int) $match[2] >= 1 && (int) $match[2] <= 12) {
177 return (int) $match[1] * 12 + (int) $match[2] - 1;
178 }
179 return null;
180
181 case 'week':
182 if (1 === preg_match('/^(\d{4})-?W(\d{1,2})$/i', $value, $match) && (int) $match[2] >= 1 && (int) $match[2] <= 53) {
183 return (int) $match[1] * 100 + (int) $match[2];
184 }
185 return null;
186
187 case 'advanced-datetime':
188 // ISO first: flatpickr's own parser is lenient and reads "2025-03-15" against d/m/Y as
189 // day 20, so the browser tries ISO first too and both sides must agree.
190 $timestamp = self::parseIso($value);
191 return null !== $timestamp ? $timestamp : self::parseWithFlatpickrFormat($value, (string) $format);
192
193 default:
194 return self::parseIso($value);
195 }
196 }
197
198 /**
199 * Convert a flatpickr format to a DateTime::createFromFormat() format.
200 *
201 * @param string $format flatpickr format
202 *
203 * @return string|null null when the format holds a token PHP cannot parse
204 */
205 public static function flatpickrToPhpFormat($format)
206 {
207 $format = (string) $format;
208 if ('' === $format) {
209 return null;
210 }
211 $phpFormat = '';
212 $length = strlen($format);
213 for ($i = 0; $i < $length; $i++) {
214 $char = $format[$i];
215 if ('\\' === $char) {
216 $i++;
217 if ($i < $length) {
218 $phpFormat .= '\\' . $format[$i];
219 }
220 continue;
221 }
222 if (array_key_exists($char, self::FLATPICKR_TOKENS)) {
223 if (null === self::FLATPICKR_TOKENS[$char]) {
224 return null;
225 }
226 $phpFormat .= self::FLATPICKR_TOKENS[$char];
227 continue;
228 }
229 // Any other letter is a format character to PHP but a literal to flatpickr.
230 $phpFormat .= ctype_alpha($char) ? '\\' . $char : $char;
231 }
232 return $phpFormat;
233 }
234
235 private static function parseWithFlatpickrFormat($value, $format)
236 {
237 $phpFormat = self::flatpickrToPhpFormat($format);
238 if (null === $phpFormat) {
239 return null;
240 }
241 // '!' zeroes every unparsed part, so a date-only format means midnight on both sides.
242 $date = \DateTime::createFromFormat('!' . $phpFormat, $value, new \DateTimeZone('UTC'));
243 if (false === $date) {
244 return null;
245 }
246 $errors = \DateTime::getLastErrors();
247 if (is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
248 return null;
249 }
250 return $date->getTimestamp();
251 }
252
253 private static function parseIso($value)
254 {
255 if (1 !== preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/', $value, $match)) {
256 return null;
257 }
258 if (!checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
259 return null;
260 }
261 return gmmktime((int) ($match[4] ?? 0), (int) ($match[5] ?? 0), (int) ($match[6] ?? 0), (int) $match[2], (int) $match[3], (int) $match[1]);
262 }
263
264 /**
265 * Split a submitted value into its dates. advanced-datetime in range mode posts
266 * "A to B" and in multiple mode "A, B"; the format itself may contain ", " (F j, Y), so
267 * segments are re-joined until each one parses.
268 *
269 * @return array<int|float>
270 */
271 private static function parseValues($value, $type, $format)
272 {
273 $single = self::parse($value, $type, $format);
274 if (null !== $single) {
275 return [$single];
276 }
277 if ('advanced-datetime' !== $type || '' === $value) {
278 return [];
279 }
280 foreach ([' to ', ','] as $separator) {
281 $parts = explode($separator, $value);
282 if (count($parts) < 2) {
283 continue;
284 }
285 $dates = [];
286 $buffer = null;
287 foreach ($parts as $part) {
288 $buffer = null === $buffer ? $part : $buffer . $separator . $part;
289 $parsed = self::parse($buffer, $type, $format);
290 if (null !== $parsed) {
291 $dates[] = $parsed;
292 $buffer = null;
293 }
294 }
295 if (null === $buffer) {
296 return $dates;
297 }
298 }
299 return [];
300 }
301
302 private static function betweenBounds($conditionValue, $type, $format)
303 {
304 $bounds = is_string($conditionValue) ? json_decode($conditionValue) : $conditionValue;
305 if (is_array($bounds)) {
306 $bounds = (object) $bounds;
307 }
308 if (!is_object($bounds)) {
309 return [null, null];
310 }
311 $min = isset($bounds->min) ? self::parse(self::stripQuotes(self::scalarValue($bounds->min)), $type, $format) : null;
312 $max = isset($bounds->max) ? self::parse(self::stripQuotes(self::scalarValue($bounds->max)), $type, $format) : null;
313 return [$min, $max];
314 }
315
316 private static function satisfiesOrder($logic, $date, $target)
317 {
318 switch ($logic) {
319 case 'greater':
320 return $date > $target;
321 case 'less':
322 return $date < $target;
323 case 'greater_or_equal':
324 return $date >= $target;
325 case 'less_or_equal':
326 return $date <= $target;
327 default:
328 return false;
329 }
330 }
331
332 private static function isEmptyValue($value)
333 {
334 return '' === self::stripQuotes(self::scalarValue($value));
335 }
336
337 private static function scalarValue($value)
338 {
339 if (is_array($value)) {
340 $value = reset($value);
341 }
342 return is_scalar($value) ? (string) $value : '';
343 }
344
345 private static function stripQuotes($value)
346 {
347 return trim(preg_replace('/^\s*[\'"]|[\'"]\s*$/', '', (string) $value));
348 }
349 }
350