Dashboard: Update the existing WordPress News dashboard widget to also include upcoming meetup events and WordCamps near the current user’s location.

Props @afercia, @andreamiddleton, @azaozz, @camikaos, @coreymckrill, @chanthaboune, @courtneypk, @dd32, @iandunn, @iseulde, @mapk, @mayukojpn, @melchoyce, @nao, @obenland, @pento, @samuelsidler, @stephdau, @tellyworth.
See #40702.

git-svn-id: https://develop.svn.wordpress.org/trunk@40607 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Andrew Ozz 2017-05-10 20:03:01 +00:00
parent 8712f5dcb4
commit be2a26ab08
12 changed files with 1474 additions and 109 deletions

View File

@ -64,7 +64,7 @@ $core_actions_post = array(
'parse-media-shortcode', 'destroy-sessions', 'install-plugin', 'update-plugin', 'press-this-save-post',
'press-this-add-category', 'crop-image', 'generate-password', 'save-wporg-username', 'delete-plugin',
'search-plugins', 'search-install-plugins', 'activate-plugin', 'update-theme', 'delete-theme',
'install-theme', 'get-post-thumbnail-html',
'install-theme', 'get-post-thumbnail-html', 'get-community-events',
);
// Deprecated

View File

@ -301,6 +301,145 @@
content: "\f153";
}
/* Dashboard WordPress events */
.community-events-errors {
margin: 0;
}
.community-events-loading {
padding: 10px 12px 8px;
}
.community-events {
margin-bottom: 6px;
padding: 0 12px;
}
.community-events .spinner {
float: none;
margin: 0;
padding-bottom: 3px;
}
.community-events-errors[aria-hidden="true"],
.community-events-errors *[aria-hidden="true"],
.community-events-loading[aria-hidden="true"],
.community-events[aria-hidden="true"],
.community-events *[aria-hidden="true"] {
display: none;
}
.community-events .activity-block:first-child,
.community-events h2 {
padding-top: 12px;
padding-bottom: 10px;
}
.community-events-form {
margin: 15px 0 5px;
}
.community-events-form .regular-text {
width: 40%;
height: 28px;
}
.community-events li.event-none {
border-left: 4px solid #0070AE;
}
.community-events-form label {
display: inline-block;
padding-bottom: 3px;
}
.community-events .activity-block > p {
margin-bottom: 0;
display: inline;
}
#community-events-submit {
margin-left: 2px;
}
.community-events .button-link:hover,
.community-events .button-link:active {
color: #00a0d2;
}
.community-events-cancel.button.button-link {
color: #0073aa;
text-decoration: underline;
margin-left: 2px;
}
.community-events ul {
background-color: #fafafa;
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
}
.community-events li {
margin: 0;
padding: 8px 12px;
color: #72777c;
}
.community-events li:first-child {
border-top: 1px solid #eee;
}
.community-events li ~ li {
border-top: 1px solid #eee;
}
.community-events .activity-block.last {
border-bottom: 1px solid #eee;
padding-top: 0;
margin-top: -1px;
}
.community-events .event-info {
display: block;
}
.event-icon {
height: 18px;
padding-right: 10px;
width: 18px;
display: none; /* Hide on smaller screens */
}
.event-icon:before {
color: #82878C;
font-size: 18px;
}
.event-meetup .event-icon:before {
content: "\f484";
}
.event-wordcamp .event-icon:before {
content: "\f486";
}
.community-events .event-title {
font-weight: 600;
display: block;
}
.community-events .event-date,
.community-events .event-time {
display: block;
}
.community-events-footer {
margin-top: 0;
margin-bottom: 0;
padding: 12px;
border-top: 1px solid #eee;
color: #ddd;
}
/* Dashboard WordPress news */
#dashboard_primary .inside {
@ -333,9 +472,8 @@ body #dashboard-widgets .postbox form .submit {
}
#dashboard_primary .rss-widget {
border-bottom: 1px solid #eee;
font-size: 13px;
padding: 8px 12px 10px;
padding: 0 12px 0;
}
#dashboard_primary .rss-widget:last-child {
@ -357,7 +495,8 @@ body #dashboard-widgets .postbox form .submit {
}
#dashboard_primary .rss-widget ul li {
margin-bottom: 8px;
padding: 4px 0;
margin: 0;
}
/* Dashboard right now */
@ -874,9 +1013,9 @@ form.initial-form.quickpress-open input#title {
}
a.rsswidget {
font-size: 14px;
font-size: 13px;
font-weight: 600;
line-height: 1.7em;
line-height: 1.4em;
}
.rss-widget ul li {
@ -1087,6 +1226,14 @@ a.rsswidget {
width: 30px;
margin: 4px 10px 5px 0;
}
.community-events-toggle-location {
height: 38px;
}
.community-events-form .regular-text {
height: 31px;
}
}
/* Smartphone */
@ -1110,3 +1257,30 @@ a.rsswidget {
left: -35px;
}
}
@media screen and (min-width: 355px) {
.community-events .event-info {
display: table-row;
float: left;
max-width: 59%;
}
.event-icon,
.event-icon[aria-hidden="true"] {
display: table-cell;
}
.event-info-inner {
display: table-cell;
}
.community-events .event-date-time {
float: right;
max-width: 39%;
}
.community-events .event-date,
.community-events .event-time {
text-align: right;
}
}

View File

@ -296,6 +296,40 @@ function wp_ajax_autocomplete_user() {
wp_die( wp_json_encode( $return ) );
}
/**
* Handles AJAX requests for community events
*
* @since 4.8.0
*/
function wp_ajax_get_community_events() {
require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
check_ajax_referer( 'community_events' );
$search = isset( $_POST['location'] ) ? wp_unslash( $_POST['location'] ) : '';
$timezone = isset( $_POST['timezone'] ) ? wp_unslash( $_POST['timezone'] ) : '';
$user_id = get_current_user_id();
$saved_location = get_user_option( 'community-events-location', $user_id );
$events_client = new WP_Community_Events( $user_id, $saved_location );
$events = $events_client->get_events( $search, $timezone );
if ( is_wp_error( $events ) ) {
wp_send_json_error( array(
'error' => $events->get_error_message(),
) );
} else {
if ( isset( $events['location'] ) ) {
// Send only the data that the client will use.
$events['location'] = $events['location']['description'];
// Store the location network-wide, so the user doesn't have to set it on each site.
update_user_option( $user_id, 'community-events-location', $events['location'], true );
}
wp_send_json_success( $events );
}
}
/**
* Ajax handler for dashboard widgets.
*

View File

@ -0,0 +1,419 @@
<?php
/**
* Administration: Community Events class.
*
* @package WordPress
* @subpackage Administration
* @since 4.8.0
*/
/**
* Class WP_Community_Events.
*
* A client for api.wordpress.org/events.
*
* @since 4.8.0
*/
class WP_Community_Events {
/**
* ID for a WordPress user account.
*
* @access protected
* @since 4.8.0
*
* @var int
*/
protected $user_id = 0;
/**
* Stores location data for the user.
*
* @access protected
* @since 4.8.0
*
* @var bool|array
*/
protected $user_location = false;
/**
* Constructor for WP_Community_Events.
*
* @since 4.8.0
*
* @param int $user_id WP user ID.
* @param bool|array $user_location Stored location data for the user.
* false to pass no location;
* array to pass a location {
* @type string $description The name of the location
* @type string $latitude The latitude in decimal degrees notation, without the degree
* symbol. e.g.: 47.615200.
* @type string $longitude The longitude in decimal degrees notation, without the degree
* symbol. e.g.: -122.341100.
* @type string $country The ISO 3166-1 alpha-2 country code. e.g.: BR
* }
*/
public function __construct( $user_id, $user_location = false ) {
$this->user_id = absint( $user_id );
$this->user_location = $user_location;
}
/**
* Gets data about events near a particular location.
*
* Cached events will be immediately returned if the `user_location` property
* is set for the current user, and cached events exist for that location.
*
* Otherwise, this method sends a request to the w.org Events API with location
* data. The API will send back a recognized location based on the data, along
* with nearby events.
*
* @since 4.8.0
*
* @param string $location_search Optional city name to help determine the location.
* e.g., "Seattle". Default empty string.
* @param string $timezone Optional timezone to help determine the location.
* Default empty string.
* @return array|WP_Error A WP_Error on failure; an array with location and events on
* success.
*/
public function get_events( $location_search = '', $timezone = '' ) {
$cached_events = $this->get_cached_events();
if ( ! $location_search && $cached_events ) {
return $cached_events;
}
$request_url = $this->get_request_url( $location_search, $timezone );
$response = wp_remote_get( $request_url );
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
$response_error = null;
$debugging_info = compact( 'request_url', 'response_code', 'response_body' );
if ( is_wp_error( $response ) ) {
$response_error = $response;
} elseif ( 200 !== $response_code ) {
$response_error = new WP_Error(
'api-error',
/* translators: %s is a numeric HTTP status code; e.g., 400, 403, 500, 504, etc. */
sprintf( __( 'Invalid API response code (%d)' ), $response_code )
);
} elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
$response_error = new WP_Error(
'api-invalid-response',
isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
);
}
if ( is_wp_error( $response_error ) ) {
$this->maybe_log_events_response( $response_error->get_error_message(), $debugging_info );
return $response_error;
} else {
$expiration = false;
if ( isset( $response_body['ttl'] ) ) {
$expiration = $response_body['ttl'];
unset( $response_body['ttl'] );
}
$this->cache_events( $response_body, $expiration );
$response_body = $this->trim_events( $response_body );
$response_body = $this->format_event_data_time( $response_body );
// Avoid bloating the log with all the event data, but keep the count.
$debugging_info['response_body']['events'] = count( $debugging_info['response_body']['events'] ) . ' events trimmed.';
$this->maybe_log_events_response( 'Valid response received', $debugging_info );
return $response_body;
}
}
/**
* Builds a URL for requests to the w.org Events API.
*
* @access protected
* @since 4.8.0
*
* @param string $search City search string. Default empty string.
* @param string $timezone Timezone string. Default empty string.
* @return string The request URL.
*/
protected function get_request_url( $search = '', $timezone = '' ) {
$api_url = 'https://api.wordpress.org/events/1.0/';
$args = array( 'number' => 5 ); // Get more than three in case some get trimmed out.
/*
* Send the minimal set of necessary arguments, in order to increase the
* chances of a cache-hit on the API side.
*/
if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
$args['latitude'] = $this->user_location['latitude'];
$args['longitude'] = $this->user_location['longitude'];
} else {
$args['locale'] = get_user_locale( $this->user_id );
if ( $timezone ) {
$args['timezone'] = $timezone;
}
if ( $search ) {
$args['location'] = $search;
} else {
/*
* Protect the user's privacy by anonymizing their IP before sending
* it to w.org, and only send it when necessary.
*
* The w.org API endpoint only uses the IP address when a location
* query is not provided, so we can safely avoid sending it when
* there is a query.
*/
$args['ip'] = $this->maybe_anonymize_ip_address( $this->get_unsafe_client_ip() );
}
}
return add_query_arg( $args, $api_url );
}
/**
* Determines the user's actual IP address, if possible.
*
* $_SERVER['REMOTE_ADDR'] cannot be used in all cases, such as when the user
* is making their request through a proxy, or when the web server is behind
* a proxy. In those cases, $_SERVER['REMOTE_ADDR'] is set to the proxy address rather
* than the user's actual address.
*
* Modified from http://stackoverflow.com/a/2031935/450127, MIT license.
*
* SECURITY WARNING: This function is _NOT_ intended to be used in
* circumstances where the authenticity of the IP address matters. This does
* _NOT_ guarantee that the returned address is valid or accurate, and it can
* be easily spoofed.
*
* @access protected
* @since 4.8.0
*
* @return false|string false on failure, the string address on success.
*/
protected function get_unsafe_client_ip() {
$client_ip = false;
// In order of preference, with the best ones for this purpose first.
$address_headers = array(
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
);
foreach ( $address_headers as $header ) {
if ( array_key_exists( $header, $_SERVER ) ) {
/*
* HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
* addresses. The first one is the original client. It can't be
* trusted for authenticity, but we don't need to for this purpose.
*/
$address_chain = explode( ',', $_SERVER[ $header ] );
$client_ip = trim( $address_chain[0] );
break;
}
}
return $client_ip;
}
/**
* Attempts to partially anonymize an IP address by converting it to a network ID.
*
* Geolocating the network ID usually returns a similar location as the
* actual IP, but provides some privacy for the user.
*
* Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
*
* @access protected
* @since 4.8.0
*
* @param string $address The IP address that should be anonymized.
* @return bool|string The anonymized address on success; the given address
* or false on failure.
*/
protected function maybe_anonymize_ip_address( $address ) {
// These functions are not available on Windows until PHP 5.3.
if ( ! function_exists( 'inet_pton' ) || ! function_exists( 'inet_ntop' ) ) {
return $address;
}
if ( 4 === strlen( inet_pton( $address ) ) ) {
$netmask = '255.255.255.0'; // ipv4.
} else {
$netmask = 'ffff:ffff:ffff:ffff:0000:0000:0000:0000'; // ipv6.
}
return inet_ntop( inet_pton( $address ) & inet_pton( $netmask ) );
}
/**
* Generates a transient key based on user location.
*
* This could be reduced to a one-liner in the calling functions, but it's
* intentionally a separate function because it's called from multiple
* functions, and having it abstracted keeps the logic consistent and DRY,
* which is less prone to errors.
*
* @access protected
* @since 4.8.0
*
* @param array $location Should contain 'latitude' and 'longitude' indexes.
* @return bool|string false on failure, or a string on success.
*/
protected function get_events_transient_key( $location ) {
$key = false;
if ( isset( $location['latitude'], $location['longitude'] ) ) {
$key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
}
return $key;
}
/**
* Caches an array of events data from the Events API.
*
* @access protected
* @since 4.8.0
*
* @param array $events Response body from the API request.
* @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
* @return bool true if events were cached; false if not.
*/
protected function cache_events( $events, $expiration = false ) {
$set = false;
$transient_key = $this->get_events_transient_key( $events['location'] );
$cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
if ( $transient_key ) {
$set = set_site_transient( $transient_key, $events, $cache_expiration );
}
return $set;
}
/**
* Gets cached events.
*
* @since 4.8.0
*
* @return false|array false on failure; an array containing `location`
* and `events` items on success.
*/
public function get_cached_events() {
$cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
$cached_response = $this->trim_events( $cached_response );
return $this->format_event_data_time( $cached_response );
}
/**
* Adds formatted date and time items for each event in an API response.
*
* This has to be called after the data is pulled from the cache, because
* the cached events are shared by all users. If it was called before storing
* the cache, then all users would see the events in the localized data/time
* of the user who triggered the cache refresh, rather than their own.
*
* @access protected
* @since 4.8.0
*
* @param array $response_body The response which contains the events.
* @return array The response with dates and times formatted.
*/
protected function format_event_data_time( $response_body ) {
if ( isset( $response_body['events'] ) ) {
foreach ( $response_body['events'] as $key => $event ) {
$timestamp = strtotime( $event['date'] );
/*
* The `date_format` option is not used because it's important
* in this context to keep the day of the week in the formatted date,
* so that users can tell at a glance if the event is on a day they
* are available, without having to open the link.
*/
/* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date. */
$response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
$response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
}
}
return $response_body;
}
/**
* Discards expired events, and reduces the remaining list.
*
* @access protected
* @since 4.8.0
*
* @param array $response_body The response body which contains the events.
* @return array The response body with events trimmed.
*/
protected function trim_events( $response_body ) {
if ( isset( $response_body['events'] ) ) {
$current_timestamp = current_time('timestamp' );
foreach ( $response_body['events'] as $key => $event ) {
// Skip WordCamps, because they might be multi-day events.
if ( 'meetup' !== $event['type'] ) {
continue;
}
$event_timestamp = strtotime( $event['date'] );
if ( $current_timestamp > $event_timestamp && ( $current_timestamp - $event_timestamp ) > DAY_IN_SECONDS ) {
unset( $response_body['events'][ $key ] );
}
}
$response_body['events'] = array_slice( $response_body['events'], 0, 3 );
}
return $response_body;
}
/**
* Logs responses to Events API requests.
*
* All responses are logged when debugging, even if they're not WP_Errors.
* Debugging info is still needed for "successful" responses, because
* the API might have returned a different location than the one the user
* intended to receive. In those cases, knowing the exact `request_url` is
* critical.
*
* Errors are logged instead of being triggered, to avoid breaking the JSON
* response when called from AJAX handlers and `display_errors` is enabled.
*
* @access protected
* @since 4.8.0
*
* @param string $message A description of what occurred.
* @param array $debugging_info Details that provide more context for the
* log entry.
*/
protected function maybe_log_events_response( $message, $details ) {
if ( ! WP_DEBUG_LOG ) {
return;
}
error_log( sprintf(
'%s: %s. Details: %s',
__METHOD__,
trim( $message, '.' ),
wp_json_encode( $details )
) );
}
}

View File

@ -52,8 +52,8 @@ function wp_dashboard_setup() {
wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' );
}
// WordPress News
wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress News' ), 'wp_dashboard_primary' );
// WordPress Events and News
wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
if ( is_network_admin() ) {
@ -129,6 +129,46 @@ function wp_dashboard_setup() {
do_action( 'do_meta_boxes', $screen->id, 'side', '' );
}
/**
* Gets the community events data that needs to be passed to dashboard.js.
*
* @since 4.8.0
*
* @return array The script data.
*/
function wp_get_community_events_script_data() {
require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
$user_id = get_current_user_id();
$user_location = get_user_option( 'community-events-location', $user_id );
$events_client = new WP_Community_Events( $user_id, $user_location );
$script_data = array(
'nonce' => wp_create_nonce( 'community_events' ),
'cache' => $events_client->get_cached_events(),
'l10n' => array(
'enter_closest_city' => __( 'Enter your closest city to find nearby events.' ),
'error_occurred_please_try_again' => __( 'An error occured. Please try again.' ),
/*
* These specific examples were chosen to highlight the fact that a
* state is not needed, even for cities whose name is not unique.
* It would be too cumbersome to include that in the instructions
* to the user, so it's left as an implication.
*/
/* translators: %s is the name of the city we couldn't locate. Replace the examples with cities in your locale, but test that they match the expected location before including them. Use endonyms (native locale names) whenever possible. */
'could_not_locate_city' => __( "We couldn't locate %s. Please try another nearby city. For example: Kansas City; Springfield; Portland." ),
// This one is only used with wp.a11y.speak(), so it can/should be more brief.
/* translators: %s is the name of a city. */
'city_updated' => __( 'City updated. Listing events near %s.' ),
)
);
return $script_data;
}
/**
* Adds a new dashboard widget.
*
@ -1069,10 +1109,173 @@ function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) {
wp_widget_rss_form( $widget_options[$widget_id], $form_inputs );
}
/**
* Renders the Events and News dashboard widget.
*
* @since 4.8.0
*/
function wp_dashboard_events_news() {
wp_print_community_events_markup();
?>
<div class="wordpress-news hide-if-no-js">
<?php wp_dashboard_primary(); ?>
</div>
<p class="community-events-footer">
<a href="https://make.wordpress.org/community/meetups-landing-page" target="_blank">
<?php _e( 'Meetups' ); ?> <span class="dashicons dashicons-external"></span>
</a>
|
<a href="https://central.wordcamp.org/schedule/" target="_blank">
<?php _e( 'WordCamps' ); ?> <span class="dashicons dashicons-external"></span>
</a>
|
<?php // translators: If a Rosetta site exists (e.g. https://es.wordpress.org/news/), then use that. Otherwise, leave untranslated. ?>
<a href="<?php _e( 'https://wordpress.org/news/' ); ?>" target="_blank">
<?php _e( 'News' ); ?> <span class="dashicons dashicons-external"></span>
</a>
</p>
<?php
}
/**
* Prints the markup for the Community Events section of the Events and News Dashboard widget.
*
* @since 4.8.0
*/
function wp_print_community_events_markup() {
$script_data = wp_get_community_events_script_data();
?>
<div class="community-events-errors notice notice-error inline hide-if-js">
<p class="hide-if-js">
<?php _e( 'This widget requires JavaScript.'); ?>
</p>
<p class="community-events-error-occurred" aria-hidden="true">
<?php echo $script_data['l10n']['error_occurred_please_try_again']; ?>
</p>
<p class="community-events-could-not-locate" aria-hidden="true"></p>
</div>
<div class="community-events-loading hide-if-no-js">
<?php _e( 'Loading&hellip;'); ?>
</div>
<?php
/*
* Hide the main element when the page first loads, because the content
* won't be ready until wp.communityEvents.renderEventsTemplate() has run.
*/
?>
<div id="community-events" class="community-events" aria-hidden="true">
<div class="activity-block">
<p>
<span id="community-events-location-message"></span>
<button class="button-link community-events-toggle-location" aria-label="<?php _e( 'Edit city'); ?>" aria-expanded="false">
<span class="dashicons dashicons-edit"></span>
</button>
</p>
<form class="community-events-form" aria-hidden="true" action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" method="post">
<label for="community-events-location">
<?php _e( 'City:' ); ?>
</label>
<?php /* translators: Replace with the name of a city in your locale that shows events. Use only the city name itself, without any region or country. Use the endonym instead of the English name. */ ?>
<input id="community-events-location" class="regular-text" type="text" name="community-events-location" placeholder="<?php _e( 'Cincinnati' ); ?>" />
<?php submit_button( __( 'Submit' ), 'secondary', 'community-events-submit', false ); ?>
<button class="community-events-cancel button button-link" type="button" aria-expanded="false">
<?php _e( 'Cancel' ); ?>
</button>
<span class="spinner"></span>
</form>
</div>
<ul class="community-events-results activity-block last"></ul>
</div>
<?php
}
/**
* Renders the events templates for the Event and News widget.
*
* @since 4.8.0
*/
function wp_print_community_events_templates() {
$script_data = wp_get_community_events_script_data();
?>
<script id="tmpl-community-events-attend-event-near" type="text/template">
<?php printf(
/* translators: %s is a placeholder for the name of a city. */
__( 'Attend an upcoming event near %s.' ),
'<strong>{{ data.location }}</strong>'
); ?>
</script>
<script id="tmpl-community-events-could-not-locate" type="text/template">
<?php printf(
$script_data['l10n']['could_not_locate_city'],
'<em>{{data.unknownCity}}</em>'
); ?>
</script>
<script id="tmpl-community-events-event-list" type="text/template">
<# _.each( data.events, function( event ) { #>
<li class="event event-{{ event.type }} wp-clearfix">
<div class="event-info">
<div class="dashicons event-icon" aria-hidden="true"></div>
<div class="event-info-inner">
<a class="event-title" href="{{ event.url }}">{{ event.title }}</a>
<span class="event-city">{{ event.location.location }}</span>
</div>
</div>
<div class="event-date-time">
<span class="event-date">{{ event.formatted_date }}</span>
<# if ( 'meetup' === event.type ) { #>
<span class="event-time">{{ event.formatted_time }}</span>
<# } #>
</div>
</li>
<# } ) #>
</script>
<script id="tmpl-community-events-no-upcoming-events" type="text/template">
<li class="event-none">
<?php printf(
/* translators: 1: the city the user searched for, 2: meetup organization documentation URL */
__( 'There aren&#8217;t any events scheduled near %1$s at the moment. Would you like to <a href="%2$s">organize one</a>?' ),
'{{data.location}}',
__( 'https://make.wordpress.org/community/handbook/meetup-organizer/welcome/' )
); ?>
</li>
</script>
<?php
}
/**
* WordPress News dashboard widget.
*
* @since 2.7.0
* @since 4.8.0 Removed popular plugins feed.
*/
function wp_dashboard_primary() {
$feeds = array(
@ -1105,9 +1308,9 @@ function wp_dashboard_primary() {
*/
'title' => apply_filters( 'dashboard_primary_title', __( 'WordPress Blog' ) ),
'items' => 1,
'show_summary' => 1,
'show_summary' => 0,
'show_author' => 0,
'show_date' => 1,
'show_date' => 0,
),
'planet' => array(
@ -1152,20 +1355,6 @@ function wp_dashboard_primary() {
)
);
if ( ( ! wp_disallow_file_mods( 'dashboard_widget' ) ) && ( ! is_multisite() && is_blog_admin() && current_user_can( 'install_plugins' ) ) || ( is_network_admin() && current_user_can( 'manage_network_plugins' ) && current_user_can( 'install_plugins' ) ) ) {
$feeds['plugins'] = array(
'link' => '',
'url' => array(
'popular' => 'http://wordpress.org/plugins/rss/browse/popular/',
),
'title' => '',
'items' => 1,
'show_summary' => 0,
'show_author' => 0,
'show_date' => 0,
);
}
wp_dashboard_cached_rss_widget( 'dashboard_primary', 'wp_dashboard_primary_output', $feeds );
}
@ -1173,6 +1362,7 @@ function wp_dashboard_primary() {
* Display the WordPress news feeds.
*
* @since 3.8.0
* @since 4.8.0 Removed popular plugins feed.
*
* @param string $widget_id Widget ID.
* @param array $feeds Array of RSS feeds.
@ -1181,94 +1371,11 @@ function wp_dashboard_primary_output( $widget_id, $feeds ) {
foreach ( $feeds as $type => $args ) {
$args['type'] = $type;
echo '<div class="rss-widget">';
if ( $type === 'plugins' ) {
wp_dashboard_plugins_output( $args['url'], $args );
} else {
wp_widget_rss_output( $args['url'], $args );
}
echo "</div>";
}
}
/**
* Display plugins text for the WordPress news widget.
*
* @since 2.5.0
*
* @param string $rss The RSS feed URL.
* @param array $args Array of arguments for this RSS feed.
*/
function wp_dashboard_plugins_output( $rss, $args = array() ) {
// Plugin feeds plus link to install them
$popular = fetch_feed( $args['url']['popular'] );
if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
$plugin_slugs = array_keys( get_plugins() );
set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
}
echo '<ul>';
foreach ( array( $popular ) as $feed ) {
if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
continue;
$items = $feed->get_items(0, 5);
// Pick a random, non-installed plugin
while ( true ) {
// Abort this foreach loop iteration if there's no plugins left of this type
if ( 0 == count($items) )
continue 2;
$item_key = array_rand($items);
$item = $items[$item_key];
list($link, $frag) = explode( '#', $item->get_link() );
$link = esc_url($link);
if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
$slug = $matches[1];
else {
unset( $items[$item_key] );
continue;
}
// Is this random plugin's slug already installed? If so, try again.
reset( $plugin_slugs );
foreach ( $plugin_slugs as $plugin_slug ) {
if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
unset( $items[$item_key] );
continue 2;
}
}
// If we get to this point, then the random plugin isn't installed and we can stop the while().
break;
}
// Eliminate some common badly formed plugin descriptions
while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
unset($items[$item_key]);
if ( !isset($items[$item_key]) )
continue;
$raw_title = $item->get_title();
$ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
'&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
/* translators: %s: plugin name */
esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
$feed->__destruct();
unset( $feed );
}
echo '</ul>';
}
/**
* Display file upload quota on dashboard.
*

View File

@ -1294,6 +1294,88 @@ function wp_dashboard_secondary() {}
*/
function wp_dashboard_secondary_control() {}
/**
* Display plugins text for the WordPress news widget.
*
* @since 2.5.0
* @deprecated 4.8.0
*
* @param string $rss The RSS feed URL.
* @param array $args Array of arguments for this RSS feed.
*/
function wp_dashboard_plugins_output( $rss, $args = array() ) {
_deprecated_function( __FUNCTION__, '4.8.0' );
// Plugin feeds plus link to install them
$popular = fetch_feed( $args['url']['popular'] );
if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
$plugin_slugs = array_keys( get_plugins() );
set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
}
echo '<ul>';
foreach ( array( $popular ) as $feed ) {
if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
continue;
$items = $feed->get_items(0, 5);
// Pick a random, non-installed plugin
while ( true ) {
// Abort this foreach loop iteration if there's no plugins left of this type
if ( 0 == count($items) )
continue 2;
$item_key = array_rand($items);
$item = $items[$item_key];
list($link, $frag) = explode( '#', $item->get_link() );
$link = esc_url($link);
if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
$slug = $matches[1];
else {
unset( $items[$item_key] );
continue;
}
// Is this random plugin's slug already installed? If so, try again.
reset( $plugin_slugs );
foreach ( $plugin_slugs as $plugin_slug ) {
if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
unset( $items[$item_key] );
continue 2;
}
}
// If we get to this point, then the random plugin isn't installed and we can stop the while().
break;
}
// Eliminate some common badly formed plugin descriptions
while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
unset($items[$item_key]);
if ( !isset($items[$item_key]) )
continue;
$raw_title = $item->get_title();
$ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
'&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
/* translators: %s: plugin name */
esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
$feed->__destruct();
unset( $feed );
}
echo '</ul>';
}
/**
* This was once used to move child posts to a new parent.
*

View File

@ -565,6 +565,10 @@ function upgrade_all() {
if ( $wp_current_db_version < 37965 )
upgrade_460();
if ( $wp_current_db_version < 40500 ) { //todo update to commit for #40702
upgrade_480();
}
maybe_disable_link_manager();
maybe_disable_automattic_widgets();
@ -1732,6 +1736,26 @@ function upgrade_460() {
}
}
/**
* Executes changes made in WordPress 4.8.0.
*
* @ignore
* @since 4.8.0
*
* @global int $wp_current_db_version Current database version.
*/
function upgrade_480() {
global $wp_current_db_version;
if ( $wp_current_db_version < 40500 ) { // todo update to commit for #40702
// This feature plugin was merged for #40702, so the plugin itself is no longer needed
deactivate_plugins( array( 'nearby-wp-events/nearby-wordpress-events.php' ), true );
// The markup stored in this transient changed for #40702
delete_transient( 'dash_' . md5( 'dashboard_primary' . '_' . get_locale() ) );
}
}
/**
* Executes network-level upgrade routines.
*

View File

@ -15,6 +15,8 @@ require_once(ABSPATH . 'wp-admin/includes/dashboard.php');
wp_dashboard_setup();
wp_enqueue_script( 'dashboard' );
wp_localize_script( 'dashboard', 'communityEventsData', wp_get_community_events_script_data() );
if ( current_user_can( 'edit_theme_options' ) )
wp_enqueue_script( 'customize-loader' );
if ( current_user_can( 'install_plugins' ) ) {
@ -138,4 +140,6 @@ include( ABSPATH . 'wp-admin/admin-header.php' );
</div><!-- wrap -->
<?php
wp_print_community_events_templates();
require( ABSPATH . 'wp-admin/admin-footer.php' );

View File

@ -1,5 +1,6 @@
/* global pagenow, ajaxurl, postboxes, wpActiveEditor:true */
var ajaxWidgets, ajaxPopulateWidgets, quickPressLoad;
window.wp = window.wp || {};
jQuery(document).ready( function($) {
var welcomePanel = $( '#welcome-panel' ),
@ -187,3 +188,262 @@ jQuery(document).ready( function($) {
}
} );
jQuery( function( $ ) {
'use strict';
var communityEventsData = window.communityEventsData || {};
var app = window.wp.communityEvents = {
initialized: false,
model: null,
/**
* Initializes the wp.communityEvents object.
*
* @since 4.8.0
*/
init: function() {
if ( app.initialized ) {
return;
}
var $container = $( '#community-events' );
/*
* When JavaScript is disabled, the errors container is shown, so
* that "This widget requires Javascript" message can be seen.
*
* When JS is enabled, the container is hidden at first, and then
* revealed during the template rendering, if there actually are
* errors to show.
*
* The display indicator switches from `hide-if-js` to `aria-hidden`
* here in order to maintain consistency with all the other fields
* that key off of `aria-hidden` to determine their visibility.
* `aria-hidden` can't be used initially, because there would be no
* way to set it to false when JavaScript is disabled, which would
* prevent people from seeing the "This widget requires JavaScript"
* message.
*/
$( '.community-events-errors' )
.attr( 'aria-hidden', true )
.removeClass( 'hide-if-js' );
$container.on( 'click', '.community-events-toggle-location, .community-events-cancel', app.toggleLocationForm );
$container.on( 'submit', '.community-events-form', function( event ) {
event.preventDefault();
app.getEvents( {
location: $( '#community-events-location' ).val()
});
});
if ( communityEventsData && communityEventsData.cache && communityEventsData.cache.location && communityEventsData.cache.events ) {
app.renderEventsTemplate( communityEventsData.cache, 'app' );
} else {
app.getEvents();
}
app.initialized = true;
},
/**
* Toggles the visibility of the Edit Location form.
*
* @since 4.8.0
*
* @param {event|string} action 'show' or 'hide' to specify a state;
* Or an event object to flip between states
*/
toggleLocationForm: function( action ) {
var $toggleButton = $( '.community-events-toggle-location' ),
$cancelButton = $( '.community-events-cancel' ),
$form = $( '.community-events-form' );
if ( 'object' === typeof action ) {
// Strict comparison doesn't work in this case.
action = 'true' == $toggleButton.attr( 'aria-expanded' ) ? 'hide' : 'show';
}
if ( 'hide' === action ) {
$toggleButton.attr( 'aria-expanded', false );
$cancelButton.attr( 'aria-expanded', false );
$form.attr( 'aria-hidden', true );
} else {
$toggleButton.attr( 'aria-expanded', true );
$cancelButton.attr( 'aria-expanded', true );
$form.attr( 'aria-hidden', false );
}
},
/**
* Sends REST API requests to fetch events for the widget.
*
* @since 4.8.0
*
* @param {object} requestParams
*/
getEvents: function( requestParams ) {
var initiatedBy,
app = this,
$spinner = $( '.community-events-form' ).children( '.spinner' );
requestParams = requestParams || {};
requestParams._wpnonce = communityEventsData.nonce;
requestParams.timezone = window.Intl ? window.Intl.DateTimeFormat().resolvedOptions().timeZone : '';
initiatedBy = requestParams.location ? 'user' : 'app';
$spinner.addClass( 'is-active' );
wp.ajax.post( 'get-community-events', requestParams )
.always( function() {
$spinner.removeClass( 'is-active' );
})
.done( function( response ) {
if ( 'no_location_available' === response.error ) {
if ( requestParams.location ) {
response.unknownCity = requestParams.location;
} else {
/*
* No location was passed, which means that this was an automatic query
* based on IP, locale, and timezone. Since the user didn't initiate it,
* it should fail silently. Otherwise, the error could confuse and/or
* annoy them.
*/
delete response.error;
}
}
app.renderEventsTemplate( response, initiatedBy );
})
.fail( function() {
app.renderEventsTemplate( {
'location' : false,
'error' : true
}, initiatedBy );
});
},
/**
* Renders the template for the Events section of the Events & News widget.
*
* @since 4.8.0
*
* @param {Object} templateParams The various parameters that will get passed to wp.template
* @param {string} initiatedBy 'user' to indicate that this was triggered manually by the user;
* 'app' to indicate it was triggered automatically by the app itself.
*/
renderEventsTemplate: function( templateParams, initiatedBy ) {
var template,
elementVisibility,
l10nPlaceholder = /%(?:\d\$)?s/g, // Match `%s`, `%1$s`, `%2$s`, etc.
$locationMessage = $( '#community-events-location-message' ),
$results = $( '.community-events-results' );
/*
* Hide all toggleable elements by default, to keep the logic simple.
* Otherwise, each block below would have to turn hide everything that
* could have been shown at an earlier point.
*
* The exception to that is that the .community-events container. It's hidden
* when the page is first loaded, because the content isn't ready yet,
* but once we've reached this point, it should always be shown.
*/
elementVisibility = {
'.community-events' : true,
'.community-events-loading' : false,
'.community-events-errors' : false,
'.community-events-error-occurred' : false,
'.community-events-could-not-locate' : false,
'#community-events-location-message' : false,
'.community-events-toggle-location' : false,
'.community-events-results' : false
};
/*
* Determine which templates should be rendered and which elements
* should be displayed.
*/
if ( templateParams.location ) {
template = wp.template( 'community-events-attend-event-near' );
$locationMessage.html( template( templateParams ) );
if ( templateParams.events.length ) {
template = wp.template( 'community-events-event-list' );
$results.html( template( templateParams ) );
} else {
template = wp.template( 'community-events-no-upcoming-events' );
$results.html( template( templateParams ) );
}
wp.a11y.speak( communityEventsData.l10n.city_updated.replace( l10nPlaceholder, templateParams.location ) );
elementVisibility['#community-events-location-message'] = true;
elementVisibility['.community-events-toggle-location'] = true;
elementVisibility['.community-events-results'] = true;
} else if ( templateParams.unknownCity ) {
template = wp.template( 'community-events-could-not-locate' );
$( '.community-events-could-not-locate' ).html( template( templateParams ) );
wp.a11y.speak( communityEventsData.l10n.could_not_locate_city.replace( l10nPlaceholder, templateParams.unknownCity ) );
elementVisibility['.community-events-errors'] = true;
elementVisibility['.community-events-could-not-locate'] = true;
} else if ( templateParams.error && 'user' === initiatedBy ) {
/*
* Errors messages are only shown for requests that were initiated
* by the user, not for ones that were initiated by the app itself.
* Showing error messages for an event that user isn't aware of
* could be confusing or unnecessarily distracting.
*/
wp.a11y.speak( communityEventsData.l10n.error_occurred_please_try_again );
elementVisibility['.community-events-errors'] = true;
elementVisibility['.community-events-error-occurred'] = true;
} else {
$locationMessage.text( communityEventsData.l10n.enter_closest_city );
elementVisibility['#community-events-location-message'] = true;
elementVisibility['.community-events-toggle-location'] = true;
}
// Set the visibility of toggleable elements.
_.each( elementVisibility, function( isVisible, element ) {
$( element ).attr( 'aria-hidden', ! isVisible );
});
$( '.community-events-toggle-location' ).attr( 'aria-expanded', elementVisibility['.community-events-toggle-location'] );
/*
* During the initial page load, the location form should be hidden
* by default if the user has saved a valid location during a previous
* session. It's safe to assume that they want to continue using that
* location, and displaying the form would unnecessarily clutter the
* widget.
*/
if ( 'app' === initiatedBy && templateParams.location ) {
app.toggleLocationForm( 'hide' );
} else {
app.toggleLocationForm( 'show' );
}
}
};
if ( $( '#dashboard_primary' ).is( ':visible' ) ) {
app.init();
} else {
$( document ).on( 'postbox-toggled', function( event, postbox ) {
var $postbox = $( postbox );
if ( 'dashboard_primary' === $postbox.attr( 'id' ) && $postbox.is( ':visible' ) ) {
app.init();
}
});
}
});

View File

@ -54,6 +54,7 @@ get_current_screen()->set_help_sidebar(
wp_dashboard_setup();
wp_enqueue_script( 'dashboard' );
wp_localize_script( 'dashboard', 'communityEventsData', wp_get_community_events_script_data() );
wp_enqueue_script( 'plugin-install' );
add_thickbox();
@ -73,4 +74,6 @@ require_once( ABSPATH . 'wp-admin/admin-header.php' );
</div><!-- wrap -->
<?php include( ABSPATH . 'wp-admin/admin-footer.php' ); ?>
<?php
wp_print_community_events_templates();
include( ABSPATH . 'wp-admin/admin-footer.php' );

View File

@ -724,7 +724,7 @@ function wp_default_scripts( &$scripts ) {
'current' => __( 'Current Color' ),
) );
$scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox' ), false, 1 );
$scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-util', 'wp-a11y' ), false, 1 );
$scripts->add( 'list-revisions', "/wp-includes/js/wp-list-revisions$suffix.js" );

View File

@ -0,0 +1,258 @@
<?php
/**
* Unit tests for methods in WP_Community_Events.
*
* @package WordPress
* @subpackage UnitTests
* @since 4.8.0
*/
/**
* Class Test_WP_Community_Events.
*
* @group admin
* @group community-events
*
* @since 4.8.0
*/
class Test_WP_Community_Events extends WP_UnitTestCase {
/**
* An instance of the class to test.
*
* @access private
* @since 4.8.0
*
* @var WP_Community_Events
*/
private $instance;
/**
* Performs setup tasks for every test.
*
* @since 4.8.0
*/
public function setUp() {
parent::setUp();
require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
$this->instance = new WP_Community_Events( 1, $this->get_user_location() );
}
/**
* Simulates a stored user location.
*
* @access private
* @since 4.8.0
*
* @return array The mock location.
*/
private function get_user_location() {
return array(
'description' => 'San Francisco',
'latitude' => '37.7749300',
'longitude' => '-122.4194200',
'country' => 'US',
);
}
/**
* Test: get_events() should return an instance of WP_Error if the response code is not 200.
*
* @since 4.8.0
*/
public function test_get_events_bad_response_code() {
add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
$this->assertWPError( $this->instance->get_events() );
remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
}
/**
* Test: The response body should not be cached if the response code is not 200.
*
* @since 4.8.0
*/
public function test_get_cached_events_bad_response_code() {
add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
$this->instance->get_events();
$this->assertFalse( $this->instance->get_cached_events() );
remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
}
/**
* Simulates an HTTP response with a non-200 response code.
*
* @since 4.8.0
*
* @return array A mock response with a 404 HTTP status code
*/
public function _http_request_bad_response_code() {
return array(
'headers' => '',
'body' => '',
'response' => array(
'code' => 404,
),
'cookies' => '',
'filename' => '',
);
}
/**
* Test: get_events() should return an instance of WP_Error if the response body does not have
* the required properties.
*
* @since 4.8.0
*/
public function test_get_events_invalid_response() {
add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
$this->assertWPError( $this->instance->get_events() );
remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
}
/**
* Test: The response body should not be cached if it does not have the required properties.
*
* @since 4.8.0
*/
public function test_get_cached_events_invalid_response() {
add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
$this->instance->get_events();
$this->assertFalse( $this->instance->get_cached_events() );
remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
}
/**
* Simulates an HTTP response with a body that does not have the required properties.
*
* @since 4.8.0
*
* @return array A mock response that's missing required properties.
*/
public function _http_request_invalid_response() {
return array(
'headers' => '',
'body' => wp_json_encode( array() ),
'response' => array(
'code' => 200,
),
'cookies' => '',
'filename' => '',
);
}
/**
* Test: With a valid response, get_events() should return an associated array containing a location array and
* an events array with individual events that have formatted time and date.
*
* @since 4.8.0
*/
public function test_get_events_valid_response() {
add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
$response = $this->instance->get_events();
$this->assertNotWPError( $response );
$this->assertEqualSetsWithIndex( $this->get_user_location(), $response['location'] );
$this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $response['events'][0]['formatted_date'] );
$this->assertEquals( '1:00 pm', $response['events'][0]['formatted_time'] );
remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
}
/**
* Test: get_cached_events() should return the same data as get_events(), including formatted time
* and date values for each event.
*
* @since 4.8.0
*/
public function test_get_cached_events_valid_response() {
add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
$this->instance->get_events();
$cached_events = $this->instance->get_cached_events();
$this->assertNotWPError( $cached_events );
$this->assertEqualSetsWithIndex( $this->get_user_location(), $cached_events['location'] );
$this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $cached_events['events'][0]['formatted_date'] );
$this->assertEquals( '1:00 pm', $cached_events['events'][0]['formatted_time'] );
remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
}
/**
* Simulates an HTTP response with valid location and event data.
*
* @since 4.8.0
*
* @return array A mock HTTP response with valid data.
*/
public function _http_request_valid_response() {
return array(
'headers' => '',
'body' => wp_json_encode( array(
'location' => $this->get_user_location(),
'events' => array(
array(
'type' => 'meetup',
'title' => 'Flexbox + CSS Grid: Magic for Responsive Layouts',
'url' => 'https://www.meetup.com/Eastbay-WordPress-Meetup/events/236031233/',
'meetup' => 'The East Bay WordPress Meetup Group',
'meetup_url' => 'https://www.meetup.com/Eastbay-WordPress-Meetup/',
'date' => date( 'Y-m-d H:i:s', strtotime( 'next Sunday 1pm' ) ),
'location' => array(
'location' => 'Oakland, CA, USA',
'country' => 'us',
'latitude' => 37.808453,
'longitude' => -122.26593,
),
),
array(
'type' => 'meetup',
'title' => 'Part 3- Site Maintenance - Tools to Make It Easy',
'url' => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/events/237706839/',
'meetup' => 'WordPress Bay Area Foothills Group',
'meetup_url' => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/',
'date' => date( 'Y-m-d H:i:s', strtotime( 'next Wednesday 1:30pm' ) ),
'location' => array(
'location' => 'Milpitas, CA, USA',
'country' => 'us',
'latitude' => 37.432813,
'longitude' => -121.907095,
),
),
array(
'type' => 'wordcamp',
'title' => 'WordCamp Kansas City',
'url' => 'https://2017.kansascity.wordcamp.org',
'meetup' => null,
'meetup_url' => null,
'date' => date( 'Y-m-d H:i:s', strtotime( 'next Saturday' ) ),
'location' => array(
'location' => 'Kansas City, MO',
'country' => 'US',
'latitude' => 39.0392325,
'longitude' => -94.577076,
),
),
),
) ),
'response' => array(
'code' => 200,
),
'cookies' => '',
'filename' => '',
);
}
}