'0',
'search' => '',
'operators' => '',
'greater_than' => false,
'less_than' => false,
'display' => '',
'sort_key' => 'id',
'sort_direction' => 'DESC',
'sort_is_num' => true,
'secondary_sort_key' => '',
'secondary_sort_direction' => 'DESC',
'unique' => false,
'limit' => '1',
'search_mode' => 'all',
'separator' => '',
'search_empty' => false,
'default' => '',
'link' => false,
],
$atts,
'gfsearch'
);
// Allow everything wp_kses_post allows plus and its attributes
$allowed_tags = wp_kses_allowed_html( 'post' );
$a_tags = [
'href' => true,
'title' => true,
'target' => true,
'rel' => true,
'class' => true,
'id' => true,
'style' => true,
];
$allowed_tags['a'] = $a_tags + ( $allowed_tags['a'] ?? [] );
$content = html_entity_decode( $content, ENT_QUOTES );
$form_id = array_map( 'intval', explode( ',', $atts['target'] ) );
$search_criteria = [];
$search_criteria['status'] = 'active';
$search_criteria['field_filters'] = [];
$search_criteria['field_filters']['mode'] = in_array( strtolower( $atts['search_mode'] ), [ 'all', 'any' ], true ) ? strtolower( $atts['search_mode'] ) : 'all';
if ( ! empty( $atts['search'] ) && empty( $atts['display'] ) && ! $atts['search_empty'] ) {
return '';
}
$search_ids = array_map( fn( $search_id ) => GFCommon::replace_variables( $search_id, [], [] ), explode( ',', $atts['search'] ) );
$search_ids = array_map( 'trim', $search_ids );
// Parse operators if provided
$operators = [];
if ( ! empty( $atts['operators'] ) ) {
$operators = array_map( 'trim', explode( ',', $atts['operators'] ) );
}
$content_values = array_map( 'trim', explode( '|', $content ) );
foreach ( $search_ids as $index => $search_id ) {
if ( empty( $search_id ) ) {
continue;
}
$current_field = GFAPI::get_field( $form_id[0], $search_id );
if ( $current_field && 'number' === $current_field['type'] ) {
$content_values[ $index ] = str_replace( ',', '', $content_values[ $index ] );
}
// Add operator if provided for this field
if ( ! empty( $operators[ $index ] ) ) {
/*
* Validate operator against supported operators
* is, = (exact match)
* isnot, isnot, != (not equal) (<> not supported due to sanitizing issues)
* contains (Substring search-converted to LIKE %value%)
* like: SQL like with wildcards
* notin, not in (values not in array)
* in (values in array)
* lt, gt, lt=, gt=, (numeric operators)
*/
$supported_operators = [
'=',
'is',
'is not',
'isnot',
'!=',
'contains',
'like',
'not in',
'notin',
'in',
'lt',
'gt',
'gt=',
'lt=',
];
if ( str_contains( $content_values[ $index ], 'array(' ) && in_array( $operators[ $index ], [ 'in', 'notin', 'not in' ], true ) ) {
$json_string = str_replace( [ 'array(', ')', "'" ], [ '[', ']', '"' ], $content_values[ $index ] );
$content_values[ $index ] = json_decode( $json_string, true );
$content_values[ $index ] = array_map(
fn( $value ) => GFCommon::replace_variables( $value, [], [] ),
$content_values[ $index ]
);
$field_filter = [
'key' => $search_id,
'value' => $content_values[ $index ],
];
} else {
$field_filter = [
'key' => $search_id,
'value' => GFCommon::replace_variables( $content_values[ $index ], [], [] ),
];
}
if ( in_array( $operators[ $index ], $supported_operators, true ) ) {
$operators[ $index ] = str_replace( 'gt', '>', $operators[ $index ] );
$operators[ $index ] = str_replace( 'lt', '<', $operators[ $index ] );
$field_filter['operator'] = $operators[ $index ];
}
} else {
$field_filter = [
'key' => $search_id,
'value' => GFCommon::replace_variables( $content_values[ $index ], [], [] ),
];
}
$search_criteria['field_filters'][] = $field_filter;
}
// Process greater_than attribute
if ( $atts['greater_than'] ) {
$greater_than = array_map( 'trim', explode( ',', $atts['greater_than'] ) );
if ( count( $greater_than ) >= 2 ) {
$search_criteria['field_filters'][] = [
'key' => intval( $greater_than[0] ),
'value' => floatval( $greater_than[1] ),
'operator' => '>',
];
}
}
// Process less_than attribute
if ( $atts['less_than'] ) {
$less_than = array_map( 'trim', explode( ',', $atts['less_than'] ) );
if ( count( $less_than ) >= 2 ) {
$search_criteria['field_filters'][] = [
'key' => intval( $less_than[0] ),
'value' => floatval( $less_than[1] ),
'operator' => '<',
];
}
}
$sorting = [
'key' => sanitize_text_field( $atts['sort_key'] ),
'direction' => in_array( strtoupper( $atts['sort_direction'] ), [ 'ASC', 'DESC', 'RAND' ], true ) ? strtoupper( $atts['sort_direction'] ) : 'DESC',
'is_numeric' => ! ( strtolower( $atts['sort_is_num'] ) === 'false' ) && $atts['sort_is_num'],
];
$secondary_sort_key = sanitize_text_field( $atts['secondary_sort_key'] );
$secondary_sort_direction = in_array( strtoupper( $atts['secondary_sort_direction'] ), [ 'ASC', 'DESC' ], true )
? strtoupper( $atts['secondary_sort_direction'] )
: 'DESC';
$paging_offset = 0;
$total_count = 0;
if ( 'all' !== strtolower( $atts['limit'] ) ) {
$original_limit = empty( $atts['limit'] ) ? 1 : (int) $atts['limit'];
if ( $secondary_sort_key ) {
$atts['limit'] = 'all';
}
}
if ( empty( $atts['limit'] ) ) {
$page_size = 1;
} elseif ( 'all' === strtolower( $atts['limit'] ) ) {
$page_size = 25;
} else {
$page_size = min( intVal( $atts['limit'] ), 25 );
}
$paging = [
'offset' => $paging_offset,
'page_size' => $page_size,
];
$entries = GFAPI::get_entries( $form_id, $search_criteria, $sorting, $paging, $total_count );
if ( 'all' === $atts['limit'] || intVal( $atts['limit'] ) > 25 ) {
$count = count( $entries );
while ( $total_count > $count ) {
$paging['offset'] += 25;
$new_entries = GFAPI::get_entries( $form_id, $search_criteria, $sorting, $paging, $total_count );
array_push( $entries, ...$new_entries ); // $entries = array_merge( $entries, $new_entries );
if ( is_numeric( $atts['limit'] ) && count( $entries ) > $atts['limit'] ) {
break;
}
$count = count( $entries );
}
if ( is_numeric( $atts['limit'] ) ) {
$entries = array_slice( $entries, 0, intVal( $atts['limit'] ) );
}
}
if ( empty( $entries ) ) {
// If default contains multiple values, use the first one
$default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
return wp_kses_post( $default_values[0] ?? '' );
}
if ( ! empty( $secondary_sort_key ) && 'RAND' !== $sorting['direction'] ) {
$grouped_entries = [];
foreach ( $entries as $entry ) {
$primary_key_value = $entry[ $sorting['key'] ] ?? ''; // Use the primary sort key as the group key
$grouped_entries[ $primary_key_value ][] = $entry;
}
// Sort each group based on the secondary sort key
foreach ( $grouped_entries as &$group ) {
usort(
$group,
function ( $entry1, $entry2 ) use ( $secondary_sort_key, $secondary_sort_direction ) {
$value1 = $entry1[ $secondary_sort_key ] ?? '';
$value2 = $entry2[ $secondary_sort_key ] ?? '';
// For non-numeric values, use string comparison
if ( ! is_numeric( $value1 ) || ! is_numeric( $value2 ) ) {
if ( strtoupper( $secondary_sort_direction ) === 'ASC' ) {
return strcasecmp( $value1, $value2 ); // Ascending order for strings
}
return strcasecmp( $value2, $value1 ); // Descending order for strings
}
// If numeric, compare numerically
$value1 = (float) $value1;
$value2 = (float) $value2;
if ( strtoupper( $secondary_sort_direction ) === 'ASC' ) {
return $value1 <=> $value2; // Ascending order for numbers
}
return $value2 <=> $value1; // Descending order for numbers
}
);
}
unset( $group ); // Clean up the reference variable to avoid potential bugs
// Flatten groups back into a single array, retaining primary sort order
$entries = [];
foreach ( $grouped_entries as $group ) {
$entries = array_merge( $entries, $group );
}
}
if ( isset( $original_limit ) && $original_limit < count( $entries ) ) {
$entries = array_slice( $entries, 0, $original_limit );
}
$results = [];
$atts['display'] = $this->convert_curly_shortcodes( $atts['display'] );
// Mask nested gfsearch shortcodes [gfsearch ...]...[/gfsearch]
// Mask only the display attribute value inside nested gfsearch shortcodes
$nested_gfsearch_map = [];
$masked_display = $atts['display'];
// Mask display attribute in [gfsearch ... display="..." or display='...']...[/gfsearch]
$masked_display = preg_replace_callback(
'/(\[gfsearch[^\]]*?\sdisplay=("|\')(.*?)(\2)[^\]]*\])/i',
function ( $m ) use ( &$nested_gfsearch_map ) {
$key = '__NESTED_GFSEARCH_DISPLAY_' . count( $nested_gfsearch_map ) . '__';
$nested_gfsearch_map[ $key ] = $m[3];
// Replace only the display value
return str_replace( $m[3], $key, $m[0] );
},
$masked_display
);
// Updated regex: only match curly-brace {id}, {gfs:id}, {gfs:id;default} and plain gfs:id (not just numbers)
$regex = '/{(gfs:)?([^{};]+)(;([^{}]+))?}|\bgfs:([0-9]+)\b/';
preg_match_all( $regex, $masked_display, $matches, PREG_SET_ORDER );
$display_ids = [];
$tag_defaults = [];
if ( empty( $matches ) ) {
$display_ids = array_map( 'sanitize_text_field', explode( ',', $masked_display ) );
$display_ids = array_map( 'trim', $display_ids );
} else {
foreach ( $matches as $match ) {
// If curly-brace format, use those capture groups
if ( isset( $match[2] ) && '' !== $match[2] ) {
$field_id = $match[2];
if ( ! empty( $match[4] ) ) {
$tag_defaults[ $field_id ] = $match[4];
}
$display_ids[] = sanitize_text_field( $field_id );
// If plain gfs:id format
} elseif ( isset( $match[5] ) && '' !== $match[5] ) {
$field_id = $match[5];
$display_ids[] = sanitize_text_field( $field_id );
}
}
}
$display_ids = array_unique( $display_ids );
$multi_input_present = false;
// Parse default values
$default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
$default_count = count( $default_values );
foreach ( $entries as $entry ) {
$entry_results = [];
foreach ( $display_ids as $index => $display_id ) {
if ( 'meta' === $display_id ) {
if ( ! empty( $atts['separator'] ) ) {
$entry_results[ $display_id ] = implode( $atts['separator'], array_keys( $entry ) );
} else {
$entry_results[ $display_id ] = '- ' . implode( '
- ', array_keys( $entry ) ) . '
';
}
continue;
}
if ( 'num_results' === $display_id ) {
continue;
}
$field = GFAPI::get_field( $entry['form_id'], $display_id );
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $field && 'number' === $field->type ) {
$field_value = GFCommon::format_number( $entry[ $display_id ], $field['numberFormat'], $entry['currency'], true );
} elseif ( $field && 'date' === $field->type ) {
$field_value = GFCommon::date_display( $entry[ $display_id ], 'Y-m-d', $field->dateFormat );
} elseif ( $field && $this->is_multi_input_field( $field ) && ! str_contains( $display_id, '.' ) ) {
$multi_input_present = true;
$ids = array_column( $field['inputs'], 'id' );
$field_results = [];
foreach ( $ids as $id ) {
if ( ! empty( $entry[ $id ] ) ) {
$field_results[] = $entry[ $id ];
}
}
$field_value = implode( ' ', $field_results );
} else {
$field_value = $entry[ $display_id ] ?? '';
if ( '' === $field_value ) {
$temp = GFCommon::replace_variables( '{' . $display_id . '}', GFAPI::get_form( $entry['form_id'] ), $entry );
if ( '{' . $display_id . '}' !== $temp ) {
$field_value = $temp;
}
}
}
// Use default value if field value is empty
if ( '' === $field_value || is_null( $field_value ) ) {
// Check if there's a tag-specific default value for this field
if ( isset( $tag_defaults[ $display_id ] ) ) {
$field_value = $tag_defaults[ $display_id ];
} elseif ( 1 === $default_count ) { // Otherwise use the global default values
// If there's only one default value, use it for all display values
$field_value = $default_values[0];
} elseif ( $index < $default_count ) {
// If there are multiple default values, use the corresponding one
$field_value = $default_values[ $index ];
} else {
$field_value = '';
}
}
$entry_results[ $display_id ] = $field_value;
}
// We only need to filter if the default value is empty
if ( '' === $atts['default'] || is_null( $atts['default'] ) ) {
$entry_results = array_filter( $entry_results, fn( $value ) => '' !== $value && ! is_null( $value ) );
}
if ( ! empty( $matches ) ) {
$display_format = $masked_display;
foreach ( $display_ids as $index => $display_id ) {
if ( 'num_results' === $display_id ) {
continue;
}
$value = $entry_results[ $display_id ] ?? '';
// If the value is empty and this is the first placeholder, use tag-specific default if available
if ( ! $value && 0 === $index ) {
if ( isset( $tag_defaults[ $display_id ] ) ) {
$value = $tag_defaults[ $display_id ];
} else {
$display_format = '';
break;
}
}
// Replace curly-brace formats first
$display_format = str_replace( '{gfs:' . $display_id . '}', $value, $display_format );
$display_format = str_replace( '{' . $display_id . '}', $value, $display_format );
// Replace {gfs:id;default-value} format
$pattern = '/{gfs:' . preg_quote( $display_id, '/' ) . ';[^{}]+}/';
$display_format = preg_replace( $pattern, $value, $display_format );
$pattern = '/{' . preg_quote( $display_id, '/' ) . ';[^{}]+}/';
$display_format = preg_replace( $pattern, $value, $display_format );
// Replace plain gfs:id only when not part of a larger word or attribute (not preceded/followed by [\w\.:])
$display_format = preg_replace( '/(?' . $result_text . '';
}
$results[] = $result_text;
} else {
$result_text = implode( ', ', $entry_results );
if ( $atts['link'] ) {
$result_text = '' . $result_text . '';
}
$results[] = $result_text;
}
}
$results = array_map( 'trim', $results );
$results = array_filter( $results, fn( $value ) => '' !== $value && ! is_null( $value ) );
if ( empty( $results ) ) {
// If default contains multiple values, use the first one
$default_values = array_map( 'trim', explode( '||', $atts['default'] ) );
return wp_kses_post( $default_values[0] ?? '' );
}
if ( empty( $atts['separator'] ) ) {
$separator = ( count( $display_ids ) > 1 || $multi_input_present ) ? '; ' : ', ';
} elseif ( strtolower( '__none__' ) === $atts['separator'] ) {
$separator = '';
} else {
$separator = $atts['separator'];
}
// Process shortcodes first, then apply uniqueness to the final output
$final_results = array_map(
function ( $result ) use ( $allowed_tags ) {
return wp_kses( do_shortcode( $result ), $allowed_tags );
},
$results
);
if ( $atts['unique'] ) {
$final_results = array_unique( $final_results );
}
$final_results = array_map(
function ( $result ) use ( $final_results ) {
return str_replace( '{gfs:num_results}', count( $final_results ), $result );
},
$final_results
);
return implode( $separator, $final_results );
}
/**
* Determines if a given field is a multi-input field.
*
* @param mixed $field The field configuration array. Expected to contain 'type' and optionally 'inputType' keys.
*
* @return bool True if the field is a multi-input field, false otherwise.
*/
private function is_multi_input_field( $field ): bool {
return 'name' === $field['type'] || 'address' === $field['type'] || 'checkbox' === $field['type'] || ( ( 'image_choice' === $field['type'] || 'multi_choice' === $field['type'] ) && 'checkbox' === $field['inputType'] );
}
/**
* Converts custom curly bracket shortcodes into standard WordPress-style shortcodes.
*
* Converts content with shortcodes in the format `{{shortcode attributes}}content{{/shortcode}}`
* to `[shortcode attributes]content[/shortcode]`. Handles standalone shortcodes and unmatched closing tags.
*
* @param string $content The content containing curly bracket shortcodes.
*
* @return string The converted content with standard WordPress-style shortcodes.
*/
private function convert_curly_shortcodes( $content ) {
/* @var array $open_match */
while ( preg_match( '/\{\{(\w+)\b(.*?)\}\}/s', $content, $open_match, PREG_OFFSET_CAPTURE ) ) {
$tag = $open_match[1][0];
$attrs = $open_match[2][0];
$start_pos = $open_match[0][1];
$end_tag = '{{/' . $tag . '}}';
$end_pos = strpos( $content, $end_tag, $start_pos );
if ( false === $end_pos ) {
break; // malformed shortcode
}
$open_len = strlen( $open_match[0][0] );
$inner = substr( $content, $start_pos + $open_len, $end_pos - $start_pos - $open_len );
$replacement = '[' . $tag . $attrs . ']' . $inner . '[/' . $tag . ']';
$content = substr_replace( $content, $replacement, $start_pos, $end_pos + strlen( $end_tag ) - $start_pos );
}
// Handle standalone shortcodes like {{shortcode attr=...}} → [shortcode attr=...]
$content = preg_replace_callback(
'/\{\{(?!\/)([^\{\}\/]+?)\s*\}\}/',
fn( $m ) => '[' . $m[1] . ']',
$content
);
// Handle unmatched closing tags {{/shortcode}} → [/shortcode]
return preg_replace( '/\{\{\/(\w+)\s*\}\}/', '[/$1]', $content );
}
}
add_action(
'gform_loaded',
function () {
if ( class_exists( 'GFAddOn' ) ) {
GFAddOn::register( 'GFSearch' );
}
},
5
);