array( 'ID' => __( 'ID', 'ai-conduit' ), 'post_author' => __( 'Author ID', 'ai-conduit' ), 'post_parent' => __( 'Parent Post ID', 'ai-conduit' ), 'post_date' => __( 'Date', 'ai-conduit' ), 'post_status' => __( 'Status', 'ai-conduit' ), ), 'table' => $wpdb->posts, 'meta_table' => $wpdb->postmeta, 'meta_fk_column' => 'post_id', 'id_column' => 'ID', ); } /** * Map field keys to posts table columns. * * @since 1.0.0 * * @return array */ public function get_field_column_map(): array { return array( 'id' => 'ID', 'title' => 'post_title', 'content' => 'post_content', 'excerpt' => 'post_excerpt', 'date' => 'post_date', 'modified' => 'post_modified', 'status' => 'post_status', 'author' => 'post_author', 'slug' => 'post_name', ); } /** * Available native post fields. * * @since 1.0.0 * * @return array */ public function get_available_fields(): array { return array( 'id' => __( 'ID', 'ai-conduit' ), 'title' => __( 'Title', 'ai-conduit' ), 'content' => __( 'Content', 'ai-conduit' ), 'excerpt' => __( 'Excerpt', 'ai-conduit' ), 'date' => __( 'Date', 'ai-conduit' ), 'modified' => __( 'Modified Date', 'ai-conduit' ), 'status' => __( 'Status', 'ai-conduit' ), 'author' => __( 'Author Name', 'ai-conduit' ), 'featured_image_url' => __( 'Featured Image URL', 'ai-conduit' ), 'permalink' => __( 'Permalink', 'ai-conduit' ), 'slug' => __( 'Slug', 'ai-conduit' ), ); } /** * Available standard filters. * * Taxonomy and meta filters are dynamic and handled separately * in get_input_schema_properties(). * * @since 1.0.0 * * @return array */ public function get_available_filters(): array { return array( 'id' => array( 'type' => 'integer', 'description' => __( 'Fetch a specific post by ID', 'ai-conduit' ), ), 'title' => array( 'type' => 'string', 'description' => __( 'Search by post title', 'ai-conduit' ), ), 'author' => array( 'type' => 'integer', 'description' => __( 'Filter by author user ID', 'ai-conduit' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'Post status', 'ai-conduit' ), 'default' => 'publish', ), 'date_after' => array( 'type' => 'string', 'description' => __( 'Posts after this date (YYYY-MM-DD)', 'ai-conduit' ), ), 'date_before' => array( 'type' => 'string', 'description' => __( 'Posts before this date (YYYY-MM-DD)', 'ai-conduit' ), ), 'per_page' => array( 'type' => 'integer', 'description' => __( 'Number of results per page', 'ai-conduit' ), 'default' => 10, ), 'page' => array( 'type' => 'integer', 'description' => __( 'Page number for pagination (starts at 1)', 'ai-conduit' ), 'default' => 1, ), 'orderby' => array( 'type' => 'string', 'description' => __( 'Sort by field', 'ai-conduit' ), 'enum' => array( 'date', 'title', 'modified', 'ID', 'author' ), ), 'order' => array( 'type' => 'string', 'description' => __( 'Sort direction', 'ai-conduit' ), 'enum' => array( 'ASC', 'DESC' ), ), ); } /** * Build inputSchema properties including taxonomy and meta filters. * * Extends the parent method to add dynamic filters from the saved * taxonomy and meta_keys configuration. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @return array JSON Schema properties. */ public function get_input_schema_properties( array $config ): array { // Standard filters from parent. $properties = parent::get_input_schema_properties( $config ); // Taxonomy filters. foreach ( $config['taxonomies'] ?? array() as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_filter'] ) ) { continue; } $prop = array( 'type' => 'string', 'description' => sprintf( /* translators: %s: taxonomy slug */ __( 'Filter by %s', 'ai-conduit' ), $tax_slug ), ); if ( ! empty( $tax_config['terms'] ) ) { $prop['enum'] = $tax_config['terms']; } $properties[ $tax_slug ] = $prop; } // Meta key filters — array-of-conditions per key. foreach ( $config['meta_keys'] ?? array() as $meta_key => $meta_config ) { if ( empty( $meta_config['as_filter'] ) ) { continue; } $meta_type = $meta_config['type'] ?? 'CHAR'; $compare = $meta_config['compare'] ?? array( '=' ); $schema_type = in_array( $meta_type, array( 'NUMERIC', 'DECIMAL', 'UNSIGNED' ), true ) ? 'number' : 'string'; $schema_format = array( 'NUMERIC' => 'integer (e.g. 29)', 'UNSIGNED' => 'positive integer (e.g. 100)', 'DECIMAL' => 'decimal (e.g. 29.99)', 'DATE' => 'date (YYYY-MM-DD)', 'DATETIME' => 'date-time (YYYY-MM-DDTHH:MM:SS)', 'TIME' => 'time (HH:MM:SS)', ); $has_array_ops = array_intersect( array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), $compare ); $has_scalar_ops = array_diff( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ) ); $base_schema = array( 'type' => $schema_type ); if ( isset( $schema_format[ $meta_type ] ) ) { $base_schema['format'] = $schema_format[ $meta_type ]; } if ( ! empty( $has_array_ops ) && ! empty( $has_scalar_ops ) ) { $value_schema = array( 'oneOf' => array( $base_schema, array( 'type' => 'array', 'items' => $base_schema ), ), ); } elseif ( ! empty( $has_array_ops ) ) { $value_schema = array( 'type' => 'array', 'items' => $base_schema ); } else { $value_schema = $base_schema; } $has_exists_only = empty( $has_array_ops ) && empty( $has_scalar_ops ); $item_required = $has_exists_only ? array( 'compare' ) : array( 'value', 'compare' ); $item_properties = array( 'compare' => array( 'type' => 'string', 'enum' => array_values( $compare ), ), ); if ( ! $has_exists_only ) { $item_properties['value'] = $value_schema; } $properties[ $meta_key ] = array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => $item_properties, 'required' => $item_required, ), 'description' => sprintf( /* translators: %s: meta key name */ __( 'Filter by %s', 'ai-conduit' ), $meta_key ), ); } return $properties; } /** * Execute the posts query. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array List of post data arrays. */ public function execute( array $config, array $arguments ): array { $query_args = $this->build_query_args( $config, $arguments ); // Prevent duplicate rows when EXISTS meta_query JOINs the meta table. $needs_distinct = $this->meta_query_has_exists( $query_args ); if ( $needs_distinct ) { add_filter( 'posts_distinct', array( $this, 'filter_posts_distinct' ) ); } $query = new WP_Query( $query_args ); if ( $needs_distinct ) { remove_filter( 'posts_distinct', array( $this, 'filter_posts_distinct' ) ); } $selected_fields = $config['fields'] ?? array_keys( $this->get_available_fields() ); $meta_keys = $config['meta_keys'] ?? array(); $taxonomies = $config['taxonomies'] ?? array(); // Collect post IDs and extract native fields + taxonomies. $post_ids = wp_list_pluck( $query->posts, 'ID' ); $results = array(); foreach ( $query->posts as $post ) { $item = $this->extract_fields( $post, $selected_fields ); $item = $this->attach_taxonomies( $post, $item, $taxonomies ); $results[ $post->ID ] = $item; } // Batch-fetch meta for all posts in a single query. if ( ! empty( $post_ids ) && ! empty( $meta_keys ) ) { $meta_map = $this->batch_fetch_meta( $post_ids, $meta_keys ); foreach ( $meta_map as $post_id => $meta_values ) { if ( isset( $results[ $post_id ] ) ) { $results[ $post_id ] = array_merge( $results[ $post_id ], $meta_values ); } } } return array( 'data' => array_values( $results ), '_pagination' => array( 'page' => max( 1, absint( $arguments['page'] ?? 1 ) ), 'per_page' => $query_args['posts_per_page'], 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, ), ); } /** * Build WP_Query arguments from config and LLM arguments. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array WP_Query compatible arguments. */ private function build_query_args( array $config, array $arguments ): array { $max_per_page = $config['filters']['per_page']['max'] ?? 50; $query_args = array( 'post_type' => sanitize_text_field( $config['post_type'] ?? 'post' ), 'post_status' => 'publish', 'posts_per_page' => max( 1, min( absint( $arguments['per_page'] ?? 10 ), $max_per_page ) ), 'paged' => max( 1, absint( $arguments['page'] ?? 1 ) ), 'orderby' => sanitize_text_field( $arguments['orderby'] ?? 'date' ), 'order' => sanitize_text_field( $arguments['order'] ?? 'DESC' ), ); // Specific post by ID. if ( ! empty( $arguments['id'] ) ) { $query_args['p'] = absint( $arguments['id'] ); } // Search by title. if ( ! empty( $arguments['title'] ) ) { $query_args['title'] = sanitize_text_field( $arguments['title'] ); } // Filter by author. if ( ! empty( $arguments['author'] ) ) { $query_args['author'] = absint( $arguments['author'] ); } // Post status — only allow non-publish if user has edit_others_posts. if ( ! empty( $arguments['status'] ) && current_user_can( 'edit_others_posts' ) ) { $query_args['post_status'] = sanitize_text_field( $arguments['status'] ); } // Date filters. if ( ! empty( $arguments['date_after'] ) && $this->is_valid_date( $arguments['date_after'] ) ) { $query_args['date_query'][] = array( 'after' => sanitize_text_field( $arguments['date_after'] ), ); } if ( ! empty( $arguments['date_before'] ) && $this->is_valid_date( $arguments['date_before'] ) ) { $query_args['date_query'][] = array( 'before' => sanitize_text_field( $arguments['date_before'] ), ); } // Taxonomy filters. $query_args = $this->apply_taxonomy_filters( $query_args, $config, $arguments ); // Meta key filters. $query_args = $this->apply_meta_filters( $query_args, $config, $arguments ); return $query_args; } /** * Apply taxonomy filter arguments to the query. * * @since 1.0.0 * * @param array $query_args Current WP_Query arguments. * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array Modified WP_Query arguments. */ private function apply_taxonomy_filters( array $query_args, array $config, array $arguments ): array { foreach ( $config['taxonomies'] ?? array() as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_filter'] ) ) { continue; } $tax_slug_sanitized = sanitize_text_field( $tax_slug ); if ( empty( $arguments[ $tax_slug_sanitized ] ) ) { continue; } $requested_term = sanitize_text_field( $arguments[ $tax_slug_sanitized ] ); $allowed_terms = $tax_config['terms'] ?? array(); // Validate against allowed terms if configured. if ( ! empty( $allowed_terms ) && ! in_array( $requested_term, $allowed_terms, true ) ) { continue; } $query_args['tax_query'][] = array( 'taxonomy' => $tax_slug_sanitized, 'field' => 'slug', 'terms' => array( $requested_term ), ); } return $query_args; } /** * Apply meta key filter arguments to the query. * * @since 1.0.0 * * @param array $query_args Current WP_Query arguments. * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array Modified WP_Query arguments. */ private function apply_meta_filters( array $query_args, array $config, array $arguments ): array { $allowed_operators = array( '=', '!=', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); foreach ( $config['meta_keys'] ?? array() as $meta_key => $meta_config ) { if ( empty( $meta_config['as_filter'] ) || false !== strpos( $meta_key, '%' ) ) { continue; } $meta_key_sanitized = sanitize_text_field( $meta_key ); $meta_type = $meta_config['type'] ?? 'CHAR'; $enabled_operators = $meta_config['compare'] ?? array( '=' ); if ( ! isset( $arguments[ $meta_key_sanitized ] ) || ! is_array( $arguments[ $meta_key_sanitized ] ) ) { continue; } foreach ( $arguments[ $meta_key_sanitized ] as $condition ) { $compare = $condition['compare'] ?? ''; // Validate against admin-allowed and WordPress-supported operators. if ( ! in_array( $compare, $enabled_operators, true ) || ! in_array( $compare, $allowed_operators, true ) ) { continue; } $clause = array( 'key' => $meta_key_sanitized, 'compare' => $compare, 'type' => $meta_type, ); // EXISTS / NOT EXISTS don't use a value. if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { // No value needed. } elseif ( in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) { $raw = $condition['value'] ?? array(); if ( ! is_array( $raw ) ) { continue; } $clause['value'] = array_map( 'sanitize_text_field', $raw ); } else { $raw = $condition['value'] ?? ''; if ( is_array( $raw ) ) { continue; } $clause['value'] = sanitize_text_field( (string) $raw ); } $query_args['meta_query'][] = $clause; } } return $query_args; } /** * Extract selected native fields from a post. * * @since 1.0.0 * * @param WP_Post $post The post object. * @param array $selected_fields List of field keys to include. * @return array Associative array of field_key => value. */ private function meta_query_has_exists( array $query_args ): bool { foreach ( $query_args['meta_query'] ?? array() as $clause ) { if ( in_array( $clause['compare'] ?? '', array( 'EXISTS', 'NOT EXISTS' ), true ) ) { return true; } } return false; } /** * Filter callback to add DISTINCT to WP_Query SELECT. * * @since 1.0.0 * * @return string */ public function filter_posts_distinct(): string { return 'DISTINCT'; } private function extract_fields( WP_Post $post, array $selected_fields ): array { $item = array(); foreach ( $selected_fields as $field ) { switch ( $field ) { case 'id': $item['id'] = $post->ID; break; case 'title': $item['title'] = get_the_title( $post ); break; case 'content': $item['content'] = wp_strip_all_tags( $post->post_content ); break; case 'excerpt': $item['excerpt'] = get_the_excerpt( $post ); break; case 'date': $item['date'] = $post->post_date; break; case 'modified': $item['modified'] = $post->post_modified; break; case 'status': $item['status'] = $post->post_status; break; case 'author': $item['author'] = get_the_author_meta( 'display_name', $post->post_author ); break; case 'featured_image_url': $thumbnail_url = get_the_post_thumbnail_url( $post, 'full' ); $item['featured_image_url'] = $thumbnail_url ? $thumbnail_url : null; break; case 'permalink': $item['permalink'] = get_permalink( $post ); break; case 'slug': $item['slug'] = $post->post_name; break; } } return $item; } /** * Batch-fetch meta values for multiple posts in a single query. * * Handles both exact keys and pattern keys (containing %). * Pattern key results are grouped under the pattern name. * * @since 1.0.0 * * @param array $post_ids List of post IDs. * @param array $meta_keys The meta keys configuration. * @return array Associative array of post_id => [ key => value ]. */ private function batch_fetch_meta( array $post_ids, array $meta_keys ): array { global $wpdb; $exact_keys = array(); $pattern_keys = array(); foreach ( $meta_keys as $meta_key => $meta_config ) { if ( empty( $meta_config['as_field'] ) ) { continue; } if ( false !== strpos( $meta_key, '%' ) ) { $pattern_keys[] = $meta_key; } else { $exact_keys[] = $meta_key; } } if ( empty( $exact_keys ) && empty( $pattern_keys ) ) { return array(); } $prepare_args = array(); $conditions = array(); // Build ID placeholders for prepare(). $id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); $prepare_args = array_merge( $prepare_args, array_map( 'absint', $post_ids ) ); // Exact meta key matches. if ( ! empty( $exact_keys ) ) { $key_placeholders = implode( ',', array_fill( 0, count( $exact_keys ), '%s' ) ); $conditions[] = "meta_key IN ({$key_placeholders})"; $prepare_args = array_merge( $prepare_args, $exact_keys ); } // Pattern meta key matches — esc_like() treats _ as literal, preserve % wildcards. foreach ( $pattern_keys as $pattern ) { $parts = explode( '%', $pattern ); $parts = array_map( array( $wpdb, 'esc_like' ), $parts ); $like_pattern = implode( '%', $parts ); $conditions[] = 'meta_key LIKE %s'; $prepare_args[] = $like_pattern; } $where_keys = implode( ' OR ', $conditions ); // Prepend the table identifier so it pairs with the leading %i placeholder. array_unshift( $prepare_args, $wpdb->postmeta ); // $id_placeholders ("%d,%d,...") and $where_keys ("meta_key IN (%s,...) OR // meta_key LIKE %s") are placeholder fragments built deterministically in code // — no values are interpolated. All values flow through prepare() args. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $id_placeholders and $where_keys contain only %s/%d placeholder fragments and SQL keywords (IN, OR), assembled by code from array_fill/implode; all values pass through prepare() args. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_key, meta_value FROM %i WHERE post_id IN ({$id_placeholders}) AND ({$where_keys})", ...$prepare_args ) ); // Initialize empty values for all posts. $meta_map = array(); foreach ( $post_ids as $id ) { $meta_map[ $id ] = array(); foreach ( $exact_keys as $key ) { $meta_map[ $id ][ $key ] = ''; } foreach ( $pattern_keys as $pattern ) { $meta_map[ $id ][ $pattern ] = array(); } } // Map rows back by post ID. foreach ( $rows as $row ) { $pid = (int) $row->post_id; $key = $row->meta_key; $val = maybe_unserialize( $row->meta_value ); if ( in_array( $key, $exact_keys, true ) ) { $meta_map[ $pid ][ $key ] = $val; } else { // Belongs to a pattern — find which one. foreach ( $pattern_keys as $pattern ) { if ( fnmatch( str_replace( '%', '*', $pattern ), $key ) ) { $meta_map[ $pid ][ $pattern ][ $key ] = $val; break; } } } } return $meta_map; } /** * Attach taxonomy term data to a post item. * * @since 1.0.0 * * @param WP_Post $post The post object. * @param array $item The current post data array. * @param array $taxonomies The taxonomies configuration. * @return array Modified post data array with taxonomy terms attached. */ private function attach_taxonomies( WP_Post $post, array $item, array $taxonomies ): array { foreach ( $taxonomies as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_field'] ) ) { continue; } $terms = wp_get_post_terms( $post->ID, $tax_slug ); if ( is_wp_error( $terms ) ) { $item[ $tax_slug ] = array(); continue; } $item[ $tax_slug ] = wp_list_pluck( $terms, 'name' ); } return $item; } }