diff --git a/mu-plugins/blocks/google-map-event-filters/README.md b/mu-plugins/blocks/google-map-event-filters/README.md new file mode 100644 index 000000000..197a3d026 --- /dev/null +++ b/mu-plugins/blocks/google-map-event-filters/README.md @@ -0,0 +1,33 @@ +# Google Map Event Filters + +This plugins creates the `wporg/google-map-event-filters` block, which displays a map and list of events that match a given filter during a given timeframe. Filters can be setup for anything, but some common examples are watch parties for WP anniversaries and the State of the Word. + +It uses the `wporg/google-map` block to display a searchable list and/or map of the selected events. + + +## Usage + +1. Setup the API key needed for the `wporg/google-maps` block. See its README for details. +1. Add a new filter to `filter_potential_events()` if you're not using an existing one. +1. Add the following to a pattern in your theme. `googleMapBlockAttributes` are the attributes that will be passed to the `wporg/google-map` block, see it's README for details. + + ```php + $filter_options = array( + 'filterSlug' => 'wp20', + 'startDate' => 'April 21, 2023', + 'endDate' => 'May 30, 2023', + + // This has to be an object, see `WordPressdotorg\MU_Plugins\Google_Map_Event_Filters\render()`. + 'googleMapBlockAttributes' => (object) array( + 'id' => 'wp20', + 'apiKey' => 'WORDCAMP_DEV_GOOGLE_MAPS_API_KEY', + ), + ); + + ?> + + + ``` + +1. View the page where the block is used. That will create the cron job that updates the data automatically in the future. +1. Run `wp cron event run prime_event_filters` to test the filtering. Look at each title, and add any false positives to `$false_positives` in `filter_potential_events()`. If any events that should be included were ignored, add a keyword from the title to `$keywords`. Run the command after those changes and make sure it's correct now. diff --git a/mu-plugins/blocks/google-map-event-filters/google-map-event-filters.php b/mu-plugins/blocks/google-map-event-filters/google-map-event-filters.php new file mode 100644 index 000000000..16c96fe97 --- /dev/null +++ b/mu-plugins/blocks/google-map-event-filters/google-map-event-filters.php @@ -0,0 +1,229 @@ + __NAMESPACE__ . '\render', + ) + ); +} + +/** + * Render the block content. + */ +function render( array $attributes, string $content, WP_Block $block ): string { + $attributes['startDate'] = strtotime( $attributes['startDate'] ); + $attributes['endDate'] = strtotime( $attributes['endDate'] ); + $wrapper_attributes = get_block_wrapper_attributes( array( 'id' => 'wp-block-wporg-google-map-event-filters-' . $attributes['filterSlug'] ) ); + + // The REST API doesn't support associative arrays, so this had to be defined as an object in this block. It + // needs to be an array when passed to the Google Map block though. + // See `rest_is_array()`. + $map_options = (array) $attributes['googleMapBlockAttributes']; + $map_options['markers'] = get_events( $attributes['filterSlug'], $attributes['startDate'], $attributes['endDate'] ); + + // This has to be called in `render()` to know which slug/dates to use. + $cron_args = array( $attributes['filterSlug'], $attributes['startDate'], $attributes['endDate'], true ); + + if ( ! wp_next_scheduled( 'prime_event_filters', $cron_args ) ) { + wp_schedule_event( + time() + HOUR_IN_SECONDS, + 'hourly', + 'prime_event_filters', + $cron_args + ); + } + + ob_start(); + + ?> + +
> + +
+ + prepare( ' + SELECT + id, `type`, source_id, title, url, description, meetup, location, latitude, longitude, date_utc, + date_utc_offset AS tz_offset + FROM `wporg_events` + WHERE + status = "scheduled" AND + date_utc BETWEEN FROM_UNIXTIME( %d ) AND FROM_UNIXTIME( %d ) + ORDER BY date_utc ASC + LIMIT 1000', + $start_timestamp, + $end_timestamp + ); + + if ( 'latin1' === DB_CHARSET ) { + $events = $wpdb->get_results( $query ); + } else { + $events = get_latin1_results_with_prepared_query( $query ); + } + + foreach ( $events as $event ) { + // `capital_P_dangit()` won't work here because the current filter isn't `the_title` and there isn't a safelisted prefix before `$text`. + $event->title = str_replace( 'Wordpress', 'WordPress', $event->title ); + + // `date_utc` is a misnomer, the value is actually in the local timezone of the event. So, convert to a true Unix timestamp (UTC). + // Can't do this reliably in the query because MySQL converts it to the server timezone. + $event->timestamp = strtotime( $event->date_utc ) - $event->tz_offset; + + unset( $event->date_utc ); + } + + return $events; +} + +/** + * Query a table that's encoded with the `latin1` charset. + * + * wordpress.org uses a `DB_CHARSET` of `latin1` for legacy reasons, but wordcamp.org and others use `utf8mb4`. + * `wporg_events` uses `latin1`, so querying it with `utf8mb4` will produce Mojibake. + * + * @param string $prepared_query ⚠️ This must have already be ran through `$wpdb->prepare()` if needed. + * + * @return object|null + */ +function get_latin1_results_with_prepared_query( string $prepared_query ) { + global $wpdb; + + // Local environments don't always use HyperDB, but production does. + $db_handle = is_a( $wpdb, 'hyperdb' ) ? $wpdb->db_connect( $prepared_query ) : $wpdb->dbh; + $wpdb->set_charset( $db_handle, 'latin1', 'latin1_swedish_ci' ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This function doesn't have the context to prepare it, the caller must. + $results = $wpdb->get_results( $prepared_query ); + + // Revert to the default charset to avoid affecting other queries. + $wpdb->set_charset( $db_handle, DB_CHARSET, DB_COLLATE ); + + return $results; +} + +/** + * Extract the desired events from an array of potential events. + */ +function filter_potential_events( string $filter_slug, array $potential_events ) : array { + $matched_events = array(); + $other_events = array(); + + switch ( $filter_slug ) { + case 'sotw': + $false_positives = array(); + $keywords = array( + 'sotw', 'state of the word', + ); + break; + + case 'wp20': + $false_positives = array( "292525625", "293437294" ); + $keywords = array( + 'wp20', '20 year', '20 ano', '20 año', '20 candeline', '20 jaar', 'wordt 20', '20 yaşında', + 'anniversary', 'aniversário', 'aniversario', 'birthday', 'cumpleaños', 'celebrate', + 'Tanti auguri', + ); + break; + + default: + return array(); + } + + foreach ( $potential_events as $event ) { + $match = false; + + // Have to use `source_id` because `id` is rotated by `REPLACE INTO` when table is updated. + if ( in_array( $event->source_id, $false_positives, true ) ) { + $other_events[] = $event; + continue; + } + + foreach ( $keywords as $keyword ) { + if ( false !== stripos( $event->description, $keyword ) || false !== stripos( $event->title, $keyword ) ) { + // These are no longer needed, so remove it to save space in the database. + unset( $event->description ); + unset( $event->source_id ); + $matched_events[] = $event; + continue 2; + } + } + + if ( ! $match ) { + $other_events[] = $event; + } + } + + print_results( $matched_events, $other_events ); + + return $matched_events; +} + +/** + * Print the matched/unmatched events for manual review. + * + * Run `wp cron event run prime_event_filters` to see this. + */ +function print_results( array $matched_events, array $other_events ) : void { + if ( 'cli' !== php_sapi_name() ) { + return; + } + + $matched_names = wp_list_pluck( $matched_events, 'title' ); + $other_names = wp_list_pluck( $other_events, 'title' ); + + sort( $matched_names ); + sort( $other_names ); + + echo "\nIgnored these events. Double check for false-negatives.\n\n"; + print_r( $other_names ); + + echo "\Included these events. Double check for false-positives.\n\n"; + print_r( $matched_names ); +} diff --git a/mu-plugins/blocks/google-map-event-filters/src/block.json b/mu-plugins/blocks/google-map-event-filters/src/block.json new file mode 100644 index 000000000..e1572e524 --- /dev/null +++ b/mu-plugins/blocks/google-map-event-filters/src/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "wporg/google-map-event-filters", + "title": "Event Filters for the Google Map block", + "icon": "nametag", + "category": "widgets", + "description": "Displays a map and/or list of a subset of events.", + "textdomain": "wporg", + "attributes": { + "filterSlug": { + "type": "string", + "default": "" + }, + "startDate": { + "type": "string", + "default": "" + }, + "endDate": { + "type": "string", + "default": "" + }, + "googleMapBlockAttributes": { + "type": "object", + "default": [] + } + }, + "supports": { + "inserter": false + }, + "editorScript": "file:./index.js", + "style": "file:./style.css" +} diff --git a/mu-plugins/blocks/google-map-event-filters/src/index.js b/mu-plugins/blocks/google-map-event-filters/src/index.js new file mode 100644 index 000000000..b5df1e45b --- /dev/null +++ b/mu-plugins/blocks/google-map-event-filters/src/index.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { registerBlockType } from '@wordpress/blocks'; +import { Placeholder } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; + +function Edit() { + return ( + + ); +} + +registerBlockType( metadata.name, { + edit: Edit, +} ); diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php index cf0cdaafb..2e061ab3a 100644 --- a/mu-plugins/loader.php +++ b/mu-plugins/loader.php @@ -34,6 +34,7 @@ require_once __DIR__ . '/blocks/local-navigation-bar/index.php'; require_once __DIR__ . '/blocks/latest-news/latest-news.php'; require_once __DIR__ . '/blocks/link-wrapper/index.php'; +require_once __DIR__ . '/blocks/google-map-event-filters/google-map-event-filters.php'; require_once __DIR__ . '/blocks/navigation/index.php'; require_once __DIR__ . '/blocks/notice/index.php'; require_once __DIR__ . '/blocks/query-filter/index.php';