From f9912030d0e3b31624e8ad893be062fa0dcbf292 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:06:34 +0000 Subject: [PATCH] feat: add cross-site events-by-artist LIST ability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register data-machine-events/events-by-artist, a cross-site-callable ability that returns the list of events tagged to an artist term on the events site, split into upcoming/past with all presentational strings (title, permalink, venue name, formatted date/time) pre-resolved in events-blog context. Fixes the broken Shows section on the artist site: switch_to_blog() changes DB context but not loaded PHP, so a consumer on another blog cannot call data_machine_events_query_events() directly. The ability is the bridge — it switches to the events blog internally (restore in try/finally) and returns structured data, not markup. Closes #408 --- data-machine-events.php | 5 + inc/Abilities/EventsByArtistAbilities.php | 369 ++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 inc/Abilities/EventsByArtistAbilities.php diff --git a/data-machine-events.php b/data-machine-events.php index a1737ad..c8aa65b 100644 --- a/data-machine-events.php +++ b/data-machine-events.php @@ -425,6 +425,11 @@ function ( array $templates ): array { new \DataMachineEvents\Abilities\EventDateQueryAbilities(); } + if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/EventsByArtistAbilities.php' ) ) { + require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/EventsByArtistAbilities.php'; + new \DataMachineEvents\Abilities\EventsByArtistAbilities(); + } + if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDetectAbilities.php' ) ) { require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDetectAbilities.php'; new \DataMachineEvents\Abilities\MergedBillDetectAbilities(); diff --git a/inc/Abilities/EventsByArtistAbilities.php b/inc/Abilities/EventsByArtistAbilities.php new file mode 100644 index 0000000..46322a6 --- /dev/null +++ b/inc/Abilities/EventsByArtistAbilities.php @@ -0,0 +1,369 @@ +registerAbilities(); + self::$registered = true; + } + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'data-machine-events/events-by-artist', + array( + 'label' => __( 'Events By Artist', 'data-machine-events' ), + 'description' => __( 'Return the list of events for an artist term on the events site, split into upcoming and past. Cross-site callable: resolves everything (permalinks, venue names, formatted dates) on the events blog so consumers on any other site can render the result directly.', 'data-machine-events' ), + 'category' => 'datamachine-events-events', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'artist_slug' ), + 'properties' => array( + 'artist_slug' => array( + 'type' => 'string', + 'description' => __( 'Artist term slug (the canonical cross-blog join key) to look up on the events site.', 'data-machine-events' ), + ), + 'scope' => array( + 'type' => 'string', + 'enum' => array( 'upcoming', 'past', 'all' ), + 'description' => __( 'Which events to return. Default all.', 'data-machine-events' ), + ), + 'limit' => array( + 'type' => 'integer', + 'description' => __( 'Maximum events to return per scope (upcoming and past are limited independently). Default 12.', 'data-machine-events' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'artist_slug' => array( 'type' => 'string' ), + 'found' => array( 'type' => 'boolean' ), + 'upcoming' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'past' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + ), + ), + 'execute_callback' => array( $this, 'executeEventsByArtist' ), + '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 events-by-artist ability. + * + * Switches to the events blog, resolves the artist term by slug, queries + * the event_dates table for that term split by now, and returns a plain + * structured array with presentational strings pre-resolved. + * + * @param array $input Input parameters. + * @return array|\WP_Error { artist_slug, found, upcoming: [...], past: [...] } + */ + public function executeEventsByArtist( array $input ): array|\WP_Error { + $artist_slug = isset( $input['artist_slug'] ) ? sanitize_title( (string) $input['artist_slug'] ) : ''; + $scope = $input['scope'] ?? 'all'; + if ( ! in_array( $scope, array( 'upcoming', 'past', 'all' ), true ) ) { + $scope = 'all'; + } + $limit = isset( $input['limit'] ) ? absint( $input['limit'] ) : self::DEFAULT_LIMIT; + if ( $limit < 1 ) { + $limit = self::DEFAULT_LIMIT; + } + + if ( '' === $artist_slug ) { + return new \WP_Error( + 'invalid_artist_slug', + __( 'A non-empty artist_slug is required.', 'data-machine-events' ), + array( 'status' => 400 ) + ); + } + + $events_blog_id = $this->resolveEventsBlogId(); + if ( ! $events_blog_id ) { + return new \WP_Error( + 'events_site_unresolved', + __( 'Could not resolve the events site blog ID.', 'data-machine-events' ), + array( 'status' => 500 ) + ); + } + + switch_to_blog( $events_blog_id ); + try { + return $this->collectEventsForArtist( $artist_slug, $scope, $limit ); + } finally { + restore_current_blog(); + } + } + + /** + * Resolve the events site blog ID. + * + * Prefers the network helper ec_get_blog_id('events') when available. + * A filter provides a graceful override / fallback for installs where + * that helper is not present, keeping this plugin generic (it does not + * hard-code any site's blog ID). + * + * @return int Blog ID, or 0 when unresolved. + */ + private function resolveEventsBlogId(): int { + $blog_id = 0; + + if ( function_exists( 'ec_get_blog_id' ) ) { + $blog_id = (int) ec_get_blog_id( 'events' ); + } + + /** + * Filter the resolved events-site blog ID for the events-by-artist ability. + * + * Lets non-Extra-Chill installs (or tests) point the ability at the + * blog that actually holds event posts without hard-coding an ID in + * this generic plugin. Return a positive integer blog ID. + * + * @param int $blog_id Blog ID resolved from ec_get_blog_id('events'), or 0. + */ + $blog_id = (int) apply_filters( 'data_machine_events_events_blog_id', $blog_id ); + + return $blog_id > 0 ? $blog_id : 0; + } + + /** + * Collect upcoming and past events for an artist term. + * + * Must be called in events-blog context (after switch_to_blog). Reads the + * event_dates table joined to term_relationships so past/upcoming split + * comes straight from start_datetime vs now, then pre-resolves every + * presentational string per event. + * + * @param string $artist_slug Artist term slug. + * @param string $scope upcoming|past|all. + * @param int $limit Per-scope result limit. + * @return array Structured result. + */ + private function collectEventsForArtist( string $artist_slug, string $scope, int $limit ): array { + $empty = array( + 'artist_slug' => $artist_slug, + 'found' => false, + 'upcoming' => array(), + 'past' => array(), + ); + + if ( ! taxonomy_exists( 'artist' ) ) { + return $empty; + } + + $term = get_term_by( 'slug', $artist_slug, 'artist' ); + if ( ! $term || is_wp_error( $term ) ) { + return $empty; + } + + $result = array( + 'artist_slug' => $artist_slug, + 'found' => true, + 'upcoming' => array(), + 'past' => array(), + ); + + if ( 'past' !== $scope ) { + $result['upcoming'] = $this->queryScope( (int) $term->term_id, 'upcoming', $limit ); + } + if ( 'upcoming' !== $scope ) { + $result['past'] = $this->queryScope( (int) $term->term_id, 'past', $limit ); + } + + return $result; + } + + /** + * Query one scope (upcoming or past) of events for an artist term. + * + * Reads post IDs directly from the event_dates table joined to + * term_relationships / term_taxonomy, filtered by start_datetime vs now + * and post_status = 'publish'. Upcoming is ordered soonest-first; past is + * ordered most-recent-first. Each event is then hydrated into a plain + * presentational array. + * + * @param int $term_id Artist term ID. + * @param string $scope upcoming|past. + * @param int $limit Max events to return. + * @return array List of event arrays. + */ + private function queryScope( int $term_id, string $scope, int $limit ): array { + global $wpdb; + + $ed_table = EventDatesTable::table_name(); + $now = current_time( 'mysql' ); + + // Two fully-literal query strings keep the date comparator and sort + // direction out of variable interpolation so $wpdb->prepare() sees a + // stable placeholder count. The only interpolated token is the table + // name (a trusted internal constant), matching the sibling + // UpcomingCountAbilities pattern. + $timing = ( 'upcoming' === $scope ) ? 'upcoming' : 'past'; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ed_table is a trusted internal constant; the two literal query strings keep placeholder counts stable. + if ( 'upcoming' === $scope ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ed.post_id AS post_id, ed.start_datetime AS start_datetime + FROM {$ed_table} ed + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = ed.post_id + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy = 'artist' + AND tt.term_id = %d + AND ed.post_status = 'publish' + AND ed.start_datetime >= %s + ORDER BY ed.start_datetime ASC + LIMIT %d", + $term_id, + $now, + $limit + ) + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ed.post_id AS post_id, ed.start_datetime AS start_datetime + FROM {$ed_table} ed + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = ed.post_id + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy = 'artist' + AND tt.term_id = %d + AND ed.post_status = 'publish' + AND ed.start_datetime < %s + ORDER BY ed.start_datetime DESC + LIMIT %d", + $term_id, + $now, + $limit + ) + ); + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( empty( $rows ) ) { + return array(); + } + + $events = array(); + foreach ( $rows as $row ) { + $event = $this->hydrateEvent( (int) $row->post_id, (string) $row->start_datetime, $timing ); + if ( null !== $event ) { + $events[] = $event; + } + } + + return $events; + } + + /** + * Hydrate a single event post into a plain presentational array. + * + * Resolves title, permalink, venue name, and formatted date / time strings + * while in events-blog context so the consumer (on another blog) can render + * them directly without re-resolving anything. + * + * @param int $post_id Event post ID. + * @param string $start_datetime MySQL start datetime from the event_dates table. + * @param string $timing 'upcoming' | 'past'. + * @return array|null Event array, or null when the post is unavailable. + */ + private function hydrateEvent( int $post_id, string $start_datetime, string $timing ): ?array { + $post = get_post( $post_id ); + if ( ! $post || 'publish' !== $post->post_status ) { + return null; + } + + $permalink = get_permalink( $post_id ); + if ( ! $permalink || is_wp_error( $permalink ) ) { + return null; + } + + $venue_name = ''; + $venue_terms = get_the_terms( $post_id, 'venue' ); + if ( $venue_terms && ! is_wp_error( $venue_terms ) ) { + $venue_name = html_entity_decode( (string) $venue_terms[0]->name, ENT_QUOTES, 'UTF-8' ); + } + + $timestamp = strtotime( $start_datetime ); + $date_iso = $timestamp ? gmdate( 'c', $timestamp ) : ''; + $date_display = $timestamp ? date_i18n( get_option( 'date_format' ), $timestamp ) : ''; + + // A midnight start time is the sentinel for "no known time"; only emit + // a time string when the event actually carries one. + $time_display = ''; + if ( $timestamp && '00:00:00' !== gmdate( 'H:i:s', $timestamp ) ) { + $time_display = date_i18n( get_option( 'time_format' ), $timestamp ); + } + + return array( + 'event_id' => $post_id, + 'title' => html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ), + 'permalink' => $permalink, + 'venue_name' => $venue_name, + 'date_iso' => $date_iso, + 'date_display' => $date_display, + 'time_display' => $time_display, + 'timing' => $timing, + ); + } +}