Making WordPress.org


Ignore:
Timestamp:
12/22/2021 02:08:56 AM (4 years ago)
Author:
dd32
Message:

Official WordPress Events: Upgrade the Meetup.com client to query via a new GraphQL API, as the REST API is being deprecated by meetup.com.

The API return values have changed significantly, but also mostly the same. This only queries for data required, and should be much more efficient in terms of querying for events and groups.

See https://github.com/WordPress/wordcamp.org/issues/697

File:
1 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/official-wordpress-events/meetup/class-meetup-client.php

    r11153 r11393  
    22namespace WordCamp\Utilities;
    33
     4use DateTime, DateTimeZone;
    45use WP_Error;
    56
     
    2021 */
    2122class Meetup_Client extends API_Client {
    22     /**
    23      * @var string The base URL for the API endpoints.
    24      */
    25     protected $api_base = 'https://api.meetup.com/';
     23
     24    /**
     25     * @var int The Venue ID for online events.
     26     */
     27    const ONLINE_VENUE_ID = 26906060;
     28
     29    /**
     30     * @var string The URL for the API endpoints.
     31     */
     32    protected $api_url = 'https://api.meetup.com/gql';
     33
     34    /**
     35     * @var string The GraphQL field that must be present for pagination to work.
     36     */
     37    public $pageInfo = 'pageInfo { hasNextPage endCursor }';
    2638
    2739    /**
     
    5971             */
    6072            'breaking_response_codes' => array(
     73                // TODO: NOTE: These headers are not returned from the GraphQL API, every request is 200 even if throttled.
    6174                401, // Unauthorized (invalid key).
    6275                429, // Too many requests (rate-limited).
    6376                404, // Unable to find group
     77
     78                503, // Timeout between API cache & GraphQL Server.
    6479            ),
     80            // NOTE: GraphQL does not expose the Quota Headers.
    6581            'throttle_callback'       => array( __CLASS__, 'throttle' ),
    6682        ) );
     
    123139     *
    124140     * This automatically paginates requests and will repeat requests to ensure all results are retrieved.
    125      * It also tries to account for API request limits and throttles to avoid getting a limit error.
     141     * For pagination to work, $this->pageInfo must be present within the string, and a 'cursor' variable defined.
    126142     *
    127143     * @param string $request_url The API endpoint URL to send the request to.
     144     * @param array  $variables   The Query variables used in the query.
    128145     *
    129146     * @return array|WP_Error The results of the request.
    130147     */
    131     protected function send_paginated_request( $request_url ) {
     148    public function send_paginated_request( $query, $variables = null ) {
    132149        $data = array();
    133150
    134         $request_url = add_query_arg( array(
    135             'page' => 200,
    136         ), $request_url );
    137 
    138         while ( $request_url ) {
    139             $response = $this->tenacious_remote_get( $request_url, $this->get_request_args() );
    140 
    141             if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
    142                 $body = json_decode( wp_remote_retrieve_body( $response ), true );
    143 
    144                 if ( isset( $body['results'] ) ) {
    145                     $new_data = $body['results'];
    146                 } else {
    147                     $new_data = $body;
    148                 }
    149 
    150                 if ( is_array( $new_data ) ) {
    151                     $data = array_merge( $data, $new_data );
    152                 } else {
    153                     $this->error->add(
    154                         'unexpected_response_data',
    155                         'The API response did not provide the expected data format.',
    156                         $response
    157                     );
    158                     break;
    159                 }
    160 
    161                 $request_url = $this->get_next_url( $response );
    162             } else {
    163                 $this->handle_error_response( $response, $request_url );
     151        $has_next_page        = false;
     152        $is_paginated_request = ! empty( $variables ) &&
     153            array_key_exists( 'cursor', $variables ) &&
     154            false !== stripos( $query, $this->pageInfo );
     155
     156        do {
     157            $request_args = $this->get_request_args( $query, $variables );
     158            $response     = $this->tenacious_remote_post( $this->api_url, $request_args );
     159
     160            if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
     161                $this->handle_error_response( $response, $this->api_url, $request_args );
    164162                break;
    165163            }
    166164
    167             if ( $request_url && $this->debug ) {
     165            $new_data = json_decode( wp_remote_retrieve_body( $response ), true );
     166
     167            if ( ! empty( $new_data['error'] ) ) {
     168                $this->handle_error_response( $response, $this->api_url, $request_args );
     169                break;
     170            }
     171
     172            if ( ! is_array( $new_data ) || ! isset( $new_data['data'] ) ) {
     173                $this->error->add(
     174                    'unexpected_response_data',
     175                    'The API response did not provide the expected data format.',
     176                    $response
     177                );
     178                break;
     179            }
     180
     181            // Merge the data, overwriting scalar values (they should be the same), and merging arrays.
     182            $data = ! $data ? $new_data : $this->array_merge_recursive_numeric_arrays(
     183                $data,
     184                $new_data
     185            );
     186
     187            // Pagination - Find the values inside the 'pageInfo' key.
     188            if ( $is_paginated_request ) {
     189                $has_next_page = false;
     190                $end_cursor    = null;
     191
     192                // Flatten the data array to a set of [ $key => $value ] pairs for LEAF nodes,
     193                // $value will never be an array, and $key will never be set to 'pageInfo' where
     194                // the targetted values are living.
     195                array_walk_recursive(
     196                    $new_data,
     197                    function( $value, $key ) use( &$has_next_page, &$end_cursor ) {
     198                        // NOTE: This will be truthful and present on the final page causing paged
     199                        // requests to always make an additional request to a final empty page.
     200                        if ( $key === 'hasNextPage' ) {
     201                            $has_next_page = $value;
     202                        } elseif ( 'endCursor' === $key ) {
     203                            $end_cursor = $value;
     204                        }
     205                    }
     206                );
     207
     208                // Do not iterate if the cursor was what we just made the request with.
     209                // This should never happen, but protects against an infinite loop otherwise.
     210                if ( ! $end_cursor || $end_cursor === $variables['cursor'] ) {
     211                    $has_next_page = false;
     212                    $end_cursor    = false;
     213                }
     214
     215                $variables['cursor'] = $end_cursor;
     216            }
     217
     218            if ( $has_next_page && $this->debug ) {
    168219                if ( 'cli' === php_sapi_name() ) {
    169                     echo "\nDebug mode: Skipping future paginated requests to $request_url";
     220                    echo "\nDebug mode: Skipping future paginated requests";
    170221                }
    171222
    172223                break;
    173224            }
    174         }
     225        } while ( $has_next_page );
    175226
    176227        if ( ! empty( $this->error->get_error_messages() ) ) {
     
    178229        }
    179230
    180         return $data;
    181     }
    182 
    183     /**
    184      * Send a single request to the Meetup API and return the total number of results available.
    185      *
    186      * @param string $request_url The API endpoint URL to send the request to.
    187      *
    188      * @return int|WP_Error
    189      */
    190     protected function send_total_count_request( $request_url ) {
    191         $count = 0;
    192 
    193         $request_url = add_query_arg( array(
    194             // We're only interested in the headers, so we don't need to receive more than one result.
    195             'page' => 1,
    196         ), $request_url );
    197 
    198         $response = $this->tenacious_remote_get( $request_url, $this->get_request_args() );
    199 
    200         if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
    201             $count_header = wp_remote_retrieve_header( $response, 'X-Total-Count' );
    202 
    203             if ( $count_header ) {
    204                 $count = absint( $count_header );
     231        return $data['data'];
     232    }
     233
     234    /**
     235     * Similar to array_merge_recursive(), but only merges numeric arrays with one another, overwriting associative elements.
     236     *
     237     * Based on https://www.php.net/manual/en/function.array-merge-recursive.php#92195
     238     */
     239    private function array_merge_recursive_numeric_arrays( array &$array1, array &$array2 ) {
     240        $merged = $array1;
     241
     242        foreach ( $array2 as $key => &$value ) {
     243            // Merge numeric arrays
     244            if ( is_array( $value ) && wp_is_numeric_array( $value ) && isset( $merged[ $key ] ) ) {
     245                $merged[ $key ] = array_merge( $merged[ $key ], $value );
     246            } elseif ( is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
     247                $merged[ $key ] = $this->array_merge_recursive_numeric_arrays( $merged[ $key ], $value );
    205248            } else {
    206                 $this->error->add(
    207                     'unexpected_response_data',
    208                     'The API response did not provide a total count value.'
    209                 );
    210             }
    211         } else {
    212             $this->handle_error_response( $response, $request_url );
    213         }
    214 
    215         if ( ! empty( $this->error->get_error_messages() ) ) {
    216             return $this->error;
    217         }
    218 
    219         return $count;
     249                $merged[ $key ] = $value;
     250            }
     251        }
     252
     253        return $merged;
    220254    }
    221255
     
    225259     * @return array
    226260     */
    227     protected function get_request_args() {
     261    protected function get_request_args( $query, $variables = null ) {
    228262        $oauth_token = $this->oauth_client->get_oauth_token();
    229263
     
    232266        }
    233267
     268        if ( is_array( $variables ) ) {
     269            $variables = wp_json_encode( $variables );
     270        }
     271
    234272        return array(
     273            'timeout' => 60,
    235274            'headers' => array(
    236275                'Accept'        => 'application/json',
     276                'Content-Type'  => 'application/json',
    237277                'Authorization' => "Bearer $oauth_token",
    238278            ),
     279            'body' => wp_json_encode( compact( 'query', 'variables' ) )
    239280        );
    240     }
    241 
    242     /**
    243      * Get the URL for the next page of results from a paginated API response.
    244      *
    245      * @param array $response
    246      *
    247      * @return string
    248      */
    249     protected function get_next_url( $response ) {
    250         $url = '';
    251 
    252         // First try v3.
    253         $links = wp_remote_retrieve_header( $response, 'link' );
    254         if ( $links ) {
    255             // Meetup.com is now returning combined link headers
    256             if ( is_string( $links ) ) {
    257                 $links = preg_split( '!,\s+!', $links );
    258             }
    259             foreach ( (array) $links as $link ) {
    260                 if ( false !== strpos( $link, 'rel="next"' ) && preg_match( '/^<([^>]+)>/', $link, $matches ) ) {
    261                     $url = $matches[1];
    262                     break;
    263                 }
    264             }
    265         }
    266 
    267         // Then try v2.
    268         if ( ! $url ) {
    269             $body = json_decode( wp_remote_retrieve_body( $response ), true );
    270 
    271             if ( isset( $body['meta']['next'] ) ) {
    272                 $url = $body['meta']['next'];
    273             }
    274         }
    275 
    276         return esc_url_raw( $url );
    277281    }
    278282
     
    286290    protected static function throttle( $response ) {
    287291        $headers = wp_remote_retrieve_headers( $response );
     292
     293        /*
     294         * NOTE: This is not in use, as GraphQL API doesn't return rate limit headers,
     295         *       but does throttle requests & fail if you exceed it.
     296         */
    288297
    289298        if ( ! isset( $headers['x-ratelimit-remaining'], $headers['x-ratelimit-reset'] ) ) {
     
    315324
    316325    /**
     326     * Convert a ISO8601-ish DateTime returned from the API to a timestamp.
     327     *
     328     * Handles timestamps in two main formats:
     329     *  - 2021-11-20T17:00+05:30
     330     *  - 2021-11-20T06:30-05:00[US/Eastern]
     331     * Neither contains seconds.
     332     *
     333     * Some extra compat formats are included, just incase Meetup.com decides to return in other similar formats,
     334     * or with different timezone formats, etc.
     335     *
     336     * @param string $datetime A DateTime string returned by the API
     337     * @return int The UTC epoch timestamp.
     338     */
     339    public function datetime_to_time( $datetime ) {
     340        if ( is_numeric( $datetime ) && $datetime > 4102444800 /* 2100-01-01 */ ) {
     341            $datetime /= 1000;
     342            return (int) $datetime;
     343        } elseif ( is_numeric( $datetime ) ) {
     344            return (int) $datetime;
     345        }
     346
     347        $datetime_formats = [
     348            'Y-m-d\TH:iP',   // 2021-11-20T17:00+05:30
     349            'Y-m-d\TH:i:sP', // 2021-11-20T17:00:00+05:30
     350            // DateTime::createFromFormat() doesn't handle the final `]` character in the following timezone format.
     351            'Y-m-d\TH:i\[e', // 2021-11-20T06:30[US/Eastern]
     352            'c',             // ISO8601, just incase the above don't cover it.
     353            'Y-m-d\TH:i:s',  // timezoneless 2021-11-20T17:00:00
     354            'Y-m-d\TH:i',    // timezoneless 2021-11-20T17:00
     355        ];
     356
     357        // See above, just keep one timezone if the timezone format is `P\[e\]`. Simpler matching, assume the timezones are the same.
     358        $datetime = preg_replace( '/([-+][0-9:]+)[[].+[]]$/', '$1', $datetime );
     359
     360        // See above..
     361        $datetime = rtrim( $datetime, ']' );
     362
     363        // Just being hopeful.
     364        $time = strtotime( $datetime );
     365        if ( $time ) {
     366            return $time;
     367        }
     368
     369        // Try each of the timezone formats.
     370        foreach ( $datetime_formats as $format ) {
     371            $time = DateTime::createFromFormat( $format, $datetime );
     372            if ( $time ) {
     373                break;
     374            }
     375        }
     376
     377        if ( ! $time ) {
     378            return false;
     379        }
     380
     381        return (int) $time->format( 'U' );
     382    }
     383
     384    /**
    317385     * Extract error information from an API response and add it to our error handler.
    318386     *
     
    360428            foreach ( $data['errors'] as $details ) {
    361429                $error->add(
    362                     $details['code'],
    363                     $details['message']
     430                    $details['extensions']['code'],
     431                    $details['message'],
     432                    $details['locations'] ?? '' // TODO This isn't being passed through to the final error?
    364433                );
    365434            }
     
    387456     * Retrieve data about groups in the Chapter program.
    388457     *
    389      * @param array $args Optional. Additional request parameters.
    390      *                    See https://www.meetup.com/meetup_api/docs/pro/:urlname/groups/.
     458     * @param array $args Optional. 'fields' and 'filters' may be defined.
    391459     *
    392460     * @return array|WP_Error
    393461     */
    394462    public function get_groups( array $args = array() ) {
    395         $request_url = $this->api_base . 'pro/wordpress/groups';
    396 
    397         if ( ! empty( $args ) ) {
    398             $request_url = add_query_arg( $args, $request_url );
    399         }
    400 
    401         return $this->send_paginated_request( $request_url );
     463        $fields = $this->get_default_fields( 'group' );
     464
     465        if ( !empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
     466            $fields = array_merge( $fields, $args['fields'] );
     467        }
     468
     469        $filters = [];
     470        /*
     471         *  See https://www.meetup.com/api/schema/#GroupAnalyticsFilter for valid filters.
     472         */
     473        if ( isset( $args['pro_join_date_max'] ) ) {
     474            $filters['proJoinDateMax'] = 'proJoinDateMax: ' . $this->datetime_to_time( $args['pro_join_date_max'] ) * 1000;
     475        }
     476        if ( isset( $args['last_event_min'] ) ) {
     477            $filters['lastEventMin'] = 'lastEventMin: ' . $this->datetime_to_time( $args['last_event_min'] ) * 1000;
     478        }
     479
     480        if ( isset( $args['filters'] ) ) {
     481            foreach ( $args['filters'] as $key => $value ) {
     482                $filters[ $key ] = "{$key}: {$value}";
     483            }
     484        }
     485
     486        $variables = [
     487            'urlname' => 'wordpress',
     488            'perPage' => 200,
     489            'cursor'  => null,
     490        ];
     491
     492        $query = '
     493        query ($urlname: String!, $perPage: Int!, $cursor: String ) {
     494            proNetworkByUrlname( urlname: $urlname ) {
     495                groupsSearch( input: { first: $perPage, after: $cursor }, filter: { ' . implode( ', ', $filters ) . '} ) {
     496                    count
     497                    '  . $this->pageInfo . '
     498                    edges {
     499                        node {
     500                            ' . implode( ' ', $fields ) . '
     501                        }
     502                    }
     503                }
     504            }
     505        }';
     506
     507        $result = $this->send_paginated_request( $query, $variables );
     508
     509        if ( is_wp_error( $result ) || ! array_key_exists( 'groupsSearch', $result['proNetworkByUrlname'] ) ) {
     510            return $result;
     511        }
     512
     513        $results = array_column(
     514            $result['proNetworkByUrlname']['groupsSearch']['edges'],
     515            'node'
     516        );
     517
     518        foreach ( $results as &$result ) {
     519            $result['member_count']  = $result['groupAnalytics']['totalMembers'];
     520            $result['pro_join_date'] = $this->datetime_to_time( $result['proJoinDate'] ) * 1000;
     521
     522            if ( ! empty( $result['groupAnalytics']['lastEventDate'] ) ) {
     523                $result['last_event'] = $this->datetime_to_time( $result['groupAnalytics']['lastEventDate'] ) * 1000;
     524            }
     525        }
     526
     527        return $results;
    402528    }
    403529
     
    418544     *
    419545     * @param array $group_slugs The URL slugs of each group to retrieve events for. Also known as `urlname`.
    420      * @param array $args        Optional. Additional request parameters.
    421      *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/#list
     546     * @param array $args        Optional.  'fields' and 'filters' may be defined.
    422547     *
    423548     * @return array|WP_Error
     
    425550    public function get_events( array $group_slugs, array $args = array() ) {
    426551        $events = array();
     552
     553        // See get_network_events(), which should be preferred for most cases.
     554        // This is kept for back-compat.
    427555
    428556        if ( $this->debug ) {
     
    445573
    446574    /**
     575     * Retrieve Event Details
     576     *
     577     * @param string $event_id The Event ID.
     578     * @return array
     579     */
     580    function get_event_details( $event_id ) {
     581
     582        $fields = $this->get_default_fields( 'event' );
     583
     584        // Accepts, slug / id / slugId as the query-by fields.
     585        $query = '
     586        query ( $eventId: ID ) {
     587            event( id: $eventId ) {
     588                ' . implode( ' ', $fields ) . '
     589            }
     590        }';
     591        $variables = [
     592            'eventId' => $event_id,
     593        ];
     594
     595        $result = $this->send_paginated_request( $query, $variables );
     596
     597        if ( is_wp_error( $result ) || ! array_key_exists( 'event', $result ) ) {
     598            return $result;
     599        }
     600
     601        $event = $result['event'] ?: false;
     602
     603        if ( $event ) {
     604            $event = $this->apply_backcompat_fields( 'event',  $event );
     605        }
     606
     607        return $event;
     608    }
     609
     610    /**
     611     * Retrieve the event Status for a range of given IDs.
     612     *
     613     * @param array $event_ids An array of [ id => MeetupID, id2 => MeetupID2 ] to query for.
     614     * @return array Array of Event Statuses if events is found, null values if MeetupID doesn't exist.
     615     */
     616    public function get_events_status( $event_ids ) {
     617        /* $events = [ id => $meetupID, id2 => $meetupID2 ] */
     618
     619        $return = [];
     620        $chunks = array_chunk( $event_ids, 250, true );
     621
     622        foreach ( $chunks as $chunked_events ) {
     623            $keys      = [];
     624            $query     = '';
     625
     626            foreach ( $chunked_events as $id => $event_id ) {
     627                $key = 'e' . md5( $id );
     628                $keys[ $key ] = $id;
     629
     630                $query .= sprintf(
     631                    '%s: event( id: "%s" ) { id status timeStatus }' . "\n",
     632                    $key,
     633                    esc_attr( $event_id )
     634                );
     635            }
     636
     637            $result = $this->send_paginated_request( "query { $query }" );
     638
     639            if ( is_wp_error( $result ) || ! isset( $result ) ) {
     640                return $result;
     641            }
     642
     643            // Unwrap it.
     644            foreach ( $result as $id => $data ) {
     645                $return[ $keys[ $id ] ] = $data;
     646            }
     647        }
     648
     649        return $return;
     650    }
     651
     652    /**
    447653     * Retrieve details about a group.
    448654     *
    449655     * @param string $group_slug The slug/urlname of a group.
    450      * @param array  $args       Optional. Additional request parameters.
    451      *                           See https://www.meetup.com/meetup_api/docs/:urlname/#get
     656     * @param array  $args       Optional. 'fields' and 'event_fields' may be defined.
    452657     *
    453658     * @return array|WP_Error
    454659     */
    455660    public function get_group_details( $group_slug, $args = array() ) {
    456         $request_url = $this->api_base . sanitize_key( $group_slug );
    457 
    458         if ( ! empty( $args ) ) {
    459             $request_url = add_query_arg( $args, $request_url );
    460         }
    461 
    462         return $this->send_paginated_request( $request_url );
     661        $fields = $this->get_default_fields( 'group' );;
     662
     663        $events_fields = [
     664            'dateTime',
     665            'going',
     666        ];
     667
     668        if ( !empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
     669            $fields = array_merge( $fields, $args['fields'] );
     670        }
     671        if ( !empty( $args['events_fields'] ) && is_array( $args['events_fields'] ) ) {
     672            $events_fields = array_merge( $events_fields, $args['events_fields'] );
     673        } elseif ( !empty( $args['events_fields'] ) && true === $args['events_fields'] ) {
     674            $events_fields = array_merge( $events_fields, $this->get_default_fields( 'events' ) );
     675        }
     676
     677        // pastEvents cannot filter to the most recent past event, `last: 1`, `reverse:true, first: 1`, etc doesn't work.
     678        // Instead, we fetch the details for every past event instead.
     679
     680        $query = '
     681        query ( $urlname: String!, $perPage: Int!, $cursor: String ) {
     682            groupByUrlname( urlname: $urlname ) {
     683                ' . implode( ' ', $fields ) . '
     684                pastEvents ( input: { first: $perPage, after: $cursor } ) {
     685                    ' . $this->pageInfo . '
     686                    edges {
     687                        node {
     688                            ' . implode( ' ', $events_fields ) . '
     689                        }
     690                    }
     691                }
     692            }
     693        }';
     694        $variables = [
     695            'urlname' => $group_slug,
     696            'perPage' => 200,
     697            'cursor'  => null,
     698        ];
     699
     700        $result = $this->send_paginated_request( $query, $variables );
     701
     702        if ( is_wp_error( $result ) || ! isset( $result['groupByUrlname'] ) ) {
     703            return $result;
     704        }
     705
     706        // Format it similar to previous response payload??
     707        $result = $result['groupByUrlname'];
     708
     709        $result = $this->apply_backcompat_fields( 'group', $result );
     710
     711        return $result;
    463712    }
    464713
     
    467716     *
    468717     * @param string $group_slug The slug/urlname of a group.
    469      * @param array  $args       Optional. Additional request parameters.
    470      *                           See https://www.meetup.com/meetup_api/docs/:urlname/members/#list
     718     * @param array  $args       Optional. 'fields' and 'filters' may be defined.
    471719     *
    472720     * @return array|WP_Error
    473721     */
    474722    public function get_group_members( $group_slug, $args = array() ) {
    475         $request_url = $this->api_base . sanitize_key( $group_slug ) . '/members';
    476 
    477         if ( ! empty( $args ) ) {
    478             $request_url = add_query_arg( $args, $request_url );
    479         }
    480 
    481         return $this->send_paginated_request( $request_url );
     723        $fields = $this->get_default_fields( 'memberships' );
     724
     725        if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
     726            $fields = array_merge(
     727                $fields,
     728                $args['fields']
     729            );
     730        }
     731
     732        // Filters
     733        $filters = [];
     734        if ( isset( $args['role'] ) && 'leads' === $args['role'] ) {
     735            // See https://www.meetup.com/api/schema/#MembershipStatus for valid statuses.
     736            $filters[] = 'status: LEADER';
     737        }
     738
     739        if ( isset( $args['filters'] ) ) {
     740            foreach ( $args['filters'] as $key => $value ) {
     741                $filters[] = "{$key}: {$value}";
     742            }
     743        }
     744
     745        // 'memberships' => 'GroupUserConnection' not documented.
     746        $query = '
     747        query ( $urlname: String!, $perPage: Int!, $cursor: String ) {
     748            groupByUrlname( urlname: $urlname ) {
     749                memberships ( input: { first: $perPage, after: $cursor }, filter: { ' . implode( ', ', $filters ) . ' } ) {
     750                    ' . $this->pageInfo . '
     751                    edges {
     752                        node {
     753                            ' . implode( ' ', $fields ) . '
     754                        }
     755                    }
     756                }
     757            }
     758        }';
     759        $variables = [
     760            'urlname' => $group_slug,
     761            'perPage' => 200,
     762            'cursor'  => null,
     763        ];
     764
     765        $results = $this->send_paginated_request( $query, $variables );
     766        if ( is_wp_error( $results ) || ! isset( $results['groupByUrlname'] ) ) {
     767            return $results;
     768        }
     769
     770        // Select memberships.edges[*].node
     771        $results = array_column(
     772            $results['groupByUrlname']['memberships']['edges'],
     773            'node'
     774        );
     775
     776        return $results;
     777    }
     778
     779    /**
     780     * Query all events from the Network.
     781     */
     782    public function get_network_events( array $args = array() ) {
     783        $defaults = [
     784            'filters'        => [],
     785            'max_event_date' => time() + YEAR_IN_SECONDS,
     786            'min_event_date' => false,
     787            'online_events'  => null, // true: only online events, false: only IRL events
     788            'status'         => 'upcoming', //  UPCOMING, PAST, CANCELLED
     789            'sort'           => '',
     790        ];
     791        $args = wp_parse_args( $args, $defaults );
     792
     793        $fields = $this->get_default_fields( 'event' );
     794
     795        // See https://www.meetup.com/api/schema/#ProNetworkEventsFilter
     796        $filters = [];
     797
     798        if ( $args['min_event_date'] ) {
     799            $filters['eventDateMin'] = 'eventDateMin: ' . $this->datetime_to_time( $args['min_event_date'] ) * 1000;
     800        }
     801        if ( $args['max_event_date'] ) {
     802            $filters['eventDateMax'] = 'eventDateMax: ' . $this->datetime_to_time( $args['max_event_date'] ) * 1000;
     803        }
     804
     805        if ( ! is_null( $args['online_events'] ) ) {
     806            $filters['isOnlineEvent'] = 'isOnlineEvent: ' . ( $args['online_events'] ? 'true' : 'false' );
     807        }
     808
     809        // See https://www.meetup.com/api/schema/#ProNetworkEventStatus
     810        if ( $args['status'] && in_array( $args['status'], [ 'cancelled', 'upcoming', 'past' ] ) ) {
     811            $filters['status'] = 'status: ' . strtoupper( $args['status'] );
     812        }
     813
     814        if ( $args['filters'] ) {
     815            foreach( $args['filters'] as $key => $filter ) {
     816                $filters[ $key ] = "{$key}: {$filter}";
     817            }
     818        }
     819
     820        $query = '
     821        query ( $urlname: String!, $perPage: Int!, $cursor: String ) {
     822            proNetworkByUrlname( urlname: $urlname ) {
     823                eventsSearch ( input: { first: $perPage, after: $cursor }, filter: { ' . implode( ', ', $filters )  . ' } ) {
     824                    ' . $this->pageInfo . '
     825                    edges {
     826                        node {
     827                            ' . implode( ' ', $fields ) . '
     828                        }
     829                    }
     830                }
     831            }
     832        }';
     833        $variables = [
     834            'urlname' => 'wordpress',
     835            'perPage' => 1000, // More per-page to avoid hitting request limits
     836            'cursor'  => null,
     837        ];
     838
     839
     840        $results = $this->send_paginated_request( $query, $variables );
     841
     842        if ( is_wp_error( $results ) || ! array_key_exists( 'eventsSearch', $results['proNetworkByUrlname'] ) ) {
     843            return $results;
     844        }
     845
     846        if ( empty( $results['proNetworkByUrlname']['eventsSearch'] ) ) {
     847            return [];
     848        }
     849
     850        // Select edges[*].node
     851        $results = array_column(
     852            $results['proNetworkByUrlname']['eventsSearch']['edges'],
     853            'node'
     854        );
     855
     856        $results = $this->apply_backcompat_fields( 'events', $results );
     857
     858        return $results;
     859
    482860    }
    483861
     
    486864     *
    487865     * @param string $group_slug The slug/urlname of a group.
    488      * @param array  $args       Optional. Additional request parameters.
    489      *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/#list
     866     * @param array  $args       Optional. 'status', 'fields' and 'filters' may be defined.
    490867     *
    491868     * @return array|WP_Error
    492869     */
    493870    public function get_group_events( $group_slug, array $args = array() ) {
    494         $request_url = $this->api_base . sanitize_key( $group_slug ) . '/events';
    495 
    496         if ( ! empty( $args ) ) {
    497             $request_url = add_query_arg( $args, $request_url );
    498         }
    499 
    500         return $this->send_paginated_request( $request_url );
     871        $defaults = [
     872            'status'          => 'upcoming',
     873            'no_earlier_than' => '',
     874            'no_later_than'   => '',
     875            'fields'          => [],
     876        ];
     877        $args = wp_parse_args( $args, $defaults );
     878
     879        /*
     880         * The GraphQL API has 4 events fields, here's some comments:
     881         *  - upcomingEvents: Supports filtering via the 'GroupUpcomingEventsFilter', which allows for 'includeCancelled'.
     882         *  - pastEvents: No filters.
     883         *  - draftEvents: No Filters.
     884         *  - unifiedEvents: Supports Filtering via the undocumented 'GroupEventsFilter', does not support status/dates?
     885         *
     886         * Querying for multiple of these fields results in multiple paginated subkeys, complicating the requests, not
     887         * impossible but not within the spirit of this simplified query class, so we'll avoid requesting multiple paginated
     888         * fields.
     889         *
     890         * As a result of this, if the request is for multiple statuses, we're going to recursively call ourselves.. so that
     891         * we can query using the individual fields to get the statii we want, and apply the other filters directly.
     892         */
     893        if ( false !== strpos( $args['status'], ',' ) ) {
     894            $events = [];
     895            foreach ( explode( ',', $args['status'] ) as $status ) {
     896                $args['status'] = $status;
     897                $status_events  = $this->get_group_events( $group_slug, $args );
     898
     899                // If any individual API request fails, fail it all.
     900                if ( is_wp_error( $status_events ) ) {
     901                    return $status_events;
     902                }
     903
     904                $events = array_merge( $events, $status_events );
     905            }
     906
     907            // Resort all items.
     908            usort( $events, function( $a, $b ) {
     909                if ( $a['time'] == $b['time'] ) {
     910                    return 0;
     911                }
     912
     913                return ( $a['time'] < $b['time'] ) ? -1 : 1;
     914            } );
     915
     916            return $events;
     917        }
     918
     919        $fields = $this->get_default_fields( 'event' );
     920
     921        // TODO: Check the above list against Official_WordPress_Events::parse_meetup_events()
     922
     923        if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
     924            $fields = array_merge(
     925                $fields,
     926                $args['fields']
     927            );
     928        }
     929
     930        // The GraphQL field to query.
     931        switch ( $args['status'] ) {
     932            case 'upcoming':
     933            case 'past':
     934            case 'draft':
     935                $event_field = $args['status'] . 'Events';
     936                break;
     937            default:
     938                // We got nothing.
     939                return [];
     940        }
     941
     942        // No filters defined, as we have to do it ourselves. See above.
     943
     944        $query = '
     945        query ( $urlname: String!, $perPage: Int!, $cursor: String ) {
     946            groupByUrlname( urlname: $urlname ) {
     947                ' . $event_field . ' ( input: { first: $perPage, after: $cursor } ) {
     948                    ' . $this->pageInfo . '
     949                    edges {
     950                        node {
     951                            ' . implode( ' ', $fields ) . '
     952                        }
     953                    }
     954                }
     955            }
     956        }';
     957        $variables = [
     958            'urlname' => $group_slug,
     959            'perPage' => 200,
     960            'cursor'  => null,
     961        ];
     962
     963        $results = $this->send_paginated_request( $query, $variables );
     964        if ( is_wp_error( $results ) || ! isset( $results['groupByUrlname'] ) ) {
     965            return $results;
     966        }
     967
     968        // Select {$event_field}.edges[*].node
     969        $results = array_column(
     970            $results['groupByUrlname'][ $event_field ]['edges'],
     971            'node'
     972        );
     973
     974        $results = $this->apply_backcompat_fields( 'events', $results );
     975
     976        // Apply filters.
     977        if ( $args['no_earlier_than'] || $args['no_later_than'] ) {
     978            $args['no_earlier_than'] = $this->datetime_to_time( $args['no_earlier_than'] ) ?: 0;
     979            $args['no_later_than']   = $this->datetime_to_time( $args['no_later_than'] ) ?: PHP_INT_MAX;
     980
     981            $results = array_filter(
     982                $results,
     983                function( $event ) use( $args ) {
     984                    return
     985                        $event['time'] >= $args['no_earlier_than'] &&
     986                        $event['time'] < $args['no_later_than'];
     987                }
     988            );
     989        }
     990
     991        return $results;
    501992    }
    502993
     
    505996     *
    506997     * @param string $route The Meetup.com API route to send a request to.
    507      * @param array  $args  Optional. Additional request parameters.
    508      *                      See https://www.meetup.com/meetup_api/docs/.
     998     * @param array  $args  Optional.  'pro_join_date_max', 'pro_join_date_min', and 'filters' may be defined.
    509999     *
    5101000     * @return int|WP_Error
    5111001     */
    5121002    public function get_result_count( $route, array $args = array() ) {
    513         $request_url = $this->api_base . $route;
    514 
    515         if ( ! empty( $args ) ) {
    516             $request_url = add_query_arg( $args, $request_url );
    517         }
    518 
    519         return $this->send_total_count_request( $request_url );
     1003        $result  = false;
     1004        $filters = [];
     1005
     1006        // Number of groups in the Pro Network.
     1007        if ( 'pro/wordpress/groups' !== $route ) {
     1008            return false;
     1009        }
     1010
     1011        // https://www.meetup.com/api/schema/#GroupAnalyticsFilter
     1012        if ( ! empty( $args['pro_join_date_max'] ) ) {
     1013            $filters['proJoinDateMax'] = 'proJoinDateMax: ' . $this->datetime_to_time( $args['pro_join_date_max'] ) * 1000;
     1014        }
     1015        if ( ! empty( $args['pro_join_date_min'] ) ) {
     1016            $filters['proJoinDateMin'] = 'proJoinDateMin: ' . $this->datetime_to_time( $args['pro_join_date_min'] ) * 1000;
     1017        }
     1018
     1019        if ( isset( $args['filters'] ) ) {
     1020            foreach ( $args['filters'] as $key => $value ) {
     1021                $filters[ $key ] = "{$key}: {$value}";
     1022            }
     1023        }
     1024
     1025        $query = '
     1026        query {
     1027            proNetworkByUrlname( urlname: "wordpress" ) {
     1028                groupsSearch( filter: { ' .  implode( ', ', $filters ) . ' } ) {
     1029                    count
     1030                }
     1031            }
     1032        }';
     1033
     1034        $results = $this->send_paginated_request( $query );
     1035        if ( is_wp_error( $results ) ) {
     1036            return $results;
     1037        }
     1038
     1039        return (int) $results['proNetworkByUrlname']['groupsSearch']['count'];
     1040    }
     1041
     1042    /**
     1043     * Get the default fields for each object type.
     1044     *
     1045     * @param string $type The Object type.
     1046     * @return array Fields to query.
     1047     */
     1048    protected function get_default_fields( $type ) {
     1049        if ( 'event' === $type ) {
     1050            // See https://www.meetup.com/api/schema/#Event for valid fields.
     1051            return [
     1052                'id',
     1053                'title',
     1054                'description',
     1055                'eventUrl',
     1056                'status',
     1057                'timeStatus',
     1058                'dateTime',
     1059                'timezone',
     1060                'endTime',
     1061                'createdAt',
     1062                'isOnline',
     1063                'going',
     1064                'group {
     1065                    ' . implode( ' ', $this->get_default_fields( 'group' ) ) . '
     1066                }',
     1067                'venue {
     1068                    id
     1069                    lat
     1070                    lng
     1071                    name
     1072                    city
     1073                    state
     1074                    country
     1075                }'
     1076            ];
     1077        } elseif ( 'memberships' === $type ) {
     1078            // See https://www.meetup.com/api/schema/#User for valid fields.
     1079            return [
     1080                'id',
     1081                'name',
     1082                'email',
     1083            ];
     1084        } elseif ( 'group' === $type ) {
     1085            return [
     1086                'id',
     1087                'name',
     1088                'urlname',
     1089                'link',
     1090                'city',
     1091                'state',
     1092                'country',
     1093                'groupAnalytics {
     1094                    totalPastEvents,
     1095                    totalMembers,
     1096                    lastEventDate,
     1097                }',
     1098                'foundedDate',
     1099                'proJoinDate',
     1100                'latitude',
     1101                'longitude',
     1102            ];
     1103        }
     1104    }
     1105
     1106    /**
     1107     * Apply back-compat fields/filters for previous uses of the client.
     1108     *
     1109     * Can be removed once all uses of the library have migrated over.
     1110     *
     1111     * @param string $type   The type of result object.
     1112     * @param array  $result The result to back-compat.
     1113     * @return The $result with back-compat.
     1114     */
     1115    protected function apply_backcompat_fields( $type, $result ) {
     1116        if ( 'event' === $type ) {
     1117
     1118            $result['name'] = $result['title'];
     1119
     1120            if ( ! empty( $result['dateTime'] ) ) {
     1121                // Required for utc_offset below.
     1122                $result['time'] = $this->datetime_to_time( $result['dateTime'] ) * 1000;
     1123            }
     1124
     1125            // Easier to parse the difference between start and end, than parse the ISO 'duration' the API provides for now.
     1126            $result['duration'] = 0;
     1127            if ( ! empty( $result['endTime'] ) ) {
     1128                $result['duration'] = ( $this->datetime_to_time( $result['endTime'] ) -  $this->datetime_to_time( $result['dateTime'] ) );
     1129                $result['duration'] *= 1000;
     1130            }
     1131
     1132            $result['utc_offset'] = 0;
     1133            if ( ! empty( $result['timezone'] ) && isset( $result['time'] ) ) {
     1134                $result['utc_offset'] = (
     1135                    new DateTime(
     1136                        // $result['time'] is back-compat above.
     1137                        gmdate( 'Y-m-d H:i:s', $result['time']/1000 ),
     1138                        new DateTimeZone( $result['timezone'] )
     1139                    )
     1140                )->getOffset();
     1141                $result['utc_offset'] *= 1000;
     1142            }
     1143
     1144            if ( ! empty( $result['venue'] ) ) {
     1145                if ( is_numeric( $result['venue']['id'] ) ) {
     1146                    $result['venue']['id'] = (int) $result['venue']['id'];
     1147                }
     1148
     1149                $result['venue']['localized_location']     = $this->localise_location( $result['venue'] );
     1150                $result['venue']['localized_country_name'] = $this->localised_country_name( $result['venue']['country'] );
     1151
     1152                // For online events, disregard the Venue lat/lon. It's not correct. In back-compat methods to allow for BC for existing uses of the class.
     1153                if ( ! empty( $result['venue']['lng'] ) && self::ONLINE_VENUE_ID == $result['venue']['id'] ) {
     1154                    $result['venue']['lat'] = '';
     1155                    $result['venue']['lon'] = '';
     1156                }
     1157
     1158                // Seriously.
     1159                if ( ! empty( $result['venue']['lng'] ) ) {
     1160                    $result['venue']['lon'] = $result['venue']['lng'];
     1161                }
     1162            }
     1163
     1164            if ( ! empty( $result['group'] ) ) {
     1165                $result['group'] = $this->apply_backcompat_fields( 'group', $result['group'] );
     1166            }
     1167
     1168            $result['status'] = strtolower( $result['status'] );
     1169            if ( in_array( $result['status'], [ 'published', 'past', 'active', 'autosched' ] ) ) {
     1170                $result['status'] = 'upcoming'; // Right, past is upcoming in this context
     1171            }
     1172
     1173            $result['yes_rsvp_count'] = $result['going'];
     1174            $result['link']           = $result['eventUrl'];
     1175        }
     1176
     1177        if ( 'events' === $type ) {
     1178            foreach ( $result as &$event ) {
     1179                $event = $this->apply_backcompat_fields( 'event', $event );
     1180            }
     1181        }
     1182
     1183        if ( 'group' === $type ) {
     1184            // Stub in the fields that are different.
     1185            $result['created']                = $this->datetime_to_time( $result['foundedDate'] ) * 1000;
     1186            $result['localized_location']     = $this->localise_location( $result );
     1187            $result['localized_country_name'] = $this->localised_country_name( $result['country'] );
     1188            $result['members']                = $result['groupAnalytics']['totalMembers'] ?? 0;
     1189            $result['member_count']           = $result['members'];
     1190
     1191            if ( ! empty( $result['proJoinDate'] ) ) {
     1192                $result['pro_join_date'] = $this->datetime_to_time( $result['proJoinDate'] ) * 1000;
     1193            }
     1194
     1195            if ( ! empty( $result['pastEvents']['edges'] ) ) {
     1196                $result['last_event']         = [
     1197                    'time'           => $this->datetime_to_time( end( $result['pastEvents']['edges'] )['node']['dateTime'] ) * 1000,
     1198                    'yes_rsvp_count' => end( $result['pastEvents']['edges'] )['node']['going'],
     1199                ];
     1200            } elseif ( ! empty( $result['groupAnalytics']['lastEventDate'] ) ) {
     1201                $result['last_event'] = $this->datetime_to_time( $result['groupAnalytics']['lastEventDate'] ) * 1000;
     1202            }
     1203
     1204            $result['lat'] = $result['latitude'];
     1205            $result['lon'] = $result['longitude'];
     1206        }
     1207        if ( 'groups' === $type ) {
     1208            foreach ( $result as &$group ) {
     1209                $group = $this->apply_backcompat_fields( 'group', $group );
     1210            }
     1211        }
     1212
     1213        return $result;
     1214    }
     1215
     1216    /**
     1217     * Generate a localised location name.
     1218     *
     1219     * For the US this is 'City, ST, USA'
     1220     * For Canada this is 'City, ST, Canada'
     1221     * For the rest of world, this is 'City, CountryName'
     1222     */
     1223    protected function localise_location( $args = array() ) {
     1224        // Hard-code the Online event location
     1225        if ( ! empty( $args['id'] ) && self::ONLINE_VENUE_ID == $args['id'] ) {
     1226            return 'online';
     1227        }
     1228
     1229        $country = $args['country'] ?? '';
     1230        $state   = $args['state']   ?? '';
     1231        $city    = $args['city']    ?? '';
     1232        $country = strtoupper( $country );
     1233
     1234        // Only the USA & Canada have valid states in the response. Others have states, but are incorrect.
     1235        if ( 'US' === $country || 'CA' === $country ) {
     1236            $state = strtoupper( $state );
     1237        } else {
     1238            $state = '';
     1239        }
     1240
     1241        // Set countries to USA, AU, or Australia in that order.
     1242        $country = $this->localised_country_name( $country );
     1243
     1244        return implode( ', ',  array_filter( [ $city, $state, $country ] ) ) ?: false;
     1245    }
     1246
     1247    /**
     1248     * Localise a country code to a country name using WP-CLDR if present.
     1249     *
     1250     * @param string $country Country Code.
     1251     * @return Country Name, or country code upon failure.
     1252     */
     1253    public function localised_country_name( $country ) {
     1254        $localised_country = '';
     1255        $country           = strtoupper( $country );
     1256
     1257        // Shortcut, CLDR isn't always what we expect here.
     1258        $shortcut = [
     1259            'US' => 'USA',
     1260            'HK' => 'Hong Kong',
     1261            'SG' => 'Singapore',
     1262        ];
     1263        if ( ! empty( $shortcut[ $country ] ) ) {
     1264            return $shortcut[ $country ];
     1265        }
     1266
     1267        if ( ! class_exists( '\WP_CLDR' ) && file_exists( WP_PLUGIN_DIR . '/wp-cldr/class-wp-cldr.php' ) ) {
     1268            require WP_PLUGIN_DIR . '/wp-cldr/class-wp-cldr.php';
     1269        }
     1270
     1271        if ( class_exists( '\WP_CLDR' ) ) {
     1272            $cldr = new \WP_CLDR();
     1273
     1274            $localised_country = $cldr->get_territory_name( $country );
     1275        }
     1276
     1277        return $localised_country ?: $country;
    5201278    }
    5211279}
Note: See TracChangeset for help on using the changeset viewer.