self::VERSION, 'settings' => self::default_settings(), 'labels' => array(), 'nodes' => array( array( 'id' => 'start', 'type' => 'start', 'position' => array( 'x' => 0, 'y' => 80, ), 'data' => array( 'title' => __( 'Take the quickscan', 'funnelfork' ), 'intro' => __( 'Answer a few short questions and get personal advice.', 'funnelfork' ), 'buttonText' => __( 'Start', 'funnelfork' ), 'showIntro' => true, ), ), array( 'id' => 'q1', 'type' => 'question', 'position' => array( 'x' => 340, 'y' => 40, ), 'data' => array( 'question' => __( 'Your first question?', 'funnelfork' ), 'description' => '', 'answerType' => self::ANSWER_YESNO, 'answers' => self::yesno_answers(), ), ), array( 'id' => 'end1', 'type' => 'end', 'position' => array( 'x' => 720, 'y' => 0, ), 'data' => array( 'title' => __( 'Great!', 'funnelfork' ), 'content' => __( 'This is the result for visitors who answered "Yes".', 'funnelfork' ), 'showLabels' => true, 'showAnswers' => false, 'isDefault' => false, 'cta' => array( 'type' => 'text' ), ), ), array( 'id' => 'end2', 'type' => 'end', 'position' => array( 'x' => 720, 'y' => 260, ), 'data' => array( 'title' => __( 'Thank you', 'funnelfork' ), 'content' => __( 'This is the default result. Answers without a line end up here.', 'funnelfork' ), 'showLabels' => true, 'showAnswers' => false, 'isDefault' => true, 'cta' => array( 'type' => 'text' ), ), ), ), 'edges' => array( array( 'id' => 'e_start_q1', 'source' => 'start', 'sourceHandle' => 'next', 'target' => 'q1', ), array( 'id' => 'e_q1_yes', 'source' => 'q1', 'sourceHandle' => 'yes', 'target' => 'end1', ), ), ); } /** * Default scan settings. */ public static function default_settings(): array { /** * Filters the default scan settings. * * @param array $settings Default settings. */ return (array) apply_filters( 'funnelfork_default_settings', array( 'mode' => self::MODE_FLOW, 'showProgress' => true, 'showBack' => true, 'transition' => 'slide', ) ); } /** * The two answers of a yes/no question. */ public static function yesno_answers(): array { return array( array( 'id' => 'yes', 'text' => __( 'Yes', 'funnelfork' ), 'labels' => array(), 'points' => 0, ), array( 'id' => 'no', 'text' => __( 'No', 'funnelfork' ), 'labels' => array(), 'points' => 0, ), ); } /** * Scan modes. Free ships "flow" and "score"; add-ons may add more. */ public static function modes(): array { return (array) apply_filters( 'funnelfork_modes', array( self::MODE_FLOW, self::MODE_SCORE ) ); } /** * Transitions between questions. Free ships "slide"; add-ons may add more. */ public static function transitions(): array { return (array) apply_filters( 'funnelfork_transitions', array( 'slide' ) ); } /** * Editor labels of the transitions, keyed by transition. An add-on that * adds a transition adds its label here. */ public static function transition_labels(): array { return (array) apply_filters( 'funnelfork_transition_labels', array( 'slide' => __( 'Slide horizontally', 'funnelfork' ) ) ); } /** * Endpoint call-to-action types. Free ships "text"; add-ons may add more * (for example a Gravity Forms form or a shortcode). */ public static function endpoint_types(): array { return (array) apply_filters( 'funnelfork_endpoint_types', array( 'text' ) ); } /** * Sanitizes and repairs a full graph. * * @param array $raw Untrusted graph data. */ public static function sanitize( array $raw ): array { $graph = array( 'version' => self::VERSION, 'settings' => self::sanitize_settings( $raw['settings'] ?? array() ), 'labels' => self::sanitize_labels( $raw['labels'] ?? array() ), 'nodes' => array(), 'edges' => array(), ); $label_ids = wp_list_pluck( $graph['labels'], 'id' ); $seen_ids = array(); $has_start = false; foreach ( (array) ( $raw['nodes'] ?? array() ) as $node ) { if ( ! is_array( $node ) ) { continue; } $clean = self::sanitize_node( $node, $label_ids ); if ( null === $clean || isset( $seen_ids[ $clean['id'] ] ) ) { continue; } if ( 'start' === $clean['type'] ) { if ( $has_start ) { continue; } $has_start = true; } $seen_ids[ $clean['id'] ] = true; $graph['nodes'][] = $clean; } if ( ! $has_start ) { $defaults = self::default_graph(); array_unshift( $graph['nodes'], $defaults['nodes'][0] ); } $graph['nodes'] = self::ensure_default_endpoint( $graph['nodes'] ); $graph['edges'] = self::sanitize_edges( $raw['edges'] ?? array(), $graph['nodes'] ); /** * Filters the sanitized graph. Add-ons use this to keep their own (sanitized) data. * * @param array $graph Sanitized graph. * @param array $raw Raw, untrusted input. */ return (array) apply_filters( 'funnelfork_sanitize_graph', $graph, $raw ); } /** * Sanitizes scan settings. * * @param mixed $raw Raw settings. */ private static function sanitize_settings( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); $defaults = self::default_settings(); $mode = sanitize_key( $raw['mode'] ?? $defaults['mode'] ); if ( ! in_array( $mode, self::modes(), true ) ) { $mode = $defaults['mode']; } // A transition of an inactive add-on is kept; the runner falls back to "slide". $transition = sanitize_key( $raw['transition'] ?? '' ); if ( '' === $transition ) { $transition = $defaults['transition']; } $clean = self::with_addons( array( 'mode' => $mode, 'showProgress' => (bool) ( $raw['showProgress'] ?? $defaults['showProgress'] ), 'showBack' => (bool) ( $raw['showBack'] ?? $defaults['showBack'] ), 'transition' => $transition, ), $raw ); /** * Filters the sanitized scan settings. * * @param array $clean Sanitized settings. * @param array $raw Raw, untrusted settings. */ return (array) apply_filters( 'funnelfork_sanitize_settings', $clean, $raw ); } /** * Sanitizes the label list. * * @param mixed $raw Raw labels. */ private static function sanitize_labels( $raw ): array { $labels = array(); $seen = array(); foreach ( (array) $raw as $label ) { if ( ! is_array( $label ) ) { continue; } $id = self::sanitize_id( $label['id'] ?? '' ); if ( '' === $id || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $labels[] = array( 'id' => $id, 'name' => sanitize_text_field( $label['name'] ?? '' ), 'color' => sanitize_hex_color( $label['color'] ?? '' ) ?: '#3858e9', 'description' => sanitize_textarea_field( $label['description'] ?? '' ), ); } return $labels; } /** * Sanitizes one node. Returns null for invalid nodes. * * @param array $node Raw node. * @param string[] $label_ids Known label IDs. */ private static function sanitize_node( array $node, array $label_ids ): ?array { $id = self::sanitize_id( $node['id'] ?? '' ); $type = $node['type'] ?? ''; if ( '' === $id || ! in_array( $type, array( 'start', 'question', 'end' ), true ) ) { return null; } $data = is_array( $node['data'] ?? null ) ? $node['data'] : array(); $clean = array( 'id' => $id, 'type' => $type, 'position' => array( 'x' => round( (float) ( $node['position']['x'] ?? 0 ), 1 ), 'y' => round( (float) ( $node['position']['y'] ?? 0 ), 1 ), ), ); switch ( $type ) { case 'start': $start = self::with_addons( array( 'title' => sanitize_text_field( $data['title'] ?? '' ), 'intro' => wp_kses_post( $data['intro'] ?? '' ), 'buttonText' => sanitize_text_field( $data['buttonText'] ?? '' ), 'showIntro' => (bool) ( $data['showIntro'] ?? true ), ), $data ); /** * Filters the sanitized data of the start node. * * @param array $start Sanitized data. * @param array $data Raw data. */ $clean['data'] = (array) apply_filters( 'funnelfork_sanitize_start', $start, $data ); break; case 'question': $clean['data'] = self::sanitize_question( $data, $label_ids ); break; case 'end': // A type of an inactive add-on is kept, and treated as "text" until the add-on is back. $cta_type = sanitize_key( $data['cta']['type'] ?? '' ); $cta = self::with_addons( array( 'type' => '' !== $cta_type ? $cta_type : 'text' ), $data['cta'] ?? null ); $end = self::with_addons( array( 'title' => sanitize_text_field( $data['title'] ?? '' ), 'content' => wp_kses_post( $data['content'] ?? '' ), 'showLabels' => (bool) ( $data['showLabels'] ?? true ), 'showAnswers' => (bool) ( $data['showAnswers'] ?? false ), 'isDefault' => (bool) ( $data['isDefault'] ?? false ), 'scoreMin' => self::sanitize_bound( $data['scoreMin'] ?? null ), 'scoreMax' => self::sanitize_bound( $data['scoreMax'] ?? null ), 'showScore' => (bool) ( $data['showScore'] ?? true ), /** * Filters the sanitized call-to-action of an endpoint. * * @param array $cta Sanitized CTA (at least a "type"). * @param array $raw Raw CTA data. */ 'cta' => (array) apply_filters( 'funnelfork_sanitize_endpoint_cta', $cta, (array) ( $data['cta'] ?? array() ) ), ), $data ); /** * Filters the sanitized data of an endpoint. * * @param array $end Sanitized data. * @param array $data Raw data. */ $clean['data'] = (array) apply_filters( 'funnelfork_sanitize_endpoint', $end, $data ); break; } return $clean; } /** * Sanitizes the data of a question node. * * @param array $data Raw data. * @param string[] $label_ids Known label IDs. */ private static function sanitize_question( array $data, array $label_ids ): array { $type = ( $data['answerType'] ?? '' ) === self::ANSWER_CHOICE ? self::ANSWER_CHOICE : self::ANSWER_YESNO; $answers = array(); $seen = array(); foreach ( (array) ( $data['answers'] ?? array() ) as $answer ) { if ( ! is_array( $answer ) ) { continue; } $id = self::sanitize_id( $answer['id'] ?? '' ); if ( '' === $id || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $clean = self::with_addons( array( 'id' => $id, 'text' => sanitize_text_field( $answer['text'] ?? '' ), 'labels' => array_values( array_intersect( array_map( 'strval', (array) ( $answer['labels'] ?? array() ) ), $label_ids ) ), 'points' => self::sanitize_points( $answer['points'] ?? 0 ), ), $answer ); /** * Filters a sanitized answer. Its "id" cannot be changed. * * @param array $clean Sanitized answer. * @param array $answer Raw answer. */ $answers[] = array_merge( (array) apply_filters( 'funnelfork_sanitize_answer', $clean, $answer ), array( 'id' => $id ) ); } if ( self::ANSWER_YESNO === $type ) { // A yes/no question always has exactly the two answers "yes" and "no". $by_id = array_column( $answers, null, 'id' ); $answers = array(); foreach ( self::yesno_answers() as $default ) { $answers[] = isset( $by_id[ $default['id'] ] ) ? $by_id[ $default['id'] ] : $default; } } $question = self::with_addons( array( 'question' => sanitize_text_field( $data['question'] ?? '' ), 'description' => sanitize_textarea_field( $data['description'] ?? '' ), 'answerType' => $type, 'answers' => $answers, ), $data ); /** * Filters the sanitized data of a question. * * @param array $question Sanitized data. * @param array $data Raw data. * @param string[] $label_ids Known label IDs. */ return (array) apply_filters( 'funnelfork_sanitize_question', $question, $data, $label_ids ); } /** * Adds the sanitized "addons" object of $raw to $clean, when there is one. * * @param array $clean Sanitized data. * @param mixed $raw Raw data that may contain "addons". */ private static function with_addons( array $clean, $raw ): array { $addons = self::sanitize_addons( is_array( $raw ) ? ( $raw['addons'] ?? null ) : null ); if ( $addons ) { $clean['addons'] = $addons; } return $clean; } /** * Sanitizes add-on data without knowing its shape: keys keep only letters, * digits, "_" and "-", strings go through wp_kses_post(), numbers and booleans * stay, anything else (and anything nested deeper than 5 levels) is dropped. * * @param mixed $raw Raw add-on data. * @param int $depth Current depth. */ public static function sanitize_addons( $raw, int $depth = 0 ): array { if ( ! is_array( $raw ) || $depth > 5 ) { return array(); } $is_list = array_is_list( $raw ); $clean = array(); foreach ( $raw as $key => $value ) { // Like sanitize_key(), but keeping upper case (add-ons use camelCase keys). $key = $is_list ? $key : substr( (string) preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $key ), 0, 64 ); if ( '' === $key ) { continue; } if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { $clean[ $key ] = $value; } elseif ( is_string( $value ) ) { $clean[ $key ] = wp_kses_post( $value ); } elseif ( is_array( $value ) ) { $clean[ $key ] = self::sanitize_addons( $value, $depth + 1 ); } } return $is_list ? array_values( $clean ) : $clean; } /** * Makes sure there is at least one endpoint and exactly one default endpoint. * * @param array $nodes Sanitized nodes. */ private static function ensure_default_endpoint( array $nodes ): array { $end_keys = array_keys( array_filter( $nodes, static fn( $n ) => 'end' === $n['type'] ) ); if ( ! $end_keys ) { $defaults = self::default_graph(); $nodes[] = $defaults['nodes'][3]; return $nodes; } $has_default = false; foreach ( $end_keys as $key ) { if ( $nodes[ $key ]['data']['isDefault'] ) { if ( $has_default ) { $nodes[ $key ]['data']['isDefault'] = false; } $has_default = true; } } if ( ! $has_default ) { $nodes[ $end_keys[0] ]['data']['isDefault'] = true; } return $nodes; } /** * Sanitizes edges: they must connect existing handles, one line per answer, * nothing may point back to the start and the flow may not loop. * * @param mixed $raw Raw edges. * @param array $nodes Sanitized nodes. */ private static function sanitize_edges( $raw, array $nodes ): array { $handles = array(); $types = array(); foreach ( $nodes as $node ) { $types[ $node['id'] ] = $node['type']; if ( 'start' === $node['type'] ) { $handles[ $node['id'] ] = array( 'next' ); } elseif ( 'question' === $node['type'] ) { $handles[ $node['id'] ] = wp_list_pluck( $node['data']['answers'], 'id' ); } } $edges = array(); $used = array(); $targets = array(); foreach ( (array) $raw as $edge ) { if ( ! is_array( $edge ) ) { continue; } $source = self::sanitize_id( $edge['source'] ?? '' ); $handle = self::sanitize_id( $edge['sourceHandle'] ?? '' ); $target = self::sanitize_id( $edge['target'] ?? '' ); if ( ! isset( $handles[ $source ] ) || ! in_array( $handle, $handles[ $source ], true ) ) { continue; } if ( ! isset( $types[ $target ] ) || 'start' === $types[ $target ] || $source === $target ) { continue; } if ( isset( $used[ $source . '|' . $handle ] ) ) { continue; } if ( self::reaches( $target, $source, $targets ) ) { continue; // Would create a loop. } $used[ $source . '|' . $handle ] = true; $targets[ $source ][] = $target; $id = self::sanitize_id( $edge['id'] ?? '' ); $edges[] = array( 'id' => '' !== $id ? $id : 'e_' . $source . '_' . $handle, 'source' => $source, 'sourceHandle' => $handle, 'target' => $target, ); } return $edges; } /** * Whether $to can be reached from $from following the given adjacency list. * * @param string $from Start node ID. * @param string $to Node ID to look for. * @param array $adjacency Map of node ID => target IDs. */ private static function reaches( string $from, string $to, array $adjacency ): bool { $stack = array( $from ); $seen = array(); while ( $stack ) { $current = array_pop( $stack ); if ( $current === $to ) { return true; } if ( isset( $seen[ $current ] ) ) { continue; } $seen[ $current ] = true; foreach ( $adjacency[ $current ] ?? array() as $next ) { $stack[] = $next; } } return false; } /** * Sanitizes the points of an answer: a whole number within the limit. * * @param mixed $points Raw points. */ private static function sanitize_points( $points ): int { return is_numeric( $points ) ? max( -self::SCORE_LIMIT, min( self::SCORE_LIMIT, (int) round( (float) $points ) ) ) : 0; } /** * Sanitizes a score range bound. Empty means open (no bound). * * @param mixed $bound Raw bound. */ private static function sanitize_bound( $bound ): ?int { if ( null === $bound || '' === $bound || ! is_numeric( $bound ) ) { return null; } return self::sanitize_points( $bound ); } /** * Sanitizes a node, answer, label or edge ID. * * @param mixed $id Raw ID. */ private static function sanitize_id( $id ): string { return substr( preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $id ), 0, 64 ); } /** * Question IDs in canvas order: left to right, then top to bottom. * In score mode an answer without a line goes to the next question in this order. * * @param array $graph Sanitized graph. * @return string[] */ public static function question_order( array $graph ): array { $questions = array_values( array_filter( $graph['nodes'], static fn( $n ) => 'question' === $n['type'] ) ); usort( $questions, static fn( $a, $b ) => array( $a['position']['x'], $a['position']['y'] ) <=> array( $b['position']['x'], $b['position']['y'] ) ); return wp_list_pluck( $questions, 'id' ); } /** * Counts nodes per type, for the admin overview. * * @param array $graph Sanitized graph. */ public static function count_nodes( array $graph ): array { $counts = array( 'question' => 0, 'end' => 0, ); foreach ( $graph['nodes'] as $node ) { if ( isset( $counts[ $node['type'] ] ) ) { ++$counts[ $node['type'] ]; } } return $counts; } }