$rule Rule. */ public static function scripted( array $rule ): bool { return '' !== trim( (string) ( $rule['customJavascript'] ?? '' ) ); } /** * Whether any of a Site's rules runs custom JavaScript set in Stand. The * rules then change only in Stand. * * @param array> $rules Rules in Stand. */ public static function has_custom_javascript( array $rules ): bool { foreach ( $rules as $rule ) { if ( is_array( $rule ) && self::scripted( $rule ) ) { return true; } } return false; } /** * Says why WordPress leaves a Site's rules with custom JavaScript to Stand. */ public static function custom_javascript_message(): string { return __( 'Chat behavior on this site uses custom JavaScript set in Stand, so change it in Stand.', 'stand-chat' ); } /** * The path pattern for one WordPress post. * * @param int $post_id Post ID. */ public static function path_for_post( int $post_id ): string { if ( (int) get_option( 'page_on_front' ) === $post_id && 'page' === get_option( 'show_on_front' ) ) { return self::front_path(); } $path = wp_make_link_relative( (string) get_permalink( $post_id ) ); $path = (string) strtok( $path, '?' ); return '' === $path ? '/' : $path; } /** * A page-specific rule needs an address that Stand can distinguish by path. * Query-based permalinks share a path with other pages; dropping the query * would change their behavior too. With Plain permalinks even the front * page shares its path with those query-address pages. * * @param int $post_id Post ID. */ public static function rule_path_for_post( int $post_id ): ?string { if ( '' === (string) get_option( 'permalink_structure' ) ) { return null; } if ( (int) get_option( 'page_on_front' ) === $post_id && 'page' === get_option( 'show_on_front' ) ) { return self::front_path(); } $url = (string) get_permalink( $post_id ); if ( '' === $url || null !== wp_parse_url( $url, PHP_URL_QUERY ) ) { return null; } return self::path_for_post( $post_id ); } /** * Explains why a page with a query-based address cannot have its own rule. */ public static function page_rule_unavailable(): string { return __( 'Page-specific Behavior rules cannot distinguish query-based addresses. Choose a permalink structure other than Plain in Settings → Permalinks.', 'stand-chat' ); } /** * The front page's path ("/", or "/subsite/" in a subdirectory network). */ public static function front_path(): string { $prefix = Environment::path_prefix(); return '' === $prefix ? '/' : trailingslashit( $prefix ); } /** * Mirrors Stand's path matching. * * @param string $pattern Path pattern. * @param string $path Page path. */ public static function matches( string $pattern, string $path ): bool { $pattern = trim( $pattern ); if ( '' === $pattern ) { return true; } $path = '' === $path ? '/' : ( '/' === $path[0] ? $path : '/' . $path ); if ( '/*' === substr( $pattern, -2 ) ) { $base = self::trim_slash( substr( $pattern, 0, -2 ) ); return '/' === $base || self::trim_slash( $path ) === $base || 0 === strpos( $path, $base . '/' ); } return self::trim_slash( $path ) === self::trim_slash( $pattern ); } /** * Index of the rule that applies to a path, or null. * * @param array> $rules Rules. * @param string $path Page path. */ public static function first_match( array $rules, string $path ): ?int { foreach ( array_values( $rules ) as $index => $rule ) { if ( ! empty( $rule['enabled'] ) && self::matches( (string) ( $rule['pathPrefix'] ?? '' ), $path ) ) { return $index; } } return null; } /** * Describes a pattern in WordPress words. * * @param string $pattern Path pattern. * @return array{kind: string, label: string, postId: int} */ public static function describe( string $pattern ): array { if ( '' === $pattern ) { return array( 'kind' => 'default', 'label' => __( 'All other pages', 'stand-chat' ), 'postId' => 0, ); } if ( self::trim_slash( $pattern ) === self::trim_slash( self::front_path() ) ) { return array( 'kind' => 'front', 'label' => __( 'Front page', 'stand-chat' ), 'postId' => (int) get_option( 'page_on_front' ), ); } $subtree = '/*' === substr( $pattern, -2 ); $base = $subtree ? substr( $pattern, 0, -2 ) : $pattern; $post_id = url_to_postid( home_url( self::strip_prefix( $base ) ) ); if ( $post_id ) { $title = html_entity_decode( get_the_title( $post_id ), ENT_QUOTES ); return array( 'kind' => $subtree ? 'subtree' : 'page', /* translators: %s: page title */ 'label' => $subtree ? sprintf( __( '%s and its child pages', 'stand-chat' ), $title ) : $title, 'postId' => $post_id, ); } return array( 'kind' => $subtree ? 'subtree' : 'path', 'label' => $pattern, 'postId' => 0, ); } /** * Validates rules from the admin screen. A rule the person didn't change * goes back exactly as Stand has it; a changed one keeps the fields the * plugin doesn't edit. Nothing changes on a Site with custom JavaScript. * * @param array $incoming Rules from the plugin. * @param array> $current Rules in Stand now. * @return array>|WP_Error */ public static function prepare( array $incoming, array $current ) { if ( self::has_custom_javascript( $current ) ) { return new WP_Error( 'stand_script_rule', self::custom_javascript_message() ); } $existing = array(); foreach ( $current as $rule ) { if ( isset( $rule['ruleId'] ) ) { $existing[ (string) $rule['ruleId'] ] = $rule; } } $rules = array(); $defaults = array(); foreach ( $incoming as $raw ) { if ( ! is_array( $raw ) ) { continue; } $post_id = (int) ( $raw['where']['postId'] ?? 0 ); $old = $existing[ (string) ( $raw['ruleId'] ?? '' ) ] ?? null; $path = (string) ( $raw['pathPrefix'] ?? '' ); // Keep existing path rules editable, including intentional root rules. // A new page choice must still have a distinct path when saved. if ( 0 < $post_id && ( null === $old || (string) $old['pathPrefix'] !== $path ) && null === self::rule_path_for_post( $post_id ) ) { return new WP_Error( 'stand_invalid', self::page_rule_unavailable() ); } $rule = self::normalize( $raw ); if ( is_wp_error( $rule ) ) { return $rule; } $old = $existing[ $rule['ruleId'] ] ?? null; if ( null !== $old && self::normalize( $old ) == $rule ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Same values in any key order. $rule = $old; } else { $rule = array_merge( null === $old ? array() : $old, $rule, array( 'customJavascript' => '' ) ); } // "All other pages" stays last: Stand would never reach rules below it. if ( '' === $rule['pathPrefix'] ) { $defaults[] = $rule; } else { $rules[] = $rule; } } $rules = array_merge( $rules, $defaults ); if ( count( $rules ) > self::LIMIT ) { return new WP_Error( 'stand_invalid', /* translators: %d: rule limit */ sprintf( __( 'A Site can have up to %d Behavior rules, including All other pages.', 'stand-chat' ), self::LIMIT ) ); } return $rules; } /** * Puts a rule for exactly one page above the rules that also match it, or * removes the page's own rule when $rule is null. The page's existing rule * keeps its place, and moves up only when a rule above it also matches the * page. * * @param array> $rules Rules. * @param string $path Page path. * @param array|null $rule New rule. * @return array> */ public static function upsert_page_rule( array $rules, string $path, ?array $rule ): array { $kept = array(); $id = ''; $at = null; foreach ( $rules as $existing ) { $pattern = (string) ( $existing['pathPrefix'] ?? '' ); // All other pages ('') is never a page's own rule, also not the front // page's ('/'). With two rules for the page, the first one is its // rule; the other stays as it is, such as an owner's variant in Stand. if ( null === $at && '' !== $pattern && self::trim_slash( $pattern ) === self::trim_slash( $path ) && '/*' !== substr( $pattern, -2 ) ) { $id = (string) ( $existing['ruleId'] ?? '' ); $at = count( $kept ); continue; } $kept[] = $existing; } if ( null === $rule ) { return $kept; } // The page's rule stays the same rule, such as when it becomes Hidden. if ( '' !== $id ) { $rule['ruleId'] = $id; } $rule['pathPrefix'] = $path; $position = count( $kept ); foreach ( $kept as $index => $existing ) { if ( self::matches( (string) ( $existing['pathPrefix'] ?? '' ), $path ) ) { $position = $index; break; } } array_splice( $kept, null === $at ? $position : min( $at, $position ), 0, array( $rule ) ); return $kept; } /** * A rule for "Hidden": Widget show trigger None, so the corner widget stays * hidden while Stand buttons and cards on the page still open chat. * * @param string $name Rule name. * @return array */ public static function hidden_rule( string $name ): array { return array( 'ruleId' => wp_generate_uuid4(), 'name' => $name, 'enabled' => true, 'hideButtonUntilTriggered' => true, 'presentation' => array( 'noGreetings' => false, 'halfSize' => false, 'stepAside' => false, ), 'trigger' => array( 'type' => 'none', 'delayMs' => 0, 'scrollPercent' => 70, ), 'action' => array( 'type' => 'showButton', 'initialMessage' => '', ), ); } /** * Validates one rule. * * @param array $raw Rule from the admin screen. * @return array|WP_Error */ public static function normalize( array $raw ) { $path = trim( (string) ( $raw['pathPrefix'] ?? '' ) ); if ( '' !== $path && ( '/' !== $path[0] || strlen( $path ) > 255 || ( false !== strpos( $path, '*' ) && '/*' !== substr( $path, -2 ) ) || 1 < substr_count( $path, '*' ) ) ) { return new WP_Error( 'stand_invalid', __( 'A path pattern is empty, starts with /, and can end with /* for a page and its child pages.', 'stand-chat' ) ); } $trigger = is_array( $raw['trigger'] ?? null ) ? $raw['trigger'] : array(); $type = (string) ( $trigger['type'] ?? 'immediate' ); $type = in_array( $type, self::TRIGGERS, true ) ? $type : 'immediate'; $action = is_array( $raw['action'] ?? null ) ? $raw['action'] : array(); $action_type = (string) ( $action['type'] ?? 'showButton' ); $present = is_array( $raw['presentation'] ?? null ) ? $raw['presentation'] : array(); $message = trim( wp_strip_all_tags( (string) ( $action['initialMessage'] ?? '' ) ) ); $name = trim( wp_strip_all_tags( (string) ( $raw['name'] ?? '' ) ) ); $rule_id = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) ( $raw['ruleId'] ?? '' ) ); return array( 'ruleId' => '' !== $rule_id ? substr( (string) $rule_id, 0, 80 ) : wp_generate_uuid4(), 'name' => '' !== $name ? mb_substr( $name, 0, 120 ) : __( 'Behavior rule', 'stand-chat' ), 'enabled' => ! array_key_exists( 'enabled', $raw ) || (bool) $raw['enabled'], 'pathPrefix' => $path, 'hideButtonUntilTriggered' => 'immediate' !== $type, 'presentation' => array( 'noGreetings' => ! empty( $present['noGreetings'] ), 'halfSize' => ! empty( $present['halfSize'] ), 'stepAside' => ! empty( $present['stepAside'] ), ), 'trigger' => array( 'type' => $type, 'delayMs' => 'delay' === $type ? min( 60000, max( 0, (int) ( $trigger['delayMs'] ?? 5000 ) ) ) : 0, 'scrollPercent' => min( 100, max( 1, (int) ( $trigger['scrollPercent'] ?? 70 ) ) ), ), 'action' => array( 'type' => in_array( $action_type, self::ACTIONS, true ) ? $action_type : 'showButton', 'initialMessage' => mb_substr( $message, 0, 500 ), ), ); } /** * Path without trailing slashes ("/" stays "/"). * * @param string $path Path. */ private static function trim_slash( string $path ): string { $path = '' === $path || '/' !== $path[0] ? '/' . $path : $path; $path = rtrim( $path, '/' ); return '' === $path ? '/' : $path; } /** * Removes a subdirectory network prefix from a path. * * @param string $path Path from the domain root. */ private static function strip_prefix( string $path ): string { $prefix = Environment::path_prefix(); if ( '' !== $prefix && 0 === strpos( $path, $prefix ) ) { return substr( $path, strlen( $prefix ) ); } return $path; } }