| 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 |
|