PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-conditional-logic.php
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form Last commit date
css 3 months ago images 6 days ago js 3 months ago libs 3 months ago templates 4 months ago class-conditional-logic.php 6 days ago class-contact-form-endpoint.php 2 weeks ago class-contact-form-field.php 6 days ago class-contact-form-plugin.php 6 days ago class-contact-form-shortcode.php 3 months ago class-contact-form.php 6 days ago class-editor-view.php 6 months ago class-feedback-author.php 6 days ago class-feedback-email-renderer.php 6 days ago class-feedback-field.php 6 days ago class-feedback-source.php 1 month ago class-feedback.php 6 days ago class-form-preview.php 6 days ago class-form-submission-error.php 9 months ago class-jetpack-form-endpoint.php 1 month ago class-util.php 1 month ago trait-country-code-utils.php 7 months ago
class-conditional-logic.php
631 lines
1 <?php
2 /**
3 * Conditional Logic evaluator for Jetpack form fields.
4 *
5 * Mirrors the JS implementation in
6 * src/blocks/shared/conditional-logic/util/evaluate.ts and MUST stay in sync.
7 *
8 * The browser decides what a visitor sees; this class decides what gets validated and
9 * stored. A disagreement between the two either discards a real answer or accepts one for
10 * a field that was never shown, so Conditional_Logic_Parity_Test pins the operator
11 * vocabulary and Conditional_Logic_Test mirrors the JS cases one for one.
12 *
13 * Targets PHP 7.2: no arrow functions, no typed properties, no match expressions.
14 *
15 * @package automattic/jetpack-forms
16 */
17
18 namespace Automattic\Jetpack\Forms\ContactForm;
19
20 /**
21 * Pure evaluator for field conditional-logic rules.
22 */
23 class Conditional_Logic {
24
25 const OP_IS = 'is';
26 const OP_IS_NOT = 'is_not';
27 const OP_CONTAINS = 'contains';
28 const OP_DOES_NOT_CONTAIN = 'does_not_contain';
29 const OP_IS_EMPTY = 'is_empty';
30 const OP_IS_NOT_EMPTY = 'is_not_empty';
31 const OP_EQUALS = 'equals';
32 const OP_NOT_EQUALS = 'not_equals';
33 const OP_GREATER_THAN = 'greater_than';
34 const OP_LESS_THAN = 'less_than';
35 const OP_GTE = 'gte';
36 const OP_LTE = 'lte';
37 const OP_BEFORE = 'before';
38 const OP_AFTER = 'after';
39 const OP_IS_CHECKED = 'is_checked';
40 const OP_IS_NOT_CHECKED = 'is_not_checked';
41
42 /**
43 * The rule type this release understands.
44 *
45 * Rules carry their own type so further condition kinds -- query string, user role, date
46 * and time -- become new rule types inside the existing groups rather than another
47 * reshape of the stored attribute.
48 */
49 const RULE_TYPE_FIELD_VALUE = 'fieldValue';
50
51 /**
52 * Shortcode field type to comparison behavior.
53 *
54 * Mirrors TYPE_KEY_BY_FIELD_TYPE in
55 * src/blocks/shared/conditional-logic/util/field-types.ts. `field-telephone` renders as
56 * either `telephone` or `phone` depending on its country-selector setting, so both appear.
57 *
58 * @var array
59 */
60 const TYPE_KEY_BY_FIELD_TYPE = array(
61 'text' => 'string',
62 'name' => 'string',
63 'email' => 'string',
64 'url' => 'string',
65 'textarea' => 'string',
66 'telephone' => 'string',
67 'phone' => 'string',
68 'select' => 'choice',
69 'radio' => 'choice',
70 'image-select' => 'choice',
71 'checkbox-multiple' => 'multichoice',
72 'number' => 'number',
73 'slider' => 'number',
74 'rating' => 'rating',
75 'date' => 'date',
76 'time' => 'time',
77 'checkbox' => 'boolean',
78 'consent' => 'boolean',
79 'hidden' => 'hidden',
80 'file' => 'file',
81 );
82
83 /**
84 * Operators that compare against nothing, so `value` is ignored.
85 *
86 * @var array
87 */
88 const OPERATORS_WITHOUT_VALUE = array(
89 self::OP_IS_EMPTY,
90 self::OP_IS_NOT_EMPTY,
91 self::OP_IS_CHECKED,
92 self::OP_IS_NOT_CHECKED,
93 );
94
95 /**
96 * Resolve a shortcode field type to its comparison behavior.
97 *
98 * @param string $field_type The shortcode `type` attribute.
99 *
100 * @return string The type key; unknown types compare textually rather than being dropped.
101 */
102 public static function type_key_for_field_type( $field_type ): string {
103 if ( ! is_string( $field_type ) || '' === $field_type ) {
104 return 'string';
105 }
106
107 return self::TYPE_KEY_BY_FIELD_TYPE[ $field_type ] ?? 'string';
108 }
109
110 /**
111 * Evaluate a field's conditional logic.
112 *
113 * A rule whose subject field is absent from `$field_types` is ignored rather than
114 * compared against an empty value, so deleting an unrelated block cannot silently hide a
115 * field. When every rule is ignored the field stays visible.
116 *
117 * @param array|null $logic The field's conditional-logic config.
118 * @param array $field_types Map of field id to shortcode type, for every form field.
119 * @param array $form_values Map of field id to submitted value.
120 * @param array $field_formats Map of field id to date format, for date fields.
121 *
122 * @return bool True when the field should be visible.
123 */
124 public static function evaluate( $logic, array $field_types, array $form_values, array $field_formats = array() ): bool {
125 if ( ! is_array( $logic ) || empty( $logic['enabled'] ) ) {
126 return true;
127 }
128
129 $groups = isset( $logic['groups'] ) && is_array( $logic['groups'] ) ? $logic['groups'] : array();
130
131 if ( empty( $groups ) ) {
132 return true;
133 }
134
135 // Each group reduces its own rules with its own operator; the groups then reduce with
136 // the top-level one. With a single group -- all the V1 panel writes -- the outer
137 // reduction is a no-op, so this behaves exactly as a flat rule list until a second
138 // group exists.
139 $group_outcomes = array();
140
141 foreach ( $groups as $group ) {
142 $rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
143
144 $outcomes = array();
145 foreach ( $rules as $rule ) {
146 if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) {
147 continue;
148 }
149
150 // A condition kind this release does not know: ignore that rule, so a form
151 // saved by a newer editor degrades to its remaining conditions.
152 if ( ! empty( $rule['type'] ) && self::RULE_TYPE_FIELD_VALUE !== $rule['type'] ) {
153 continue;
154 }
155
156 $field_id = (string) $rule['field'];
157 if ( ! array_key_exists( $field_id, $field_types ) ) {
158 continue; // Subject field no longer exists: ignore this rule.
159 }
160
161 $type_key = self::type_key_for_field_type( $field_types[ $field_id ] );
162 $actual = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
163 // A date field's value is written in its own format, so the comparison needs it.
164 $format = isset( $field_formats[ $field_id ] ) ? (string) $field_formats[ $field_id ] : '';
165 $outcome = self::evaluate_rule_value( $rule, $type_key, $actual, $format );
166
167 if ( null !== $outcome ) {
168 $outcomes[] = $outcome;
169 }
170 }
171
172 // A group with nothing evaluable is ignored, the same way a single unusable rule
173 // is, so deleting a subject field cannot silently hide the field referencing it.
174 if ( empty( $outcomes ) ) {
175 continue;
176 }
177
178 $group_operator = $group['logicalOperator'] ?? 'any';
179 $group_outcomes[] = 'all' === $group_operator
180 ? ! in_array( false, $outcomes, true )
181 : in_array( true, $outcomes, true );
182 }
183
184 if ( empty( $group_outcomes ) ) {
185 return true;
186 }
187
188 $logical_operator = $logic['logicalOperator'] ?? 'any';
189
190 if ( 'all' === $logical_operator ) {
191 $matched = ! in_array( false, $group_outcomes, true );
192 } else {
193 $matched = in_array( true, $group_outcomes, true );
194 }
195
196 $action = $logic['action'] ?? 'show';
197
198 return 'hide' === $action ? ! $matched : $matched;
199 }
200
201 /**
202 * Resolve visibility for every field in a form at once.
203 *
204 * Runs to a fixed point so a hidden field's value reads as empty for everyone else: if the
205 * question was never asked, its answer must not satisfy another field's condition. On
206 * ambiguity — circular rules, or passes exhausted — the field is left visible, because a
207 * stray value in a response is recoverable and a silently discarded answer is not.
208 *
209 * @param array $fields Map of field id to `array( 'logic' => array|null, 'type' => string )`.
210 * @param array $form_values Map of field id to submitted value.
211 *
212 * @return array Map of field id to bool visibility.
213 */
214 public static function resolve_visibility( array $fields, array $form_values ): array {
215 $visible = array();
216 foreach ( $fields as $field_id => $descriptor ) {
217 $visible[ $field_id ] = true;
218 }
219
220 $with_logic = array();
221 $field_types = array();
222 $field_formats = array();
223 foreach ( $fields as $field_id => $descriptor ) {
224 $field_types[ $field_id ] = $descriptor['type'] ?? 'text';
225 if ( isset( $descriptor['format'] ) ) {
226 $field_formats[ $field_id ] = (string) $descriptor['format'];
227 }
228 if ( isset( $descriptor['logic'] ) && is_array( $descriptor['logic'] ) && ! empty( $descriptor['logic']['enabled'] ) ) {
229 $with_logic[] = $field_id;
230 }
231 }
232
233 if ( empty( $with_logic ) ) {
234 return $visible;
235 }
236
237 // An acyclic dependency chain settles at least one more level per pass, so one pass
238 // per conditional field, plus one to confirm nothing moved, always reaches the fixed
239 // point unless the rules are circular.
240 //
241 // This used to be clamped to a constant 25, which made that claim false: a chain
242 // deeper than 24 ran out of passes, was read as circular, and failed open with
243 // fields left visible that should have been hidden. The bound is the field count
244 // because that is what actually guarantees convergence -- and it is self-limiting,
245 // since it can only be as large as the form.
246 $max_passes = count( $with_logic ) + 1;
247
248 // Fields that change after the opening pass are reacting to another field's change,
249 // which is the signature of an oscillation. Collected across every pass, because a
250 // participant in a cycle need not be the one that flipped on the final pass.
251 $unstable = array();
252
253 for ( $pass = 0; $pass < $max_passes; $pass++ ) {
254 $effective = array();
255 foreach ( $fields as $field_id => $descriptor ) {
256 $value = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
257 $effective[ $field_id ] = $visible[ $field_id ] ? $value : '';
258 }
259
260 $changed_count = 0;
261 foreach ( $with_logic as $field_id ) {
262 $next = self::evaluate( $fields[ $field_id ]['logic'], $field_types, $effective, $field_formats );
263 if ( $next !== $visible[ $field_id ] ) {
264 $visible[ $field_id ] = $next;
265 ++$changed_count;
266 if ( $pass > 0 ) {
267 $unstable[ $field_id ] = true;
268 }
269 }
270 }
271
272 if ( 0 === $changed_count ) {
273 return $visible; // Fixed point.
274 }
275 }
276
277 // Passes exhausted, so the rules are circular. Fail open for everything in the cycle.
278 foreach ( array_keys( $unstable ) as $field_id ) {
279 $visible[ $field_id ] = true;
280 }
281
282 return $visible;
283 }
284
285 /**
286 * Evaluate a single rule against a submitted value.
287 *
288 * @param array $rule The rule.
289 * @param string $type_key Comparison behavior of the rule's subject field.
290 * @param mixed $actual The subject field's current value.
291 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
292 *
293 * @return bool|null True or false, or null when the rule must be ignored.
294 */
295 private static function evaluate_rule_value( array $rule, $type_key, $actual, $format = '' ) {
296 $operator = (string) $rule['operator'];
297 $needs_value = ! in_array( $operator, self::OPERATORS_WITHOUT_VALUE, true );
298 $expected = '';
299 if ( $needs_value && isset( $rule['value'] ) ) {
300 $expected = $rule['value'];
301 }
302
303 // An operator that compares against something, given nothing to compare against, cannot
304 // say anything -- so the rule is ignored rather than evaluated against an empty string.
305 // Evaluating it would be worse than useless: `does_not_contain ''` is true of every
306 // value, so a half-written rule would quietly force its field visible. The editor
307 // already tells the author this rule is inert; this is what makes that true.
308 if ( $needs_value && '' === trim( self::to_comparable_string( $expected ) ) ) {
309 return null;
310 }
311
312 switch ( $operator ) {
313 case self::OP_IS_EMPTY:
314 return self::is_empty_value( $actual );
315 case self::OP_IS_NOT_EMPTY:
316 return ! self::is_empty_value( $actual );
317 case self::OP_IS_CHECKED:
318 return ! self::is_empty_value( $actual );
319 case self::OP_IS_NOT_CHECKED:
320 return self::is_empty_value( $actual );
321 }
322
323 if ( 'multichoice' === $type_key ) {
324 $selection = self::to_selection_list( $actual );
325 $target = trim( self::to_comparable_string( $expected ) );
326
327 switch ( $operator ) {
328 case self::OP_CONTAINS:
329 return in_array( $target, $selection, true );
330 case self::OP_DOES_NOT_CONTAIN:
331 return ! in_array( $target, $selection, true );
332 }
333
334 return null;
335 }
336
337 if ( 'number' === $type_key || 'rating' === $type_key ) {
338 // A rating submits `selected/max`, e.g. `4/5`. The rule stores the bare number, so
339 // only the submitted side needs unpacking -- without it is_numeric() sees `4/5`,
340 // every comparison returns false, and a rating rule can never match.
341 $submitted = 'rating' === $type_key ? self::to_rating_value( $actual ) : $actual;
342 $pair = self::to_numeric_pair( $submitted, $expected );
343 if ( null === $pair ) {
344 return false;
345 }
346
347 switch ( $operator ) {
348 case self::OP_EQUALS:
349 return $pair[0] === $pair[1];
350 case self::OP_NOT_EQUALS:
351 return $pair[0] !== $pair[1];
352 case self::OP_GREATER_THAN:
353 return $pair[0] > $pair[1];
354 case self::OP_LESS_THAN:
355 return $pair[0] < $pair[1];
356 case self::OP_GTE:
357 return $pair[0] >= $pair[1];
358 case self::OP_LTE:
359 return $pair[0] <= $pair[1];
360 }
361
362 return null;
363 }
364
365 if ( 'date' === $type_key || 'time' === $type_key ) {
366 $pair = self::to_temporal_pair( $actual, $expected, $type_key, $format );
367 if ( null === $pair ) {
368 return false;
369 }
370
371 switch ( $operator ) {
372 case self::OP_IS:
373 return $pair[0] === $pair[1];
374 case self::OP_IS_NOT:
375 return $pair[0] !== $pair[1];
376 case self::OP_BEFORE:
377 return $pair[0] < $pair[1];
378 case self::OP_AFTER:
379 return $pair[0] > $pair[1];
380 }
381
382 return null;
383 }
384
385 // string, choice, hidden and file all compare textually.
386 $left = self::to_comparable_string( $actual );
387 $right = self::to_comparable_string( $expected );
388
389 switch ( $operator ) {
390 case self::OP_IS:
391 return $left === $right;
392 case self::OP_IS_NOT:
393 return $left !== $right;
394 case self::OP_CONTAINS:
395 return '' !== $right && false !== strpos( $left, $right );
396 case self::OP_DOES_NOT_CONTAIN:
397 return '' === $right || false === strpos( $left, $right );
398 }
399
400 return null;
401 }
402
403 /**
404 * Normalize a multi-select value into a list of selected option strings.
405 *
406 * Membership comparison exists so `contains "Blue"` does not match an option named
407 * "Blueberry", and so an option containing a comma cannot corrupt the comparison.
408 *
409 * @param mixed $value The submitted value.
410 *
411 * @return array Selected options, trimmed, with blanks removed.
412 */
413 private static function to_selection_list( $value ): array {
414 $raw = is_array( $value ) ? $value : array( $value );
415 $list = array();
416
417 foreach ( $raw as $item ) {
418 $text = trim( self::to_comparable_string( $item ) );
419 if ( '' !== $text ) {
420 $list[] = $text;
421 }
422 }
423
424 return $list;
425 }
426
427 /**
428 * The selected value of a rating, dropping the scale it was submitted with.
429 *
430 * @param mixed $value The submitted value, `selected/max` or a bare number.
431 *
432 * @return string The selected part, for numeric comparison.
433 */
434 private static function to_rating_value( $value ) {
435 $text = trim( self::to_comparable_string( $value ) );
436 $slash = strpos( $text, '/' );
437
438 return false === $slash ? $text : substr( $text, 0, $slash );
439 }
440
441 /**
442 * Parse both sides of a numeric comparison.
443 *
444 * @param mixed $actual The submitted value.
445 * @param mixed $expected The value configured on the rule.
446 *
447 * @return array|null Both sides as floats, or null when either side is not numeric.
448 */
449 private static function to_numeric_pair( $actual, $expected ) {
450 $left = trim( self::to_comparable_string( $actual ) );
451 $right = trim( self::to_comparable_string( $expected ) );
452
453 if ( '' === $left || '' === $right || ! is_numeric( $left ) || ! is_numeric( $right ) ) {
454 return null;
455 }
456
457 return array( (float) $left, (float) $right );
458 }
459
460 /**
461 * Parse both sides of a date or time comparison into comparable numbers.
462 *
463 * Times become minutes since midnight, so a bare `HH:MM` needs no date context.
464 *
465 * @param mixed $actual The submitted value.
466 * @param mixed $expected The value configured on the rule.
467 * @param string $type_key Either `date` or `time`.
468 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
469 *
470 * @return array|null Both sides as ints, or null when either side cannot be parsed.
471 */
472 private static function to_temporal_pair( $actual, $expected, $type_key, $format = '' ) {
473 // The submitted value is written in the field's format; a rule's value is always ISO.
474 $left = self::parse_temporal( $actual, $type_key, $format );
475 $right = self::parse_temporal( $expected, $type_key, '' );
476
477 if ( null === $left || null === $right ) {
478 return null;
479 }
480
481 return array( $left, $right );
482 }
483
484 /**
485 * Parse one side of a temporal comparison.
486 *
487 * @param mixed $value The value to parse.
488 * @param string $type_key Either `date` or `time`.
489 * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
490 *
491 * @return int|null Comparable integer, or null when unparseable.
492 */
493 private static function parse_temporal( $value, $type_key, $format = '' ) {
494 $text = trim( self::to_comparable_string( $value ) );
495 if ( '' === $text ) {
496 return null;
497 }
498
499 if ( 'time' === $type_key ) {
500 if ( ! preg_match( '/^(\d{1,2}):(\d{2})/', $text, $matches ) ) {
501 return null;
502 }
503 return ( (int) $matches[1] ) * 60 + (int) $matches[2];
504 }
505
506 return self::parse_date( $text, $format );
507 }
508
509 /**
510 * Parse a date into a comparable YYYYMMDD integer.
511 *
512 * Deliberately not strtotime(). The browser has to reach the same answer, and its
513 * Date.parse() reads a bare `YYYY-MM-DD` as UTC while reading `mm/dd/yy` as local time --
514 * so for any visitor away from UTC the two engines disagreed by the offset, and `is` was
515 * false on one side while `after` was true on the other. A `dd/mm/yy` field was worse:
516 * neither engine could read `31/12/2026` at all, so a show-rule hid its field permanently
517 * and the answer was then dropped at storage.
518 *
519 * The field's own format decides how to read the value, the same way the datepicker
520 * writes it. A rule's value always arrives as ISO, since the rule builder uses a native
521 * date input, so ISO is accepted regardless of the field's format.
522 *
523 * @param string $text Date text.
524 * @param string $format Field date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
525 *
526 * @return int|null YYYYMMDD, or null when the text does not match.
527 */
528 private static function parse_date( $text, $format = '' ) {
529 // ISO first: the rule side is always ISO, and it is the default field format.
530 if ( preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m ) ) {
531 return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
532 }
533
534 if ( ! preg_match( '/^(\d{1,4})[\/.-](\d{1,2})[\/.-](\d{1,4})$/', $text, $m ) ) {
535 return null;
536 }
537
538 // jQuery UI tokens, as used by the field: `yy` is the four-digit year.
539 switch ( $format ) {
540 case 'dd/mm/yy':
541 return self::to_date_int( (int) $m[3], (int) $m[2], (int) $m[1] );
542 case 'mm/dd/yy':
543 return self::to_date_int( (int) $m[3], (int) $m[1], (int) $m[2] );
544 default:
545 return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
546 }
547 }
548
549 /**
550 * Combine date parts, rejecting anything out of range.
551 *
552 * @param int $year Four-digit year.
553 * @param int $month Month.
554 * @param int $day Day.
555 *
556 * @return int|null YYYYMMDD, or null when the parts cannot be a date.
557 */
558 private static function to_date_int( $year, $month, $day ) {
559 if ( $month < 1 || $month > 12 || $day < 1 || $day > 31 ) {
560 return null;
561 }
562
563 return $year * 10000 + $month * 100 + $day;
564 }
565
566 /**
567 * Reduce any submitted value to a comparable string.
568 *
569 * @param mixed $value The submitted value.
570 *
571 * @return string Comparable string; empty for values with no sensible text form.
572 */
573 private static function to_comparable_string( $value ): string {
574 if ( null === $value ) {
575 return '';
576 }
577 if ( is_bool( $value ) ) {
578 return $value ? '1' : '';
579 }
580 if ( is_array( $value ) ) {
581 $parts = array();
582 foreach ( $value as $item ) {
583 $parts[] = self::to_comparable_string( $item );
584 }
585 return implode( ',', $parts );
586 }
587 if ( is_object( $value ) ) {
588 return '';
589 }
590
591 return (string) $value;
592 }
593
594 /**
595 * Whether a submitted value counts as unanswered.
596 *
597 * @param mixed $value The submitted value.
598 *
599 * @return bool True when the field has no answer.
600 */
601 private static function is_empty_value( $value ): bool {
602 if ( null === $value ) {
603 return true;
604 }
605 if ( is_string( $value ) ) {
606 return '' === trim( $value );
607 }
608 if ( is_bool( $value ) ) {
609 return ! $value;
610 }
611 if ( is_array( $value ) ) {
612 foreach ( $value as $item ) {
613 if ( ! self::is_empty_value( $item ) ) {
614 return false;
615 }
616 }
617 return true;
618 }
619 if ( is_object( $value ) ) {
620 foreach ( get_object_vars( $value ) as $item ) {
621 if ( ! self::is_empty_value( $item ) ) {
622 return false;
623 }
624 }
625 return true;
626 }
627
628 return false;
629 }
630 }
631