diff --git a/data-machine-events.php b/data-machine-events.php index 1fa889d..ddd523a 100644 --- a/data-machine-events.php +++ b/data-machine-events.php @@ -362,6 +362,11 @@ private function load_data_machine_components() { new \DataMachineEvents\Abilities\UpcomingCountAbilities(); } + if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/EventDateQueryAbilities.php' ) ) { + require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/EventDateQueryAbilities.php'; + new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + } + $this->registerSystemHealthChecks(); } diff --git a/inc/Abilities/CalendarAbilities.php b/inc/Abilities/CalendarAbilities.php index 83da596..2979edc 100644 --- a/inc/Abilities/CalendarAbilities.php +++ b/inc/Abilities/CalendarAbilities.php @@ -10,15 +10,16 @@ namespace DataMachineEvents\Abilities; -use WP_Query; -use DataMachineEvents\Blocks\Calendar\Query\EventQueryBuilder; +use DateTime; use DataMachineEvents\Blocks\Calendar\Query\ScopeResolver; use DataMachineEvents\Blocks\Calendar\Data\EventHydrator; use DataMachineEvents\Blocks\Calendar\Grouping\DateGrouper; use DataMachineEvents\Blocks\Calendar\Display\EventRenderer; use DataMachineEvents\Blocks\Calendar\Pagination; use DataMachineEvents\Blocks\Calendar\Pagination\PageBoundary; +use DataMachineEvents\Blocks\Calendar\Cache\CalendarCache; use DataMachineEvents\Blocks\Calendar\Template_Loader; +use DataMachineEvents\Core\EventDatesTable; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -207,7 +208,7 @@ public function executeGetCalendarPage( array $input ): array { 'geo_radius_unit' => $input['geo_radius_unit'] ?? 'mi', ); - $date_data = PageBoundary::get_unique_event_dates( $base_params ); + $date_data = self::get_unique_event_dates( $base_params ); $unique_dates = $date_data['dates']; $total_event_count = $date_data['total_events']; $events_per_date = $date_data['events_per_date']; @@ -268,13 +269,67 @@ function ( $d ) use ( $range_start, $range_end ) { } } - $built = EventQueryBuilder::build_query_args( $query_params ); - $events_query = new WP_Query( $built['args'] ); - $built['cleanup'](); + // Build ability input from query_params. + $ability_input = array( + 'scope' => $query_params['show_past'] ? 'past' : 'upcoming', + 'tax_filters' => $query_params['tax_filters'], + 'search' => $query_params['search_query'], + 'order' => $query_params['show_past'] ? 'DESC' : 'ASC', + ); + + // Date range overrides scope. + if ( ! empty( $query_params['date_start'] ) || ! empty( $query_params['date_end'] ) ) { + $ability_input['date_start'] = $query_params['date_start']; + $ability_input['date_end'] = $query_params['date_end']; + $ability_input['time_start'] = $query_params['time_start'] ?? ''; + $ability_input['time_end'] = $query_params['time_end'] ?? ''; + // When user provides explicit dates, don't add scope filter. + if ( $query_params['user_date_range'] ) { + $ability_input['scope'] = 'all'; + } + } + + // Taxonomy archive constraint. + if ( ! empty( $query_params['archive_taxonomy'] ) && ! empty( $query_params['archive_term_id'] ) ) { + $ability_input['tax_filters'][ $query_params['archive_taxonomy'] ] = array( (int) $query_params['archive_term_id'] ); + } + + // Apply calendar_base_query filter. + $tax_query_override = apply_filters( + 'data_machine_events_calendar_base_query', + null, + array( + 'archive_taxonomy' => $query_params['archive_taxonomy'], + 'archive_term_id' => $query_params['archive_term_id'], + 'source' => 'ability', + ) + ); + if ( $tax_query_override ) { + foreach ( $tax_query_override as $clause ) { + if ( isset( $clause['taxonomy'] ) && isset( $clause['terms'] ) ) { + $ability_input['tax_filters'][ $clause['taxonomy'] ] = (array) $clause['terms']; + } + } + } + + // Geo. + if ( ! empty( $query_params['geo_lat'] ) && ! empty( $query_params['geo_lng'] ) ) { + $ability_input['geo'] = array( + 'lat' => (float) $query_params['geo_lat'], + 'lng' => (float) $query_params['geo_lng'], + 'radius' => (float) ( $query_params['geo_radius'] ?? 25 ), + 'unit' => $query_params['geo_radius_unit'] ?? 'mi', + ); + } + + $event_date_query = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $query_result = $event_date_query->executeQueryEvents( $ability_input ); - $event_counts = EventQueryBuilder::get_event_counts(); + $event_counts = self::compute_event_counts_via_ability(); - $paged_events = DateGrouper::build_paged_events( $events_query ); + // Build paged_events from ability result posts (replaces DateGrouper::build_paged_events + // which requires a WP_Query object — we have raw WP_Post objects from the ability). + $paged_events = self::build_paged_events_from_posts( $query_result['posts'] ); $paged_date_groups = DateGrouper::group_events_by_date( $paged_events, $show_past, @@ -293,7 +348,7 @@ function ( $d ) use ( $range_start, $range_end ) { 'current_page' => $current_page, 'max_pages' => $max_pages, 'total_event_count' => $total_event_count, - 'event_count' => $events_query->post_count, + 'event_count' => $query_result['post_count'], 'date_boundaries' => array( 'start_date' => $date_boundaries['start_date'], 'end_date' => $date_boundaries['end_date'], @@ -315,7 +370,7 @@ function ( $d ) use ( $range_start, $range_end ) { $max_pages, $show_past, $date_boundaries, - $events_query->post_count, + $query_result['post_count'], $total_event_count, $event_counts, $deferred_dates, @@ -328,6 +383,66 @@ function ( $d ) use ( $range_start, $range_end ) { return $result; } + /** + * Build paged events array from raw WP_Post objects. + * + * Mirrors DateGrouper::build_paged_events() but operates on a plain + * array of WP_Post objects instead of requiring a WP_Query instance. + * + * @param array $posts Array of WP_Post objects. + * @return array Array of event items with post, datetime, and event_data. + */ + private static function build_paged_events_from_posts( array $posts ): array { + $paged_events = array(); + + foreach ( $posts as $event_post ) { + $event_data = EventHydrator::parse_event_data( $event_post ); + + if ( $event_data ) { + $start_time = $event_data['startTime'] ?? '00:00:00'; + $event_tz = DateGrouper::get_event_timezone( $event_data ); + $event_datetime = new DateTime( + $event_data['startDate'] . ' ' . $start_time, + $event_tz + ); + + $paged_events[] = array( + 'post' => $event_post, + 'datetime' => $event_datetime, + 'event_data' => $event_data, + ); + } + } + + return $paged_events; + } + + /** + * Compute past/future event counts via the query-events ability (cached). + * + * @return array ['past' => int, 'future' => int] + */ + private static function compute_event_counts_via_ability(): array { + $cache_key = 'data-machine_cal_counts'; + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + + $future = $ability->executeQueryEvents( array( 'scope' => 'upcoming', 'fields' => 'count' ) ); + $past = $ability->executeQueryEvents( array( 'scope' => 'past', 'fields' => 'count' ) ); + + $result = array( + 'past' => $past['total'], + 'future' => $future['total'], + ); + + set_transient( $cache_key, $result, 10 * MINUTE_IN_SECONDS ); + return $result; + } + /** * Serialize date groups for JSON output * @@ -422,4 +537,162 @@ private function renderHtml( 'navigation' => $navigation_html, ); } + + /** + * Get unique event dates for pagination calculations (cached). + * + * Multi-day events are expanded to count on each spanned date. + * + * @param array $params Query parameters. + * @return array { + * @type array $dates Ordered array of unique date strings (Y-m-d). + * @type int $total_events Total number of matching events. + * @type array $events_per_date Event counts keyed by date. + * } + */ + private static function get_unique_event_dates( array $params ): array { + $cache_key = CalendarCache::generate_key( $params, 'dates' ); + $cached = CalendarCache::get( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $result = self::compute_unique_event_dates( $params ); + + CalendarCache::set( $cache_key, $result, CalendarCache::TTL_DATES ); + + return $result; + } + + /** + * Compute unique event dates (uncached). + * + * Fetches start/end dates (without post IDs) and uses DATE() in SQL + * to minimize data transfer. Multi-day events are properly expanded + * to count on each spanned date. + * + * @param array $params Query parameters. + * @return array Event dates data. + */ + private static function compute_unique_event_dates( array $params ): array { + global $wpdb; + + $show_past_param = $params['show_past'] ?? false; + $current_date = current_time( 'Y-m-d' ); + $ed_table = EventDatesTable::table_name(); + + // Build WHERE clauses from params for taxonomy/location filtering. + $where_clauses = array( + "p.post_type = 'data_machine_events'", + "p.post_status = 'publish'", + ); + $join_clauses = array(); + $query_values = array(); + + if ( ! $show_past_param ) { + $where_clauses[] = 'ed.start_datetime >= %s'; + $query_values[] = $current_date . ' 00:00:00'; + } + + // Handle taxonomy archive filter (any taxonomy: artist, venue, location, etc.). + $archive_taxonomy = $params['archive_taxonomy'] ?? ''; + $archive_term_id = $params['archive_term_id'] ?? 0; + + if ( $archive_taxonomy && $archive_term_id ) { + $join_clauses[] = "INNER JOIN {$wpdb->term_relationships} tr_archive ON p.ID = tr_archive.object_id"; + $join_clauses[] = "INNER JOIN {$wpdb->term_taxonomy} tt_archive ON tr_archive.term_taxonomy_id = tt_archive.term_taxonomy_id"; + $where_clauses[] = 'tt_archive.taxonomy = %s'; + $query_values[] = $archive_taxonomy; + $where_clauses[] = 'tt_archive.term_id = %d'; + $query_values[] = (int) $archive_term_id; + } + + // Handle additional taxonomy filters from the filter bar. + $tax_filters = $params['tax_filters'] ?? array(); + $filter_index = 0; + foreach ( $tax_filters as $taxonomy_slug => $term_ids ) { + if ( empty( $term_ids ) || ! is_array( $term_ids ) ) { + continue; + } + + $alias_tr = 'tr_filter_' . $filter_index; + $alias_tt = 'tt_filter_' . $filter_index; + + $join_clauses[] = "INNER JOIN {$wpdb->term_relationships} {$alias_tr} ON p.ID = {$alias_tr}.object_id"; + $join_clauses[] = "INNER JOIN {$wpdb->term_taxonomy} {$alias_tt} ON {$alias_tr}.term_taxonomy_id = {$alias_tt}.term_taxonomy_id"; + $where_clauses[] = "{$alias_tt}.taxonomy = %s"; + $query_values[] = sanitize_key( $taxonomy_slug ); + + $placeholders = implode( ', ', array_fill( 0, count( $term_ids ), '%d' ) ); + $where_clauses[] = "{$alias_tt}.term_id IN ({$placeholders})"; + foreach ( $term_ids as $term_id ) { + $query_values[] = (int) $term_id; + } + + ++$filter_index; + } + + $joins = implode( ' ', $join_clauses ); + $where = implode( ' AND ', $where_clauses ); + + // Fetch start/end dates without IDs — DATE() in SQL avoids gmdate() in PHP. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + empty( $query_values ) + ? "SELECT DATE(ed.start_datetime) AS start_date, DATE(ed.end_datetime) AS end_date + FROM {$wpdb->posts} p + INNER JOIN {$ed_table} ed ON p.ID = ed.post_id + {$joins} + WHERE {$where} + ORDER BY ed.start_datetime ASC" + : $wpdb->prepare( + "SELECT DATE(ed.start_datetime) AS start_date, DATE(ed.end_datetime) AS end_date + FROM {$wpdb->posts} p + INNER JOIN {$ed_table} ed ON p.ID = ed.post_id + {$joins} + WHERE {$where} + ORDER BY ed.start_datetime ASC", + ...$query_values + ) + ); + + $total_events = count( $rows ); + $events_per_date = array(); + + foreach ( $rows as $row ) { + $events_per_date[ $row->start_date ] = ( $events_per_date[ $row->start_date ] ?? 0 ) + 1; + + // Multi-day: expand to each spanned date after the start. + if ( $row->end_date && $row->end_date > $row->start_date ) { + $current = new \DateTime( $row->start_date ); + $current->modify( '+1 day' ); + $end_dt = new \DateTime( $row->end_date ); + + while ( $current <= $end_dt ) { + $date = $current->format( 'Y-m-d' ); + + if ( ! $show_past_param && $date < $current_date ) { + $current->modify( '+1 day' ); + continue; + } + + $events_per_date[ $date ] = ( $events_per_date[ $date ] ?? 0 ) + 1; + $current->modify( '+1 day' ); + } + } + } + + if ( $show_past_param ) { + krsort( $events_per_date ); + } else { + ksort( $events_per_date ); + } + + return array( + 'dates' => array_keys( $events_per_date ), + 'total_events' => $total_events, + 'events_per_date' => $events_per_date, + ); + } } diff --git a/inc/Abilities/DuplicateDetectionAbilities.php b/inc/Abilities/DuplicateDetectionAbilities.php index 160b830..f30c1ef 100644 --- a/inc/Abilities/DuplicateDetectionAbilities.php +++ b/inc/Abilities/DuplicateDetectionAbilities.php @@ -17,6 +17,7 @@ namespace DataMachineEvents\Abilities; use DataMachine\Core\Similarity\SimilarityEngine; +use DataMachineEvents\Abilities\EventDateQueryAbilities; use DataMachineEvents\Utilities\EventIdentifierGenerator; use DataMachineEvents\Core\Event_Post_Type; use const DataMachineEvents\Core\EVENT_DATETIME_META_KEY; @@ -304,33 +305,14 @@ private function executeFindDuplicateEventLegacy( string $title, string $venue, } if ( $venue_term ) { - $dedup_date_filter_1 = function ( $clauses ) use ( $startDate ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $startDate ); - return $clauses; - }; - add_filter( 'posts_clauses', $dedup_date_filter_1 ); - - $candidates = get_posts( - array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 10, - 'post_status' => array( 'publish', 'draft', 'pending' ), - 'tax_query' => array( - array( - 'taxonomy' => 'venue', - 'field' => 'term_id', - 'terms' => $venue_term->term_id, - ), - ), - ) - ); - - remove_filter( 'posts_clauses', $dedup_date_filter_1 ); + $event_query = new EventDateQueryAbilities(); + $result = $event_query->executeQueryEvents( array( + 'date_match' => $startDate, + 'tax_filters' => array( 'venue' => array( $venue_term->term_id ) ), + 'per_page' => 10, + 'status' => 'any', + ) ); + $candidates = $result['posts']; foreach ( $candidates as $candidate ) { if ( EventIdentifierGenerator::titlesMatch( $title, $candidate->post_title ) ) { @@ -347,26 +329,13 @@ private function executeFindDuplicateEventLegacy( string $title, string $venue, } // Strategy 2: date-scoped fuzzy title + venue confirmation. - $dedup_date_filter_2 = function ( $clauses ) use ( $startDate ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $startDate ); - return $clauses; - }; - add_filter( 'posts_clauses', $dedup_date_filter_2 ); - - $candidates = get_posts( - array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 20, - 'post_status' => array( 'publish', 'draft', 'pending' ), - ) - ); - - remove_filter( 'posts_clauses', $dedup_date_filter_2 ); + $event_query = new EventDateQueryAbilities(); + $result = $event_query->executeQueryEvents( array( + 'date_match' => $startDate, + 'per_page' => 20, + 'status' => 'any', + ) ); + $candidates = $result['posts']; foreach ( $candidates as $candidate ) { if ( ! EventIdentifierGenerator::titlesMatch( $title, $candidate->post_title ) ) { diff --git a/inc/Abilities/EncodingFixAbilities.php b/inc/Abilities/EncodingFixAbilities.php index a2d2176..d8469b7 100644 --- a/inc/Abilities/EncodingFixAbilities.php +++ b/inc/Abilities/EncodingFixAbilities.php @@ -17,7 +17,7 @@ namespace DataMachineEvents\Abilities; -use DataMachineEvents\Core\Event_Post_Type; +use DataMachineEvents\Abilities\EventDateQueryAbilities; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -219,42 +219,13 @@ public function executeEncodingFix( array $input ): array { * @return array|\WP_Error Array of WP_Post objects or WP_Error */ private function queryEvents( string $scope ): array|\WP_Error { - $order = 'ASC'; - if ( 'past' === $scope ) { - $order = 'DESC'; - } - - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'none', - 'order' => $order, - ); - - $now = current_time( 'Y-m-d H:i:s' ); - - $event_date_filter = function ( $clauses ) use ( $scope, $now, $order ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - if ( 'upcoming' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime >= %s', $now ); - } elseif ( 'past' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime < %s', $now ); - } - $clauses['orderby'] = 'ed.start_datetime ' . $order; - return $clauses; - }; - add_filter( 'posts_clauses', $event_date_filter ); - - $query = new \WP_Query( $args ); - - remove_filter( 'posts_clauses', $event_date_filter ); + $ability = new EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( array( + 'scope' => $scope, + 'order' => 'past' === $scope ? 'DESC' : 'ASC', + ) ); - return $query->posts; + return $result['posts']; } /** diff --git a/inc/Abilities/EventDateQueryAbilities.php b/inc/Abilities/EventDateQueryAbilities.php new file mode 100644 index 0000000..89e4146 --- /dev/null +++ b/inc/Abilities/EventDateQueryAbilities.php @@ -0,0 +1,379 @@ +registerAbilities(); + self::$registered = true; + } + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'data-machine-events/query-events', + array( + 'label' => __( 'Query Events', 'data-machine-events' ), + 'description' => __( 'Query events filtered by date scope, taxonomy, geo, and search. The single primitive for all event date queries.', 'data-machine-events' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'scope' => array( + 'type' => 'string', + 'enum' => array( 'upcoming', 'past', 'all' ), + 'description' => 'Date scope filter. Default: upcoming.', + ), + 'date_start' => array( + 'type' => 'string', + 'description' => 'Range start date (YYYY-MM-DD). Overrides scope when set.', + ), + 'date_end' => array( + 'type' => 'string', + 'description' => 'Range end date (YYYY-MM-DD). Overrides scope when set.', + ), + 'date_match' => array( + 'type' => 'string', + 'description' => 'Exact date match (YYYY-MM-DD). For duplicate detection queries.', + ), + 'days_ahead' => array( + 'type' => 'integer', + 'description' => 'Bounded lookahead in days for upcoming scope. 0 = unlimited.', + ), + 'time_start' => array( + 'type' => 'string', + 'description' => 'Time bound start (HH:MM:SS). Used with date_start.', + ), + 'time_end' => array( + 'type' => 'string', + 'description' => 'Time bound end (HH:MM:SS). Used with date_end.', + ), + 'tax_filters' => array( + 'type' => 'object', + 'description' => 'Taxonomy filters as { taxonomy_slug: [term_ids] }.', + ), + 'search' => array( + 'type' => 'string', + 'description' => 'Search query string.', + ), + 'geo' => array( + 'type' => 'object', + 'properties' => array( + 'lat' => array( 'type' => 'number' ), + 'lng' => array( 'type' => 'number' ), + 'radius' => array( 'type' => 'number' ), + 'unit' => array( 'type' => 'string', 'enum' => array( 'mi', 'km' ) ), + ), + ), + 'exclude' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => 'Post IDs to exclude.', + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => 'Posts per page. -1 for all. Default: -1.', + ), + 'fields' => array( + 'type' => 'string', + 'enum' => array( 'all', 'ids', 'count' ), + 'description' => 'Return format: all (WP_Post objects), ids (post IDs), count (just total). Default: all.', + ), + 'order' => array( + 'type' => 'string', + 'enum' => array( 'ASC', 'DESC' ), + 'description' => 'Sort direction for event start_datetime. Default: ASC.', + ), + 'status' => array( + 'type' => 'string', + 'description' => 'Post status. Default: publish.', + ), + 'meta_query' => array( + 'type' => 'array', + 'description' => 'Additional meta_query clauses (for non-date meta like ticket_url, flow_id).', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => 'WP_Post objects, post IDs, or empty (for count mode).', + ), + 'total' => array( + 'type' => 'integer', + 'description' => 'Total matching events (found_posts).', + ), + 'post_count' => array( + 'type' => 'integer', + 'description' => 'Number of posts returned on this page.', + ), + ), + ), + 'execute_callback' => array( $this, 'executeQueryEvents' ), + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + }; + + if ( did_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } else { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Execute the query-events ability. + * + * @param array $input Input parameters. + * @return array { posts: array, total: int, post_count: int } + */ + public function executeQueryEvents( array $input ): array { + $scope = $input['scope'] ?? 'upcoming'; + $date_start = $input['date_start'] ?? ''; + $date_end = $input['date_end'] ?? ''; + $date_match = $input['date_match'] ?? ''; + $days_ahead = (int) ( $input['days_ahead'] ?? 0 ); + $time_start = $input['time_start'] ?? ''; + $time_end = $input['time_end'] ?? ''; + $tax_filters = is_array( $input['tax_filters'] ?? null ) ? $input['tax_filters'] : array(); + $search = $input['search'] ?? ''; + $geo = is_array( $input['geo'] ?? null ) ? $input['geo'] : array(); + $exclude = is_array( $input['exclude'] ?? null ) ? array_map( 'absint', $input['exclude'] ) : array(); + $per_page = (int) ( $input['per_page'] ?? -1 ); + $fields = $input['fields'] ?? 'all'; + $order = strtoupper( $input['order'] ?? 'ASC' ) === 'DESC' ? 'DESC' : 'ASC'; + $status = $input['status'] ?? 'publish'; + $meta_query = is_array( $input['meta_query'] ?? null ) ? $input['meta_query'] : array(); + + // Build WP_Query args. + $query_args = array( + 'post_type' => Event_Post_Type::POST_TYPE, + 'post_status' => $status, + 'posts_per_page' => $per_page, + 'orderby' => 'none', // Ordering via posts_clauses. + ); + + if ( 'ids' === $fields ) { + $query_args['fields'] = 'ids'; + } + + if ( 'count' === $fields ) { + $query_args['fields'] = 'ids'; + $query_args['posts_per_page'] = 1; + } + + if ( ! empty( $exclude ) ) { + $query_args['post__not_in'] = $exclude; + } + + if ( ! empty( $search ) ) { + $query_args['s'] = $search; + } + + if ( ! empty( $meta_query ) ) { + $query_args['meta_query'] = $meta_query; + } + + // Taxonomy filters. + if ( ! empty( $tax_filters ) ) { + $tax_query = array( 'relation' => 'AND' ); + + foreach ( $tax_filters as $taxonomy => $term_ids ) { + $term_ids = is_array( $term_ids ) ? $term_ids : array( $term_ids ); + $tax_query[] = array( + 'taxonomy' => sanitize_key( $taxonomy ), + 'field' => 'term_id', + 'terms' => array_map( 'absint', $term_ids ), + 'operator' => 'IN', + ); + } + + $query_args['tax_query'] = $tax_query; + } + + // Geo filter (venue proximity). + if ( ! empty( $geo['lat'] ) && ! empty( $geo['lng'] ) ) { + $geo_lat = (float) $geo['lat']; + $geo_lng = (float) $geo['lng']; + $geo_radius = (float) ( $geo['radius'] ?? 25 ); + $geo_unit = $geo['unit'] ?? 'mi'; + + if ( class_exists( 'DataMachineEvents\\Blocks\\Calendar\\Geo_Query' ) + && \DataMachineEvents\Blocks\Calendar\Geo_Query::validate_params( $geo_lat, $geo_lng, $geo_radius ) ) { + + $nearby_venue_ids = \DataMachineEvents\Blocks\Calendar\Geo_Query::get_venue_ids_within_radius( + $geo_lat, $geo_lng, $geo_radius, $geo_unit + ); + + $tax_query = isset( $query_args['tax_query'] ) ? $query_args['tax_query'] : array(); + $tax_query['relation'] = 'AND'; + $tax_query[] = array( + 'taxonomy' => 'venue', + 'field' => 'term_id', + 'terms' => ! empty( $nearby_venue_ids ) ? $nearby_venue_ids : array( 0 ), + 'operator' => 'IN', + ); + + $query_args['tax_query'] = $tax_query; + } + } + + // Build the posts_clauses filter for date filtering + ordering. + $filters = array(); + + $clauses_filter = $this->buildDateClauses( $scope, $date_start, $date_end, $date_match, $days_ahead, $time_start, $time_end, $order ); + add_filter( 'posts_clauses', $clauses_filter ); + $filters[] = $clauses_filter; + + // Execute query. + $query = new WP_Query( $query_args ); + + // Cleanup filters immediately. + foreach ( $filters as $f ) { + remove_filter( 'posts_clauses', $f ); + } + + $posts = $query->posts; + if ( 'count' === $fields ) { + $posts = array(); + } + + return array( + 'posts' => $posts, + 'total' => $query->found_posts, + 'post_count' => $query->post_count, + ); + } + + /** + * Build a single posts_clauses callback that handles JOIN, WHERE, and ORDER BY. + * + * This consolidates all date logic into one filter — no stacking, no leaks. + * + * @param string $scope upcoming|past|all + * @param string $date_start Range start (YYYY-MM-DD). + * @param string $date_end Range end (YYYY-MM-DD). + * @param string $date_match Exact date match (YYYY-MM-DD). + * @param int $days_ahead Bounded lookahead days. + * @param string $time_start Time start (HH:MM:SS). + * @param string $time_end Time end (HH:MM:SS). + * @param string $order ASC or DESC. + * @return callable The posts_clauses filter callback. + */ + private function buildDateClauses( + string $scope, + string $date_start, + string $date_end, + string $date_match, + int $days_ahead, + string $time_start, + string $time_end, + string $order + ): callable { + return function ( $clauses ) use ( $scope, $date_start, $date_end, $date_match, $days_ahead, $time_start, $time_end, $order ) { + global $wpdb; + $table = EventDatesTable::table_name(); + + // JOIN — only add once. + if ( strpos( $clauses['join'], $table ) === false ) { + $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; + } + + $now = current_time( 'mysql' ); + + // Exact date match takes priority (dedup queries). + if ( ! empty( $date_match ) ) { + $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $date_match ); + } elseif ( ! empty( $date_start ) || ! empty( $date_end ) ) { + // Explicit date range. + if ( ! empty( $date_start ) ) { + $start_dt = ! empty( $time_start ) + ? $date_start . ' ' . $time_start + : $date_start . ' 00:00:00'; + + $clauses['where'] .= $wpdb->prepare( + ' AND (ed.start_datetime >= %s OR ed.end_datetime >= %s)', + $start_dt, + $start_dt + ); + } + + if ( ! empty( $date_end ) ) { + $end_dt = ! empty( $time_end ) + ? $date_end . ' ' . $time_end + : $date_end . ' 23:59:59'; + + $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime <= %s', $end_dt ); + } + } elseif ( 'upcoming' === $scope ) { + // Canonical upcoming: start_datetime >= now OR end_datetime >= now. + if ( $days_ahead > 0 ) { + $end_date = gmdate( 'Y-m-d 23:59:59', strtotime( "+{$days_ahead} days" ) ); + $clauses['where'] .= $wpdb->prepare( + ' AND (ed.start_datetime >= %s OR ed.end_datetime >= %s) AND ed.start_datetime <= %s', + $now, + $now, + $end_date + ); + } else { + $clauses['where'] .= $wpdb->prepare( + ' AND (ed.start_datetime >= %s OR ed.end_datetime >= %s)', + $now, + $now + ); + } + } elseif ( 'past' === $scope ) { + // Canonical past: start < now AND (end < now OR end IS NULL). + $clauses['where'] .= $wpdb->prepare( + ' AND (ed.start_datetime < %s AND (ed.end_datetime < %s OR ed.end_datetime IS NULL))', + $now, + $now + ); + } + // 'all' scope — no date WHERE clause. + + // ORDER BY — always by start_datetime unless date_match (dedup doesn't need ordering). + if ( empty( $date_match ) ) { + $clauses['orderby'] = "ed.start_datetime {$order}"; + } + + return $clauses; + }; + } +} diff --git a/inc/Abilities/EventHealthAbilities.php b/inc/Abilities/EventHealthAbilities.php index 0337433..85ede3a 100644 --- a/inc/Abilities/EventHealthAbilities.php +++ b/inc/Abilities/EventHealthAbilities.php @@ -14,7 +14,7 @@ namespace DataMachineEvents\Abilities; -use DataMachineEvents\Core\Event_Post_Type; +use DataMachineEvents\Abilities\EventDateQueryAbilities; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -358,43 +358,19 @@ public function executeHealthCheck( array $input ): array { * @return array|\WP_Error Array of WP_Post objects or WP_Error */ private function queryEvents( string $scope, int $days_ahead ): array|\WP_Error { - $order = 'ASC'; - if ( 'past' === $scope ) { - $order = 'DESC'; - } - - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'none', - 'order' => $order, + $input = array( + 'scope' => $scope, + 'order' => 'past' === $scope ? 'DESC' : 'ASC', ); - $now = current_time( 'Y-m-d H:i:s' ); - - $event_date_filter = function ( $clauses ) use ( $scope, $now, $days_ahead, $order ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - if ( 'upcoming' === $scope ) { - $end_date = gmdate( 'Y-m-d H:i:s', strtotime( "+{$days_ahead} days" ) ); - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime BETWEEN %s AND %s', $now, $end_date ); - } elseif ( 'past' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime < %s', $now ); - } - $clauses['orderby'] = 'ed.start_datetime ' . $order; - return $clauses; - }; - add_filter( 'posts_clauses', $event_date_filter ); - - $query = new \WP_Query( $args ); + if ( 'upcoming' === $scope && $days_ahead > 0 ) { + $input['days_ahead'] = $days_ahead; + } - remove_filter( 'posts_clauses', $event_date_filter ); + $ability = new EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( $input ); - return $query->posts; + return $result['posts']; } /** diff --git a/inc/Abilities/EventQualityAuditAbilities.php b/inc/Abilities/EventQualityAuditAbilities.php index a897216..b710ef4 100644 --- a/inc/Abilities/EventQualityAuditAbilities.php +++ b/inc/Abilities/EventQualityAuditAbilities.php @@ -11,7 +11,7 @@ namespace DataMachineEvents\Abilities; -use DataMachineEvents\Core\Event_Post_Type; +use DataMachineEvents\Abilities\EventDateQueryAbilities; use DataMachineEvents\Utilities\EventIdentifierGenerator; if ( ! defined( 'ABSPATH' ) ) { @@ -249,35 +249,17 @@ public function executeAudit( array $input ): array { } private function queryEvents( string $scope, int $days_ahead, int $flow_id = 0, int $location_term_id = 0 ): array|\WP_Error { - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'none', - 'order' => 'ASC', + $input = array( + 'scope' => $scope, + 'order' => 'ASC', ); - $today = current_time( 'Y-m-d' ); - - $date_filter = function ( $clauses ) use ( $scope, $today, $days_ahead ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - if ( 'upcoming' === $scope ) { - $end_date = gmdate( 'Y-m-d 23:59:59', strtotime( '+' . $days_ahead . ' days', strtotime( $today ) ) ); - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime BETWEEN %s AND %s', $today . ' 00:00:00', $end_date ); - } elseif ( 'past' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime < %s', $today . ' 00:00:00' ); - } - $clauses['orderby'] = 'ed.start_datetime ASC'; - return $clauses; - }; - add_filter( 'posts_clauses', $date_filter ); + if ( 'upcoming' === $scope && $days_ahead > 0 ) { + $input['days_ahead'] = $days_ahead; + } if ( $flow_id > 0 ) { - $args['meta_query'] = array( + $input['meta_query'] = array( array( 'key' => '_datamachine_post_flow_id', 'value' => (string) $flow_id, @@ -287,20 +269,13 @@ private function queryEvents( string $scope, int $days_ahead, int $flow_id = 0, } if ( $location_term_id > 0 ) { - $args['tax_query'] = array( - array( - 'taxonomy' => 'location', - 'field' => 'term_id', - 'terms' => array( $location_term_id ), - ), - ); + $input['tax_filters'] = array( 'location' => array( $location_term_id ) ); } - $posts = get_posts( $args ); - - remove_filter( 'posts_clauses', $date_filter ); + $ability = new EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( $input ); - return is_array( $posts ) ? $posts : array(); + return is_array( $result['posts'] ) ? $result['posts'] : array(); } private function extractBlockAttributes( int $post_id ): array { diff --git a/inc/Abilities/EventQueryAbilities.php b/inc/Abilities/EventQueryAbilities.php index c7e54d0..3757994 100644 --- a/inc/Abilities/EventQueryAbilities.php +++ b/inc/Abilities/EventQueryAbilities.php @@ -177,6 +177,9 @@ public function executeGetVenueEvents( array $input ): array { $query_args['date_query'] = $date_query; } + // @todo Migrate to EventDateQueryAbilities when it supports WordPress date_query + // (published_before/published_after). The ability only handles event date filtering, + // not WP publish date filtering, so we keep the inline posts_clauses for now. $venue_event_filter = function ( $clauses ) { global $wpdb; $table = \DataMachineEvents\Core\EventDatesTable::table_name(); diff --git a/inc/Abilities/FilterAbilities.php b/inc/Abilities/FilterAbilities.php index 1cc192d..9c9ed92 100644 --- a/inc/Abilities/FilterAbilities.php +++ b/inc/Abilities/FilterAbilities.php @@ -14,8 +14,9 @@ namespace DataMachineEvents\Abilities; -use DataMachineEvents\Blocks\Calendar\Taxonomy_Helper; use DataMachineEvents\Blocks\Calendar\Geo_Query; +use DataMachineEvents\Core\Event_Post_Type; +use DataMachineEvents\Core\EventDatesTable; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -228,7 +229,7 @@ public function executeGetFilterOptions( array $input ): array { } } - $taxonomies_data = Taxonomy_Helper::get_all_taxonomies_with_counts( $active_filters, $date_context, $tax_query_override ); + $taxonomies_data = $this->get_all_taxonomies_with_counts( $active_filters, $date_context, $tax_query_override ); return array( 'success' => true, @@ -242,4 +243,301 @@ public function executeGetFilterOptions( array $input ): array { ), ); } + + /** + * Get all taxonomies with event counts using real-time cross-filtering. + * + * @param array $active_filters Active filter selections keyed by taxonomy slug. + * @param array $date_context Optional date filtering context (date_start, date_end, past). + * @param array|null $tax_query_override Optional taxonomy query override. + * @return array Structured taxonomy data with hierarchy and event counts. + */ + private function get_all_taxonomies_with_counts( $active_filters = array(), $date_context = array(), $tax_query_override = null ) { + $taxonomies_data = array(); + + $taxonomies = get_object_taxonomies( Event_Post_Type::POST_TYPE, 'objects' ); + + if ( ! $taxonomies ) { + return $taxonomies_data; + } + + $excluded_taxonomies = apply_filters( 'data_machine_events_excluded_taxonomies', array(), 'modal' ); + + foreach ( $taxonomies as $taxonomy ) { + if ( in_array( $taxonomy->name, $excluded_taxonomies, true ) || ! $taxonomy->public ) { + continue; + } + + $terms_hierarchy = $this->get_taxonomy_hierarchy( $taxonomy->name, null, $date_context, $active_filters, $tax_query_override ); + + if ( ! empty( $terms_hierarchy ) ) { + $taxonomies_data[ $taxonomy->name ] = array( + 'label' => $taxonomy->label, + 'name' => $taxonomy->name, + 'hierarchical' => $taxonomy->hierarchical, + 'terms' => $terms_hierarchy, + ); + } + } + + return $taxonomies_data; + } + + /** + * Get terms in a taxonomy filtered by allowed term IDs. + * + * @param string $taxonomy_slug Taxonomy to get terms for. + * @param array|null $allowed_term_ids Limit to these term IDs, or null for all. + * @param array $date_context Optional date filtering context. + * @param array $active_filters Optional active taxonomy filters for cross-filtering. + * @param array|null $tax_query_override Optional taxonomy query override. + * @return array Hierarchical term structure with event counts. + */ + private function get_taxonomy_hierarchy( $taxonomy_slug, $allowed_term_ids = null, $date_context = array(), $active_filters = array(), $tax_query_override = null ) { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy_slug, + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return array(); + } + + if ( null !== $allowed_term_ids && empty( $allowed_term_ids ) ) { + return array(); + } + + $term_counts = $this->get_batch_term_counts( $taxonomy_slug, $date_context, $active_filters, $tax_query_override ); + + $terms_with_events = array(); + foreach ( $terms as $term ) { + if ( null !== $allowed_term_ids && ! in_array( $term->term_id, $allowed_term_ids, true ) ) { + continue; + } + + $event_count = $term_counts[ $term->term_id ] ?? 0; + if ( $event_count > 0 ) { + $term->event_count = $event_count; + $terms_with_events[] = $term; + } + } + + if ( empty( $terms_with_events ) ) { + return array(); + } + + $taxonomy_obj = get_taxonomy( $taxonomy_slug ); + if ( $taxonomy_obj && $taxonomy_obj->hierarchical ) { + return self::build_hierarchy_tree( $terms_with_events ); + } + + return array_map( + function ( $term ) { + return array( + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'event_count' => $term->event_count, + 'level' => 0, + 'children' => array(), + ); + }, + $terms_with_events + ); + } + + /** + * Get event counts for all terms in a taxonomy with a single query. + * + * Uses direct SQL with EventDatesTable for date filtering instead of + * DateFilter, since this method does GROUP BY with cross-taxonomy filtering. + * + * @param string $taxonomy_slug Taxonomy to count events for. + * @param array $date_context Optional date filtering context. + * @param array $active_filters Optional active taxonomy filters for cross-filtering. + * @param array|null $tax_query_override Optional taxonomy query override. + * @return array Term ID => event count mapping. + */ + private function get_batch_term_counts( $taxonomy_slug, $date_context = array(), $active_filters = array(), $tax_query_override = null ) { + global $wpdb; + + $post_type = Event_Post_Type::POST_TYPE; + + $joins = ''; + $where_clauses = ''; + $params = array( $taxonomy_slug, $post_type ); + + if ( ! empty( $date_context ) ) { + $date_start = $date_context['date_start'] ?? ''; + $date_end = $date_context['date_end'] ?? ''; + $show_past = ! empty( $date_context['past'] ) && '1' === $date_context['past']; + $current_datetime = current_time( 'mysql' ); + + $ed_table = EventDatesTable::table_name(); + $joins .= " INNER JOIN {$ed_table} ed ON p.ID = ed.post_id"; + + if ( ! empty( $date_start ) && ! empty( $date_end ) ) { + $where_clauses .= ' AND (ed.start_datetime >= %s AND ed.start_datetime <= %s)'; + $params[] = $date_start . ' 00:00:00'; + $params[] = $date_end . ' 23:59:59'; + } elseif ( $show_past ) { + $where_clauses .= ' AND (ed.start_datetime < %s AND (ed.end_datetime < %s OR ed.end_datetime IS NULL))'; + $params[] = $current_datetime; + $params[] = $current_datetime; + } else { + $where_clauses .= ' AND (ed.start_datetime >= %s OR ed.end_datetime >= %s)'; + $params[] = $current_datetime; + $params[] = $current_datetime; + } + } + + if ( ! empty( $tax_query_override ) && is_array( $tax_query_override ) ) { + $base_join_index = 0; + foreach ( $tax_query_override as $clause ) { + $base_taxonomy = sanitize_key( $clause['taxonomy'] ?? '' ); + $base_terms = array_map( 'absint', (array) ( $clause['terms'] ?? array() ) ); + + if ( ! $base_taxonomy || empty( $base_terms ) ) { + continue; + } + + $placeholders = implode( ',', array_fill( 0, count( $base_terms ), '%d' ) ); + $alias_tr = "base_tr_{$base_join_index}"; + $alias_tt = "base_tt_{$base_join_index}"; + + $joins .= " INNER JOIN {$wpdb->term_relationships} {$alias_tr} ON p.ID = {$alias_tr}.object_id"; + $joins .= " INNER JOIN {$wpdb->term_taxonomy} {$alias_tt} ON {$alias_tr}.term_taxonomy_id = {$alias_tt}.term_taxonomy_id"; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $where_clauses .= " AND {$alias_tt}.taxonomy = %s AND {$alias_tt}.term_id IN ($placeholders)"; + $params[] = $base_taxonomy; + $params = array_merge( $params, $base_terms ); + + ++$base_join_index; + } + } + + // Cross-taxonomy filtering (exclude current taxonomy from cross-filter). + $cross_filters = array_diff_key( $active_filters, array( $taxonomy_slug => true ) ); + $join_index = 0; + foreach ( $cross_filters as $filter_taxonomy => $term_ids ) { + if ( empty( $term_ids ) ) { + continue; + } + + $term_ids = array_map( 'intval', (array) $term_ids ); + $placeholders = implode( ',', array_fill( 0, count( $term_ids ), '%d' ) ); + + $alias_tr = "cross_tr_{$join_index}"; + $alias_tt = "cross_tt_{$join_index}"; + + $joins .= " INNER JOIN {$wpdb->term_relationships} {$alias_tr} ON p.ID = {$alias_tr}.object_id"; + $joins .= " INNER JOIN {$wpdb->term_taxonomy} {$alias_tt} ON {$alias_tr}.term_taxonomy_id = {$alias_tt}.term_taxonomy_id"; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $where_clauses .= " AND {$alias_tt}.taxonomy = %s AND {$alias_tt}.term_id IN ($placeholders)"; + $params[] = $filter_taxonomy; + $params = array_merge( $params, $term_ids ); + + ++$join_index; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $query = $wpdb->prepare( + "SELECT tt.term_id, COUNT(DISTINCT tr.object_id) as event_count + FROM {$wpdb->term_relationships} tr + INNER JOIN {$wpdb->term_taxonomy} tt + ON tr.term_taxonomy_id = tt.term_taxonomy_id + INNER JOIN {$wpdb->posts} p + ON tr.object_id = p.ID + {$joins} + WHERE tt.taxonomy = %s + AND p.post_type = %s + AND p.post_status = 'publish' + {$where_clauses} + GROUP BY tt.term_id", + $params + ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $results = $wpdb->get_results( $query ); + + $counts = array(); + foreach ( $results as $row ) { + $counts[ (int) $row->term_id ] = (int) $row->event_count; + } + + return $counts; + } + + /** + * Build a nested hierarchy tree from a flat array of terms. + * + * @param array $terms Flat array of term objects. + * @param int $parent_id Parent term ID for current level. + * @param int $level Current nesting level. + * @return array Nested tree structure. + */ + private static function build_hierarchy_tree( $terms, $parent_id = 0, $level = 0 ) { + $tree = array(); + + $term_ids = array_map( + function ( $t ) { + return $t->term_id; + }, + $terms + ); + + foreach ( $terms as $term ) { + $effective_parent = $term->parent; + while ( 0 !== $effective_parent && ! in_array( $effective_parent, $term_ids, true ) ) { + $parent_term = get_term( $effective_parent ); + $effective_parent = $parent_term && ! is_wp_error( $parent_term ) ? $parent_term->parent : 0; + } + + if ( $effective_parent == $parent_id ) { + $term_data = array( + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'event_count' => $term->event_count, + 'level' => $level, + 'children' => array(), + ); + + $children = self::build_hierarchy_tree( $terms, $term->term_id, $level + 1 ); + if ( ! empty( $children ) ) { + $term_data['children'] = $children; + } + + $tree[] = $term_data; + } + } + + return $tree; + } + + /** + * Flatten a nested term hierarchy into a flat array, preserving level information. + * + * @param array $terms_hierarchy Nested term structure. + * @return array Flattened term array maintaining level information. + */ + public static function flatten_hierarchy( $terms_hierarchy ) { + $flattened = array(); + + foreach ( $terms_hierarchy as $term ) { + $flattened[] = $term; + + if ( ! empty( $term['children'] ) ) { + $flattened = array_merge( $flattened, self::flatten_hierarchy( $term['children'] ) ); + } + } + + return $flattened; + } } diff --git a/inc/Abilities/TicketUrlResyncAbilities.php b/inc/Abilities/TicketUrlResyncAbilities.php index fca7236..9da771a 100644 --- a/inc/Abilities/TicketUrlResyncAbilities.php +++ b/inc/Abilities/TicketUrlResyncAbilities.php @@ -17,7 +17,7 @@ namespace DataMachineEvents\Abilities; -use DataMachineEvents\Core\Event_Post_Type; +use DataMachineEvents\Abilities\EventDateQueryAbilities; use function DataMachineEvents\Core\datamachine_normalize_ticket_url; if ( ! defined( 'ABSPATH' ) ) { @@ -114,33 +114,16 @@ public function executeResync( array $input ): array { $limit = (int) ( $input['limit'] ?? self::DEFAULT_LIMIT ); $future_only = $input['future_only'] ?? false; - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => $limit, - 'post_status' => 'any', - 'orderby' => 'date', - 'order' => 'DESC', + $event_query = new EventDateQueryAbilities(); + $query_input = array( + 'scope' => $future_only ? 'upcoming' : 'all', + 'per_page' => $limit, + 'status' => 'any', + 'order' => 'DESC', ); - if ( $future_only ) { - $future_date = current_time( 'Y-m-d' ); - $future_date_filter = function ( $clauses ) use ( $future_date ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime >= %s', $future_date ); - return $clauses; - }; - add_filter( 'posts_clauses', $future_date_filter ); - } - - $events = get_posts( $args ); - - if ( $future_only ) { - remove_filter( 'posts_clauses', $future_date_filter ); - } + $result = $event_query->executeQueryEvents( $query_input ); + $events = $result['posts']; $updated = 0; $skipped = 0; $changes = array(); diff --git a/inc/Abilities/TimezoneAbilities.php b/inc/Abilities/TimezoneAbilities.php index bea8f32..82f7666 100644 --- a/inc/Abilities/TimezoneAbilities.php +++ b/inc/Abilities/TimezoneAbilities.php @@ -10,6 +10,7 @@ namespace DataMachineEvents\Abilities; +use DataMachineEvents\Abilities\EventDateQueryAbilities; use DataMachineEvents\Core\Event_Post_Type; use DataMachineEvents\Core\Venue_Taxonomy; @@ -220,43 +221,19 @@ public function executeFixAbility( array $input ): array { } private function queryEvents( string $scope, int $days_ahead ): array { - $order = 'ASC'; - if ( 'past' === $scope ) { - $order = 'DESC'; - } - - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'none', - 'order' => $order, + $input = array( + 'scope' => $scope, + 'order' => 'past' === $scope ? 'DESC' : 'ASC', ); - $now = current_time( 'Y-m-d H:i:s' ); - - $event_date_filter = function ( $clauses ) use ( $scope, $now, $days_ahead, $order ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - if ( 'upcoming' === $scope ) { - $end_date = gmdate( 'Y-m-d H:i:s', strtotime( "+{$days_ahead} days" ) ); - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime BETWEEN %s AND %s', $now, $end_date ); - } elseif ( 'past' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime < %s', $now ); - } - $clauses['orderby'] = 'ed.start_datetime ' . $order; - return $clauses; - }; - add_filter( 'posts_clauses', $event_date_filter ); - - $query = new \WP_Query( $args ); + if ( 'upcoming' === $scope && $days_ahead > 0 ) { + $input['days_ahead'] = $days_ahead; + } - remove_filter( 'posts_clauses', $event_date_filter ); + $ability = new EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( $input ); - return $query->posts; + return $result['posts']; } private function findBrokenTimezoneEvents( string $scope, int $days_ahead, int $limit ): array { diff --git a/inc/Blocks/Calendar/Pagination/PageBoundary.php b/inc/Blocks/Calendar/Pagination/PageBoundary.php index d388100..7a99adb 100644 --- a/inc/Blocks/Calendar/Pagination/PageBoundary.php +++ b/inc/Blocks/Calendar/Pagination/PageBoundary.php @@ -28,6 +28,8 @@ class PageBoundary { * * Multi-day events are expanded to count on each spanned date. * + * @deprecated Use CalendarAbilities::get_unique_event_dates() instead. Moved in 0.16.0. + * * @param array $params Query parameters. * @return array { * @type array $dates Ordered array of unique date strings (Y-m-d). @@ -59,6 +61,8 @@ public static function get_unique_event_dates( array $params ): array { * ~960ms to ~500ms at 25,000 events by eliminating the ID column * and gmdate() parsing in PHP. * + * @deprecated Use CalendarAbilities::compute_unique_event_dates() instead. Moved in 0.16.0. + * * @param array $params Query parameters. * @return array Event dates data. */ diff --git a/inc/Blocks/Calendar/Query/EventQueryBuilder.php b/inc/Blocks/Calendar/Query/EventQueryBuilder.php index 4ef452e..46cf7c5 100644 --- a/inc/Blocks/Calendar/Query/EventQueryBuilder.php +++ b/inc/Blocks/Calendar/Query/EventQueryBuilder.php @@ -21,6 +21,10 @@ exit; } +/** + * @deprecated 0.24.0 Use data-machine-events/query-events ability + * (EventDateQueryAbilities::executeQueryEvents) instead. + */ class EventQueryBuilder { /** diff --git a/inc/Blocks/Calendar/Taxonomy_Helper.php b/inc/Blocks/Calendar/Taxonomy_Helper.php index 481dbd5..d791ff3 100644 --- a/inc/Blocks/Calendar/Taxonomy_Helper.php +++ b/inc/Blocks/Calendar/Taxonomy_Helper.php @@ -15,15 +15,24 @@ } /** - * Taxonomy data processing with hierarchy building and post count calculations + * Taxonomy data processing with hierarchy building and post count calculations. + * + * @deprecated Use FilterAbilities methods instead. This class is kept only for + * backward compatibility and will be removed in a future release. + * @see \DataMachineEvents\Abilities\FilterAbilities */ class Taxonomy_Helper { /** - * Get all taxonomies with event counts using real-time cross-filtering + * Get all taxonomies with event counts using real-time cross-filtering. + * + * @deprecated Use FilterAbilities::executeGetFilterOptions() or the private + * get_all_taxonomies_with_counts() method on FilterAbilities. + * @see \DataMachineEvents\Abilities\FilterAbilities * - * @param array $active_filters Active filter selections keyed by taxonomy slug. - * @param array $date_context Optional date filtering context (date_start, date_end, past). + * @param array $active_filters Active filter selections keyed by taxonomy slug. + * @param array $date_context Optional date filtering context (date_start, date_end, past). + * @param array|null $tax_query_override Optional taxonomy query override. * @return array Structured taxonomy data with hierarchy and event counts. */ public static function get_all_taxonomies_with_counts( $active_filters = array(), $date_context = array(), $tax_query_override = null ) { @@ -58,12 +67,16 @@ public static function get_all_taxonomies_with_counts( $active_filters = array() } /** - * Get terms in a taxonomy filtered by allowed term IDs + * Get terms in a taxonomy filtered by allowed term IDs. + * + * @deprecated Use the private get_taxonomy_hierarchy() method on FilterAbilities. + * @see \DataMachineEvents\Abilities\FilterAbilities * - * @param string $taxonomy_slug Taxonomy to get terms for. + * @param string $taxonomy_slug Taxonomy to get terms for. * @param array|null $allowed_term_ids Limit to these term IDs, or null for all. - * @param array $date_context Optional date filtering context. - * @param array $active_filters Optional active taxonomy filters for cross-filtering. + * @param array $date_context Optional date filtering context. + * @param array $active_filters Optional active taxonomy filters for cross-filtering. + * @param array|null $tax_query_override Optional taxonomy query override. * @return array Hierarchical term structure with event counts. */ public static function get_taxonomy_hierarchy( $taxonomy_slug, $allowed_term_ids = null, $date_context = array(), $active_filters = array(), $tax_query_override = null ) { diff --git a/inc/Blocks/Calendar/render.php b/inc/Blocks/Calendar/render.php index 715057b..7fa62f8 100644 --- a/inc/Blocks/Calendar/render.php +++ b/inc/Blocks/Calendar/render.php @@ -17,7 +17,6 @@ use DataMachineEvents\Abilities\CalendarAbilities; use DataMachineEvents\Abilities\FilterAbilities; -use DataMachineEvents\Blocks\Calendar\Taxonomy_Helper; use DataMachineEvents\Blocks\Calendar\Query\ScopeResolver; if ( wp_is_json_request() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { @@ -155,7 +154,7 @@ $has_other_archive_taxonomy_terms = false; if ( isset( $taxonomies_with_counts[ $archive_context['taxonomy'] ] ) ) { - $archive_terms = Taxonomy_Helper::flatten_hierarchy( $taxonomies_with_counts[ $archive_context['taxonomy'] ]['terms'] ?? array() ); + $archive_terms = FilterAbilities::flatten_hierarchy( $taxonomies_with_counts[ $archive_context['taxonomy'] ]['terms'] ?? array() ); foreach ( $archive_terms as $term_data ) { if ( (int) ( $term_data['term_id'] ?? 0 ) !== (int) $archive_context['term_id'] ) { $has_other_archive_taxonomy_terms = true; diff --git a/inc/Cli/Check/EventQueryTrait.php b/inc/Cli/Check/EventQueryTrait.php index e937287..ba6edb5 100644 --- a/inc/Cli/Check/EventQueryTrait.php +++ b/inc/Cli/Check/EventQueryTrait.php @@ -11,8 +11,6 @@ namespace DataMachineEvents\Cli\Check; -use DataMachineEvents\Core\Event_Post_Type; - trait EventQueryTrait { /** @@ -23,43 +21,19 @@ trait EventQueryTrait { * @return \WP_Post[] Array of post objects. */ private function query_events( string $scope, int $days_ahead = 90 ): array { - $order = 'ASC'; - if ( 'past' === $scope ) { - $order = 'DESC'; - } - - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'orderby' => 'none', - 'order' => $order, + $input = array( + 'scope' => $scope, + 'order' => 'past' === $scope ? 'DESC' : 'ASC', ); - $now = current_time( 'Y-m-d H:i:s' ); - - $event_date_filter = function ( $clauses ) use ( $scope, $now, $days_ahead, $order ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - if ( 'upcoming' === $scope ) { - $end_date = gmdate( 'Y-m-d H:i:s', strtotime( "+{$days_ahead} days" ) ); - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime BETWEEN %s AND %s', $now, $end_date ); - } elseif ( 'past' === $scope ) { - $clauses['where'] .= $wpdb->prepare( ' AND ed.start_datetime < %s', $now ); - } - $clauses['orderby'] = 'ed.start_datetime ' . $order; - return $clauses; - }; - add_filter( 'posts_clauses', $event_date_filter ); - - $query = new \WP_Query( $args ); + if ( 'upcoming' === $scope && $days_ahead > 0 ) { + $input['days_ahead'] = $days_ahead; + } - remove_filter( 'posts_clauses', $event_date_filter ); + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( $input ); - return $query->posts; + return $result['posts']; } /** diff --git a/inc/Steps/Upsert/Events/EventUpsert.php b/inc/Steps/Upsert/Events/EventUpsert.php index 32c400a..e7d2da2 100644 --- a/inc/Steps/Upsert/Events/EventUpsert.php +++ b/inc/Steps/Upsert/Events/EventUpsert.php @@ -472,35 +472,16 @@ private function findEventByVenueDateAndFuzzyTitle( string $title, string $venue } // Query events at this venue on this date. - // Use date-only for LIKE query; time comparison is done separately. + // Use date-only matching; time comparison is done separately. $date_only = self::extractDateForQuery( $startDate ); - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 10, - 'post_status' => array( 'publish', 'draft', 'pending' ), - 'tax_query' => array( - array( - 'taxonomy' => 'venue', - 'field' => 'term_id', - 'terms' => $venue_term->term_id, - ), - ), - ); - - $date_filter = function ( $clauses ) use ( $date_only ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $date_only ); - return $clauses; - }; - add_filter( 'posts_clauses', $date_filter ); - - $candidates = get_posts( $args ); - - remove_filter( 'posts_clauses', $date_filter ); + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( array( + 'date_match' => $date_only, + 'tax_filters' => array( 'venue' => array( $venue_term->term_id ) ), + 'per_page' => 10, + 'status' => 'any', + ) ); + $candidates = $result['posts']; if ( empty( $candidates ) ) { return null; @@ -643,32 +624,32 @@ private static function extractDateForQuery( string $datetime ): string { private function findEventByExactTitle( string $title, string $venue, string $startDate ): ?int { $low_confidence_title = EventIdentifierGenerator::isLowConfidenceTitle( $title ); - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'title' => $title, - 'posts_per_page' => 1, - 'post_status' => array( 'publish', 'draft', 'pending' ), - 'fields' => 'ids', - ); - if ( ! empty( $startDate ) ) { $exact_date_only = self::extractDateForQuery( $startDate ); - $date_filter = function ( $clauses ) use ( $exact_date_only ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( array( + 'date_match' => $exact_date_only, + 'per_page' => -1, + 'fields' => 'ids', + 'status' => 'any', + ) ); + // Filter to exact title match in PHP. + $posts = array(); + foreach ( $result['posts'] as $candidate_id ) { + if ( get_the_title( $candidate_id ) === $title ) { + $posts[] = $candidate_id; + break; } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $exact_date_only ); - return $clauses; - }; - add_filter( 'posts_clauses', $date_filter ); - } - - $posts = get_posts( $args ); - - if ( ! empty( $startDate ) ) { - remove_filter( 'posts_clauses', $date_filter ); + } + } else { + $args = array( + 'post_type' => Event_Post_Type::POST_TYPE, + 'title' => $title, + 'posts_per_page' => 1, + 'post_status' => array( 'publish', 'draft', 'pending' ), + 'fields' => 'ids', + ); + $posts = get_posts( $args ); } if ( ! empty( $posts ) ) { @@ -740,34 +721,21 @@ private function findEventByTicketUrl( string $ticketUrl, string $startDate ): ? // Strategy A: exact match on stored normalized URL $ticket_date_only = self::extractDateForQuery( $startDate ); - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => array( 'publish', 'draft', 'pending' ), - 'fields' => 'ids', - 'meta_query' => array( + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $result_a = $ability->executeQueryEvents( array( + 'date_match' => $ticket_date_only, + 'per_page' => 1, + 'fields' => 'ids', + 'status' => 'any', + 'meta_query' => array( array( 'key' => EVENT_TICKET_URL_META_KEY, 'value' => $normalized_url, 'compare' => '=', ), ), - ); - - $ticket_date_filter_a = function ( $clauses ) use ( $ticket_date_only ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $ticket_date_only ); - return $clauses; - }; - add_filter( 'posts_clauses', $ticket_date_filter_a ); - - $posts = get_posts( $args ); - - remove_filter( 'posts_clauses', $ticket_date_filter_a ); + ) ); + $posts = $result_a['posts']; if ( ! empty( $posts ) ) { do_action( @@ -793,33 +761,19 @@ private function findEventByTicketUrl( string $ticketUrl, string $startDate ): ? } // Search all events on the same date and compare their canonical identities - $date_args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 50, - 'post_status' => array( 'publish', 'draft', 'pending' ), - 'fields' => 'ids', - 'meta_query' => array( + $result_b = $ability->executeQueryEvents( array( + 'date_match' => $ticket_date_only, + 'per_page' => 50, + 'fields' => 'ids', + 'status' => 'any', + 'meta_query' => array( array( 'key' => EVENT_TICKET_URL_META_KEY, 'compare' => 'EXISTS', ), ), - ); - - $ticket_date_filter_b = function ( $clauses ) use ( $ticket_date_only ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $ticket_date_only ); - return $clauses; - }; - add_filter( 'posts_clauses', $ticket_date_filter_b ); - - $candidates = get_posts( $date_args ); - - remove_filter( 'posts_clauses', $ticket_date_filter_b ); + ) ); + $candidates = $result_b['posts']; foreach ( $candidates as $candidate_id ) { $stored_url = get_post_meta( $candidate_id, EVENT_TICKET_URL_META_KEY, true ); @@ -871,26 +825,13 @@ private function findEventByDateAndFuzzyTitle( string $title, string $startDate, } $date_only = self::extractDateForQuery( $startDate ); - $args = array( - 'post_type' => Event_Post_Type::POST_TYPE, - 'posts_per_page' => 20, - 'post_status' => array( 'publish', 'draft', 'pending' ), - ); - - $date_filter = function ( $clauses ) use ( $date_only ) { - global $wpdb; - $table = \DataMachineEvents\Core\EventDatesTable::table_name(); - if ( strpos( $clauses['join'], $table ) === false ) { - $clauses['join'] .= " INNER JOIN {$table} AS ed ON {$wpdb->posts}.ID = ed.post_id"; - } - $clauses['where'] .= $wpdb->prepare( ' AND DATE(ed.start_datetime) = %s', $date_only ); - return $clauses; - }; - add_filter( 'posts_clauses', $date_filter ); - - $candidates = get_posts( $args ); - - remove_filter( 'posts_clauses', $date_filter ); + $ability = new \DataMachineEvents\Abilities\EventDateQueryAbilities(); + $result = $ability->executeQueryEvents( array( + 'date_match' => $date_only, + 'per_page' => 20, + 'status' => 'any', + ) ); + $candidates = $result['posts']; if ( empty( $candidates ) ) { return null;