PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / snippet-conditions.php

snippet-conditions.php in ZIP AI – AI Website Builder & AI Agent 0.0.13, at inc/core/snippet-conditions.php

605 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet Conditions — evaluates conditional logic for snippet execution.
4 *
5 * Data model (one-row-per-type):
6 * - Each row is one constraint: { type, operator, value?, values?, meta? }
7 * - At most one row per `type` — enforced by Snippet_Store::sanitize_conditions.
8 * - All rows AND together (every constraint must match).
9 * - Multi-value operators (`in`/`not_in`/`starts_with`/`contains`/`ends_with`/`regex`)
10 * OR across the values within a single row.
11 *
12 * Late condition types (page/post_type/post/device) require the main query to
13 * be set up. For early hooks the executor defers to `template_redirect` if any
14 * row references a late type.
15 *
16 * @since 0.0.5
17 * @package zip-ai
18 */
19
20 namespace ZipAI\MCP\Classes\Core;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class Snippet_Conditions {
27
28 /**
29 * Condition types that require the main query (after `wp` action).
30 *
31 * @var array<int,string>
32 */
33 private static $late_condition_types = array( 'page', 'post_type', 'post', 'device' );
34
35 // ── Public ────────────────────────────────────────────────────────────
36
37 /**
38 * Evaluate an array of conditions.
39 *
40 * Connector semantics: each row has an optional `connector` ("and" | "or",
41 * default "and"; ignored on the first row). Consecutive AND rows form a
42 * chain; chains OR together (standard SQL precedence — AND binds tighter
43 * than OR). Empty array means "runs everywhere".
44 *
45 * Examples:
46 * [post=2051, OR device=mobile] → post=2051 OR device=mobile
47 * [post=2051, AND user_role=admin, OR device=mobile]
48 * → (post=2051 AND user_role=admin) OR device=mobile
49 *
50 * @param array<int,array<string,mixed>> $conditions Sanitized condition rows.
51 * @param bool $is_early True when the hook fires before `wp`.
52 * @return bool
53 */
54 public static function evaluate( array $conditions, $is_early = false ) {
55 $chains = self::build_chains( $conditions );
56 if ( empty( $chains ) ) {
57 return true;
58 }
59
60 foreach ( $chains as $chain ) {
61 $chain_passed = true;
62 foreach ( $chain as $cond ) {
63 $type = $cond['type'] ?? '';
64 if ( $is_early && in_array( $type, self::$late_condition_types, true ) ) {
65 continue; // late types skipped on early hooks (executor defers)
66 }
67 if ( ! self::evaluate_single( $cond ) ) {
68 $chain_passed = false;
69 break; // AND short-circuit within chain
70 }
71 }
72 if ( $chain_passed ) {
73 return true; // OR short-circuit across chains
74 }
75 }
76
77 return false;
78 }
79
80 /**
81 * Split a flat conditions list into AND-chains separated by OR-connectors.
82 * First row's connector is ignored. Returns array of chains; each chain is
83 * a non-empty list of row arrays.
84 *
85 * @param array<int,array<string,mixed>> $conditions Sanitized condition rows.
86 * @return array<int,array<int,array<string,mixed>>> AND-chains; each chain is a non-empty list of rows.
87 */
88 public static function build_chains( array $conditions ) {
89 $chains = array();
90 $current = array();
91 foreach ( $conditions as $cond ) {
92 if ( empty( $cond['type'] ?? '' ) ) {
93 continue;
94 }
95 $connector = isset( $cond['connector'] ) && is_string( $cond['connector'] ) ? strtolower( $cond['connector'] ) : 'and';
96 if ( 'or' === $connector && ! empty( $current ) ) {
97 $chains[] = $current;
98 $current = array();
99 }
100 $current[] = $cond;
101 }
102 if ( ! empty( $current ) ) {
103 $chains[] = $current;
104 }
105 return $chains;
106 }
107
108 /**
109 * Evaluate conditions with a per-row trace. Used by `simulate_match`,
110 * executor trace mode, and admin diagnostic UI.
111 *
112 * Output shape:
113 * {
114 * result: bool,
115 * rows: [
116 * { row, status: "pass"|"fail"|"skipped", reason }
117 * ]
118 * }
119 *
120 * @param array<int,array<string,mixed>> $conditions Sanitized condition rows.
121 * @param bool $is_early True when the hook fires before `wp`.
122 * @return array<string,mixed> Trace result with a `result` bool and per-chain `chains`.
123 */
124 public static function evaluate_with_trace( array $conditions, $is_early = false ) {
125 $chains = self::build_chains( $conditions );
126 if ( empty( $chains ) ) {
127 return array(
128 'result' => true,
129 'chains' => array(),
130 'reason' => 'empty conditions → runs everywhere',
131 );
132 }
133
134 $chain_traces = array();
135 $final = false;
136
137 foreach ( $chains as $chain ) {
138 $chain_passed = true;
139 $row_traces = array();
140 foreach ( $chain as $cond ) {
141 $type = is_string( $cond['type'] ?? null ) ? $cond['type'] : '';
142 if ( $is_early && in_array( $type, self::$late_condition_types, true ) ) {
143 $row_traces[] = array(
144 'row' => $cond,
145 'status' => 'skipped',
146 'reason' => sprintf( 'late type "%s" on early hook — deferred', $type ),
147 );
148 continue;
149 }
150 $result = self::evaluate_single( $cond );
151 $row_traces[] = array(
152 'row' => $cond,
153 'status' => $result ? 'pass' : 'fail',
154 'reason' => self::describe_row_outcome( $cond, $result ),
155 );
156 if ( ! $result ) {
157 $chain_passed = false;
158 }
159 }
160 $chain_traces[] = array(
161 'passed' => $chain_passed,
162 'rows' => $row_traces,
163 );
164 if ( $chain_passed ) {
165 $final = true;
166 }
167 }
168
169 return array(
170 'result' => $final,
171 'chains' => $chain_traces,
172 );
173 }
174
175 /**
176 * Per-row natural-language outcome string. Reports the runtime value the
177 * evaluator compared against so authors can debug a failing row at a
178 * glance.
179 *
180 * @param array<string,mixed> $cond Condition row being described.
181 * @param bool $result Whether the row matched.
182 * @return string Human-readable outcome for the row.
183 */
184 private static function describe_row_outcome( array $cond, $result ) {
185 $type = $cond['type'] ?? '';
186 $verb = $result ? 'matched' : 'did not match';
187
188 switch ( $type ) {
189 case 'post_type':
190 $current = self::current_post_type();
191 return sprintf( '%s — current post_type=%s', $verb, $current ?? '(none)' );
192 case 'post':
193 $current = self::current_post_id();
194 return sprintf( '%s — current post_id=%s', $verb, $current ? $current : '(none)' );
195 case 'page':
196 $tokens = array();
197 if ( function_exists( 'is_front_page' ) && ( is_front_page() || is_home() ) ) {
198 $tokens[] = 'home';
199 }
200 if ( is_single() ) {
201 $tokens[] = 'single'; }
202 if ( is_page() ) {
203 $tokens[] = 'page'; }
204 if ( is_archive() ) {
205 $tokens[] = 'archive'; }
206 if ( is_search() ) {
207 $tokens[] = 'search'; }
208 if ( is_404() ) {
209 $tokens[] = '404'; }
210 $page_tokens = implode( ',', $tokens );
211 return sprintf( '%s — current page=[%s]', $verb, $page_tokens ? $page_tokens : '(none)' );
212 case 'user':
213 return sprintf( '%s — logged_in=%s', $verb, is_user_logged_in() ? 'true' : 'false' );
214 case 'user_role':
215 if ( ! is_user_logged_in() ) {
216 return sprintf( '%s — anonymous user', $verb );
217 }
218 $user = wp_get_current_user();
219 return sprintf( '%s — current roles=[%s]', $verb, implode( ',', (array) $user->roles ) );
220 case 'device':
221 return sprintf( '%s — wp_is_mobile=%s', $verb, wp_is_mobile() ? 'true' : 'false' ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_is_mobile_wp_is_mobile -- evaluates a device display-condition server-side; wp_is_mobile is the WP API with no alternative.
222 case 'url_pattern':
223 $uri = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
224 return sprintf( '%s — current uri=%s', $verb, $uri );
225 default:
226 return $verb;
227 }
228 }
229
230 // ── Dispatch ──────────────────────────────────────────────────────────
231
232 /**
233 * Dispatch a single condition row to its per-type evaluator.
234 *
235 * @param array<string,mixed> $cond Condition row.
236 * @return bool Whether the condition matches the current request.
237 */
238 private static function evaluate_single( array $cond ) {
239 $type = is_string( $cond['type'] ?? null ) ? $cond['type'] : '';
240 $operator = is_string( $cond['operator'] ?? null ) ? $cond['operator'] : 'is';
241 $values = self::values_from_row( $cond );
242 $meta = is_array( $cond['meta'] ?? null ) ? $cond['meta'] : array();
243
244 switch ( $type ) {
245 case 'page':
246 return self::evaluate_page( $operator, $values );
247 case 'user':
248 return self::evaluate_user( $operator, $values );
249 case 'user_role':
250 return self::evaluate_user_role( $operator, $values );
251 case 'post_type':
252 return self::evaluate_post_type( $operator, $values );
253 case 'post':
254 return self::evaluate_post( $operator, $values );
255 case 'device':
256 return self::evaluate_device( $operator, $values );
257 case 'url_pattern':
258 /**
259 * Narrowed type for `$meta`.
260 *
261 * @var array<string,mixed> $meta
262 */
263 return self::evaluate_url_pattern( $operator, $values, $meta );
264 default:
265 // Fail closed on unknown types so typos block execution
266 // instead of producing over-permissive behavior.
267 if ( function_exists( 'error_log' ) ) {
268 error_log( sprintf( '[ZIP AI Snippet] Unknown condition type "%s" — failing closed.', $type ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic logging for snippet execution; no WP core logger equivalent.
269 }
270 return false;
271 }
272 }
273
274 /**
275 * Extract the non-empty comparison values from a condition row.
276 *
277 * @param array<string,mixed> $cond Condition row.
278 * @return array<int,string> Comparison values.
279 */
280 private static function values_from_row( array $cond ) {
281 if ( isset( $cond['values'] ) && is_array( $cond['values'] ) && ! empty( $cond['values'] ) ) {
282 return array_values(
283 array_map(
284 'strval',
285 array_filter(
286 $cond['values'],
287 static function ( $v ) {
288 return null !== $v && '' !== $v && is_scalar( $v );
289 }
290 )
291 )
292 );
293 }
294 $single = $cond['value'] ?? '';
295 if ( '' === $single || ! is_scalar( $single ) ) {
296 return array();
297 }
298 return array( (string) $single );
299 }
300
301 // ── Operator helper ───────────────────────────────────────────────────
302
303 /**
304 * Match a haystack against an operator + needle list. Multi-value
305 * operators (starts_with, contains, ends_with, regex) OR across needles:
306 * the row matches if ANY needle matches the haystack.
307 *
308 * @param string|int|null $haystack Value being tested.
309 * @param string $operator Comparison operator.
310 * @param array<int,string> $needles List of comparison values.
311 * @return bool Whether the haystack matches under the operator.
312 */
313 private static function match_value( $haystack, $operator, array $needles ) {
314 if ( empty( $needles ) ) {
315 return false;
316 }
317 $haystack = (string) $haystack;
318
319 switch ( $operator ) {
320 case 'is':
321 return $haystack === (string) $needles[0];
322 case 'is_not':
323 return $haystack !== (string) $needles[0];
324 case 'in':
325 return in_array( $haystack, array_map( 'strval', $needles ), true );
326 case 'not_in':
327 return ! in_array( $haystack, array_map( 'strval', $needles ), true );
328 case 'contains':
329 foreach ( $needles as $n ) {
330 if ( '' !== $n && false !== strpos( $haystack, (string) $n ) ) {
331 return true;
332 }
333 }
334 return false;
335 case 'starts_with':
336 foreach ( $needles as $n ) {
337 if ( '' !== $n && 0 === strpos( $haystack, (string) $n ) ) {
338 return true;
339 }
340 }
341 return false;
342 case 'ends_with':
343 foreach ( $needles as $n ) {
344 $nlen = strlen( (string) $n );
345 if ( $nlen > 0 && substr( $haystack, -$nlen ) === (string) $n ) {
346 return true;
347 }
348 }
349 return false;
350 case 'regex':
351 foreach ( $needles as $n ) {
352 if ( self::safe_regex_match( (string) $n, $haystack ) ) {
353 return true;
354 }
355 }
356 return false;
357 default:
358 return $haystack === (string) $needles[0];
359 }
360 }
361
362 /**
363 * Run a user-supplied regex safely.
364 *
365 * @param string $pattern Regex pattern, optionally with delimiters.
366 * @param string $haystack Subject string to test.
367 * @return bool Whether the pattern matches the subject.
368 */
369 private static function safe_regex_match( $pattern, $haystack ) {
370 if ( strlen( $pattern ) > 256 || '' === $pattern ) {
371 return false;
372 }
373 if ( ! preg_match( '/^([\/#~%@]).+\1[a-zA-Z]*$/s', $pattern ) ) {
374 $pattern = '/' . str_replace( '/', '\/', $pattern ) . '/';
375 }
376 $ok = @preg_match( $pattern, $haystack ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- suppresses the warning from a malformed user-supplied regex pattern; failure returns false and is handled by the 1 === $ok return check.
377 return 1 === $ok;
378 }
379
380 // ── Per-type evaluators ───────────────────────────────────────────────
381
382 /**
383 * Evaluate a `page` condition against the current request context.
384 *
385 * @param string $operator Comparison operator.
386 * @param array<int,string> $values Page tokens or object IDs.
387 * @return bool Whether the condition matches.
388 */
389 private static function evaluate_page( $operator, array $values ) {
390 $pos = self::any_positive(
391 $values,
392 static function ( string $value ) {
393 switch ( $value ) {
394 case 'home':
395 return is_front_page() || is_home();
396 case 'single':
397 return is_single();
398 case 'page':
399 return is_page();
400 case 'archive':
401 return is_archive();
402 case '404':
403 return is_404();
404 case 'search':
405 return is_search();
406 default:
407 if ( is_numeric( $value ) ) {
408 return is_page( (int) $value ) || is_single( (int) $value );
409 }
410 return is_page( $value ) || is_single( $value );
411 }
412 }
413 );
414 return self::apply_negation( $pos, $operator );
415 }
416
417 /**
418 * Evaluate a `user` (logged-in state) condition.
419 *
420 * @param string $operator Comparison operator.
421 * @param array<int,string> $values Expected user-state tokens.
422 * @return bool Whether the condition matches.
423 */
424 private static function evaluate_user( $operator, array $values ) {
425 $pos = self::any_positive(
426 $values,
427 static function ( $value ) {
428 return 'logged_in' === $value && is_user_logged_in();
429 }
430 );
431 return self::apply_negation( $pos, $operator );
432 }
433
434 /**
435 * Evaluate a `user_role` condition against the current user's roles.
436 *
437 * @param string $operator Comparison operator.
438 * @param array<int,string> $values Role slugs to match.
439 * @return bool Whether the condition matches.
440 */
441 private static function evaluate_user_role( $operator, array $values ) {
442 if ( ! is_user_logged_in() ) {
443 return self::apply_negation( false, $operator );
444 }
445 $user = wp_get_current_user();
446 $pos = self::any_positive(
447 $values,
448 static function ( $value ) use ( $user ) {
449 return in_array( $value, (array) $user->roles, true );
450 }
451 );
452 return self::apply_negation( $pos, $operator );
453 }
454
455 /**
456 * Evaluate a `post_type` condition against the current post type.
457 *
458 * @param string $operator Comparison operator.
459 * @param array<int,string> $values Post-type slugs to match.
460 * @return bool Whether the condition matches.
461 */
462 private static function evaluate_post_type( $operator, array $values ) {
463 $current = self::current_post_type();
464 if ( null === $current ) {
465 return self::apply_negation( false, $operator );
466 }
467 return self::match_value( $current, self::expand_operator( $operator ), $values );
468 }
469
470 /**
471 * Evaluate a `post` condition against the current post ID.
472 *
473 * @param string $operator Comparison operator.
474 * @param array<int,string> $values Post IDs to match.
475 * @return bool Whether the condition matches.
476 */
477 private static function evaluate_post( $operator, array $values ) {
478 $post_id = self::current_post_id();
479 if ( ! $post_id ) {
480 return self::apply_negation( false, $operator );
481 }
482 return self::match_value( (string) $post_id, self::expand_operator( $operator ), $values );
483 }
484
485 /**
486 * Evaluate a `device` condition (mobile detection).
487 *
488 * @param string $operator Comparison operator.
489 * @param array<int,string> $values Device tokens to match.
490 * @return bool Whether the condition matches.
491 */
492 private static function evaluate_device( $operator, array $values ) {
493 $pos = self::any_positive(
494 $values,
495 static function ( $value ) {
496 return 'mobile' === $value && wp_is_mobile(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_is_mobile_wp_is_mobile -- evaluates a device display-condition server-side; wp_is_mobile is the WP API with no alternative.
497 }
498 );
499 return self::apply_negation( $pos, $operator );
500 }
501
502 /**
503 * Evaluate a `url_pattern` condition against the request URI.
504 *
505 * @param string $operator Comparison operator.
506 * @param array<int,string> $values URL patterns to match.
507 * @param array<string,mixed> $meta Optional row metadata.
508 * @return bool Whether the condition matches.
509 */
510 private static function evaluate_url_pattern( $operator, array $values, array $meta ) {
511 $uri = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
512 if ( empty( $values ) ) {
513 return false;
514 }
515 $op = $operator ? $operator : 'starts_with';
516 return self::match_value( $uri, $op, $values );
517 }
518
519 // ── Helpers ───────────────────────────────────────────────────────────
520
521 /**
522 * True when any value passes the given check.
523 *
524 * @param array<int,string> $values Values to test.
525 * @param callable $check Predicate applied to each value.
526 * @return bool Whether any value passed.
527 */
528 private static function any_positive( array $values, callable $check ) {
529 foreach ( $values as $v ) {
530 if ( $check( $v ) ) {
531 return true;
532 }
533 }
534 return false;
535 }
536
537 /**
538 * Invert the result for negating operators (`is_not`, `not_in`).
539 *
540 * @param bool $result Positive match result.
541 * @param string $operator Comparison operator.
542 * @return bool Result after applying negation.
543 */
544 private static function apply_negation( $result, $operator ) {
545 return ( 'is_not' === $operator || 'not_in' === $operator ) ? ! $result : (bool) $result;
546 }
547
548 /**
549 * Map `is`/`is_not` to their multi-value `in`/`not_in` equivalents.
550 *
551 * @param string $operator Comparison operator.
552 * @return string Expanded operator.
553 */
554 private static function expand_operator( $operator ) {
555 if ( 'is' === $operator ) {
556 return 'in';
557 }
558 if ( 'is_not' === $operator ) {
559 return 'not_in';
560 }
561 return $operator;
562 }
563
564 /**
565 * Resolve the current request's post type.
566 *
567 * @return string|null Post type, or null when none.
568 */
569 private static function current_post_type() {
570 $current = get_post_type();
571 if ( $current ) {
572 return $current;
573 }
574 $queried = get_queried_object();
575 if ( $queried instanceof \WP_Post_Type ) {
576 return $queried->name;
577 }
578 $qv = get_query_var( 'post_type' );
579 if ( is_array( $qv ) && ! empty( $qv ) && is_string( $qv[0] ) ) {
580 return $qv[0];
581 }
582 if ( is_string( $qv ) && '' !== $qv ) {
583 return $qv;
584 }
585 return null;
586 }
587
588 /**
589 * Resolve the current request's post ID.
590 *
591 * @return int Post ID, or 0 when none.
592 */
593 private static function current_post_id() {
594 $id = get_the_ID();
595 if ( $id ) {
596 return (int) $id;
597 }
598 $queried = get_queried_object();
599 if ( $queried && isset( $queried->ID ) && is_numeric( $queried->ID ) ) {
600 return (int) $queried->ID;
601 }
602 return 0;
603 }
604 }
605