Changeset 11393 for sites/trunk/wordpress.org/public_html/wp-content/plugins/official-wordpress-events/meetup/class-meetup-client.php
- Timestamp:
- 12/22/2021 02:08:56 AM (4 years ago)
- 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 2 2 namespace WordCamp\Utilities; 3 3 4 use DateTime, DateTimeZone; 4 5 use WP_Error; 5 6 … … 20 21 */ 21 22 class 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 }'; 26 38 27 39 /** … … 59 71 */ 60 72 'breaking_response_codes' => array( 73 // TODO: NOTE: These headers are not returned from the GraphQL API, every request is 200 even if throttled. 61 74 401, // Unauthorized (invalid key). 62 75 429, // Too many requests (rate-limited). 63 76 404, // Unable to find group 77 78 503, // Timeout between API cache & GraphQL Server. 64 79 ), 80 // NOTE: GraphQL does not expose the Quota Headers. 65 81 'throttle_callback' => array( __CLASS__, 'throttle' ), 66 82 ) ); … … 123 139 * 124 140 * 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. 126 142 * 127 143 * @param string $request_url The API endpoint URL to send the request to. 144 * @param array $variables The Query variables used in the query. 128 145 * 129 146 * @return array|WP_Error The results of the request. 130 147 */ 131 p rotected function send_paginated_request( $request_url ) {148 public function send_paginated_request( $query, $variables = null ) { 132 149 $data = array(); 133 150 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 ); 164 162 break; 165 163 } 166 164 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 ) { 168 219 if ( 'cli' === php_sapi_name() ) { 169 echo "\nDebug mode: Skipping future paginated requests to $request_url";220 echo "\nDebug mode: Skipping future paginated requests"; 170 221 } 171 222 172 223 break; 173 224 } 174 } 225 } while ( $has_next_page ); 175 226 176 227 if ( ! empty( $this->error->get_error_messages() ) ) { … … 178 229 } 179 230 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 ); 205 248 } 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; 220 254 } 221 255 … … 225 259 * @return array 226 260 */ 227 protected function get_request_args( ) {261 protected function get_request_args( $query, $variables = null ) { 228 262 $oauth_token = $this->oauth_client->get_oauth_token(); 229 263 … … 232 266 } 233 267 268 if ( is_array( $variables ) ) { 269 $variables = wp_json_encode( $variables ); 270 } 271 234 272 return array( 273 'timeout' => 60, 235 274 'headers' => array( 236 275 'Accept' => 'application/json', 276 'Content-Type' => 'application/json', 237 277 'Authorization' => "Bearer $oauth_token", 238 278 ), 279 'body' => wp_json_encode( compact( 'query', 'variables' ) ) 239 280 ); 240 }241 242 /**243 * Get the URL for the next page of results from a paginated API response.244 *245 * @param array $response246 *247 * @return string248 */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 headers256 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 );277 281 } 278 282 … … 286 290 protected static function throttle( $response ) { 287 291 $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 */ 288 297 289 298 if ( ! isset( $headers['x-ratelimit-remaining'], $headers['x-ratelimit-reset'] ) ) { … … 315 324 316 325 /** 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 /** 317 385 * Extract error information from an API response and add it to our error handler. 318 386 * … … 360 428 foreach ( $data['errors'] as $details ) { 361 429 $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? 364 433 ); 365 434 } … … 387 456 * Retrieve data about groups in the Chapter program. 388 457 * 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. 391 459 * 392 460 * @return array|WP_Error 393 461 */ 394 462 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; 402 528 } 403 529 … … 418 544 * 419 545 * @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. 422 547 * 423 548 * @return array|WP_Error … … 425 550 public function get_events( array $group_slugs, array $args = array() ) { 426 551 $events = array(); 552 553 // See get_network_events(), which should be preferred for most cases. 554 // This is kept for back-compat. 427 555 428 556 if ( $this->debug ) { … … 445 573 446 574 /** 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 /** 447 653 * Retrieve details about a group. 448 654 * 449 655 * @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. 452 657 * 453 658 * @return array|WP_Error 454 659 */ 455 660 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; 463 712 } 464 713 … … 467 716 * 468 717 * @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. 471 719 * 472 720 * @return array|WP_Error 473 721 */ 474 722 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 482 860 } 483 861 … … 486 864 * 487 865 * @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. 490 867 * 491 868 * @return array|WP_Error 492 869 */ 493 870 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; 501 992 } 502 993 … … 505 996 * 506 997 * @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. 509 999 * 510 1000 * @return int|WP_Error 511 1001 */ 512 1002 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; 520 1278 } 521 1279 }
Note: See TracChangeset
for help on using the changeset viewer.