diff --git a/includes/core/classes/class-autoloader.php b/includes/core/classes/class-autoloader.php index 9616f622a..639386b34 100644 --- a/includes/core/classes/class-autoloader.php +++ b/includes/core/classes/class-autoloader.php @@ -89,6 +89,7 @@ static function ( string $class_string = '' ): void { switch ( $class_type ) { case 'blocks': case 'commands': + case 'endpoints': case 'settings': case 'traits': array_pop( $structure ); diff --git a/includes/core/classes/class-calendars.php b/includes/core/classes/class-calendars.php new file mode 100644 index 000000000..32c5f189c --- /dev/null +++ b/includes/core/classes/class-calendars.php @@ -0,0 +1,827 @@ +setup_hooks(); + } + + /** + * Set up hooks for registering custom calendar endpoints. + * + * This method hooks into the `registered_post_type_{post_type}` action to ensure that + * the custom endpoints for the `gatherpress_event` post type are registered after the + * post type is initialized. + * + * @since 1.0.0 + * + * @return void + */ + protected function setup_hooks(): void { + add_action( + sprintf( + 'registered_post_type_%s', + 'gatherpress_event' + ), + array( $this, 'init_events' ), + ); + add_action( + sprintf( + 'registered_post_type_%s', + 'gatherpress_venue' + ), + array( $this, 'init_venues' ), + ); + // @todo Maybe hook this two actions dynamically based on a registered post type?! + add_action( + 'registered_taxonomy_for_object_type', + array( $this, 'init_taxonomies' ), + 10, + 2 + ); + add_action( + 'registered_taxonomy', + array( $this, 'init_taxonomies' ), + 10, + 2 + ); + add_action( 'wp_head', array( $this, 'alternate_links' ) ); + } + + /** + * Initializes the custom calendar endpoints for single events. + * + * This method sets up a `Posttype_Single_Endpoint` for the `gatherpress_event` post type + * (because this is this class' default post type), + * adding custom endpoints for external calendar services (Google Calendar, Yahoo Calendar) + * and download templates for iCal and Outlook. + * + * @since 1.0.0 + * + * @return void + */ + public function init_events(): void { + + // Important: Register the feed endpoint before the single endpoint, + // to make sure rewrite rules get saved in the correct order. + new Posttype_Feed_Endpoint( + array( + new Endpoint_Template( self::ICAL_SLUG, array( $this, 'get_ical_feed_template' ) ), + ), + self::QUERY_VAR + ); + new Posttype_Single_Endpoint( + array( + new Endpoint_Template( self::ICAL_SLUG, array( $this, 'get_ical_file_template' ) ), + new Endpoint_Template( 'outlook', array( $this, 'get_ical_file_template' ) ), + new Endpoint_Redirect( 'google-calendar', array( $this, 'get_google_calendar_link' ) ), + new Endpoint_Redirect( 'yahoo-calendar', array( $this, 'get_yahoo_calendar_link' ) ), + ), + self::QUERY_VAR + ); + } + + /** + * Initializes the custom calendar endpoints for single venues. + * + * This method sets up a `Posttype_Single_Endpoint` for the `gatherpress_venue` post type. + * + * @since 1.0.0 + * + * @return void + */ + public function init_venues(): void { + new Posttype_Single_Feed_Endpoint( + array( + new Endpoint_Template( self::ICAL_SLUG, array( $this, 'get_ical_feed_template' ) ), + ), + self::QUERY_VAR + ); + } + + /** + * Initializes the custom calendar endpoints for taxonomies that belong to events. + * + * This method sets up one `Taxonomy_Feed_Endpoint` for each taxonomy, + * that is registered for the `gatherpress_event` post type + * and publicly available. + * + * @param string $taxonomy Name of the taxonomy that got registered last. + * @param string|array $object_type This will be a string when called via 'registered_taxonomy_for_object_type', + * and could(!) be an array when called from 'registered_taxonomy'. + * + * @return void + */ + public function init_taxonomies( string $taxonomy, $object_type ): void { + + // Stop, if the currently registered taxonomy ... + if ( // ... is not registered for the events post type. + ! in_array( 'gatherpress_event', (array) $object_type, true ) || + // ... is GatherPress' shadow-taxonomy for venues. + '_gatherpress_venue' === $taxonomy || + // ... should not be public. + ! is_taxonomy_viewable( $taxonomy ) + ) { + return; + } + + new Taxonomy_Feed_Endpoint( + array( + new Endpoint_Template( self::ICAL_SLUG, array( $this, 'get_ical_feed_template' ) ), + ), + self::QUERY_VAR, + $taxonomy + ); + } + + /** + * Prints get_bloginfo( 'name' ), + /* translators: Separator between site name and feed type in feed links. */ + 'separator' => _x( '»', 'feed link separator', 'gatherpress' ), + /* translators: 1: Site name, 2: Separator (raquo), 3: Post title. */ + 'singletitle' => __( '📅 %1$s %2$s %3$s iCal Download', 'gatherpress' ), + /* translators: 1: Site title, 2: Separator (raquo). */ + 'feedtitle' => __( '📅 %1$s %2$s iCal Feed', 'gatherpress' ), + /* translators: 1: Site name, 2: Separator (raquo), 3: Post type name. */ + 'posttypetitle' => __( '📅 %1$s %2$s %3$s iCal Feed', 'gatherpress' ), + /* translators: 1: Site name, 2: Separator (raquo), 3: Term name, 4: Taxonomy singular name. */ + 'taxtitle' => __( '📅 %1$s %2$s %3$s %4$s iCal Feed', 'gatherpress' ), + ); + + $alternate_links = array(); + + // @todo "/feed/ical" could be enabled as alias of "/event/feed/ical", + // and called with "get_feed_link( self::ICAL_SLUG )". + $alternate_links[] = array( + 'url' => get_post_type_archive_feed_link( 'gatherpress_event', self::ICAL_SLUG ), + 'attr' => sprintf( + $args['feedtitle'], + $args['blogtitle'], + $args['separator'] + ), + ); + + if ( is_singular( 'gatherpress_event' ) ) { + $alternate_links[] = array( + 'url' => self::get_url( self::ICAL_SLUG ), + 'attr' => sprintf( + $args['singletitle'], + $args['blogtitle'], + $args['separator'], + the_title_attribute( array( 'echo' => false ) ) + ), + ); + + // Get all terms, associated with the current event-post. + $terms = get_terms( + array( + 'taxonomy' => get_object_taxonomies( get_queried_object() ), + 'object_ids' => get_queried_object_id(), + ) + ); + // Loop over terms and generate the ical feed links for the . + array_walk( + $terms, + function ( WP_Term $term ) use ( $args, &$alternate_links ) { + $tax = get_taxonomy( $term->taxonomy ); + switch ( $term->taxonomy ) { + case '_gatherpress_venue': + $gatherpress_venue = Venue::get_instance()->get_venue_post_from_term_slug( $term->slug ); + + // An Online-Event will have no Venue; prevent error on non-existent object. + // Feels weird to use a *_comments_* function here, but it delivers clean results + // in the form of "domain.tld/event/my-sample-event/feed/ical/". + $href = ( $gatherpress_venue ) ? get_post_comments_feed_link( $gatherpress_venue->ID, self::ICAL_SLUG ) : null; + break; + + default: + $href = get_term_feed_link( $term->term_id, $term->taxonomy, self::ICAL_SLUG ); + break; + } + // Can be empty for Online-Events. + if ( ! empty( $href ) ) { + $alternate_links[] = array( + 'url' => $href, + 'attr' => sprintf( + $args['taxtitle'], + $args['blogtitle'], + $args['separator'], + $term->name, + $tax->labels->singular_name + ), + ); + } + } + ); + } elseif ( is_singular( 'gatherpress_venue' ) ) { + + // Feels weird to use a *_comments_* function here, but it delivers clean results + // in the form of "domain.tld/venue/my-sample-venue/feed/ical/". + $alternate_links[] = array( + 'url' => get_post_comments_feed_link( get_queried_object_id(), self::ICAL_SLUG ), + 'attr' => sprintf( + $args['singletitle'], + $args['blogtitle'], + $args['separator'], + the_title_attribute( array( 'echo' => false ) ) + ), + ); + } elseif ( is_tax() ) { + $term = get_queried_object(); + + if ( $term && is_object_in_taxonomy( 'gatherpress_event', $term->taxonomy ) ) { + $tax = get_taxonomy( $term->taxonomy ); + + $alternate_links[] = array( + 'url' => get_term_feed_link( $term->term_id, $term->taxonomy, self::ICAL_SLUG ), + 'attr' => sprintf( + $args['taxtitle'], + $args['blogtitle'], + $args['separator'], + $term->name, + $tax->labels->singular_name + ), + ); + } + } + + // Render tags into . + array_walk( + $alternate_links, + function ( $link ) { + printf( + '' . "\n", + esc_attr( 'text/calendar' ), + esc_attr( $link['attr'] ), + esc_url( $link['url'] ) + ); + } + ); + } + + /** + * Returns the template for the current calendar download. + * + * This method provides the template file to be used for iCal and Outlook downloads. + * + * By adding a file with the same name to your themes root folder + * or your themes `/templates` folder, this template will be used + * with priority over the default template provided by GatherPress. + * + * @since 1.0.0 + * + * @return array An array containing: + * - 'file_name': the file name of the template to be loaded from the theme. Will load defaults from the plugin if theme files do not exist. + * - 'dir_path': (Optional) Absolute path to some template directory outside of the theme folder. + */ + public function get_ical_file_template(): array { + return array( + 'file_name' => Utility::prefix_key( 'ical-download.php' ), + ); + } + + /** + * Returns the template for the subscribeable calendar feed. + * + * This method provides the template file to be used for ical-feeds. + * + * By adding a file with the same name to your themes root folder + * or your themes `/templates` folder, this template will be used + * with priority over the default template provided by GatherPress. + * + * @since 1.0.0 + * + * @return array An array containing: + * - 'file_name': the file name of the template to be loaded from the theme. + * Will load defaults from the plugin if theme files do not exist. + * - 'dir_path': (Optional) Absolute path to some template directory outside of the theme folder. + */ + public function get_ical_feed_template(): array { + return array( + 'file_name' => Utility::prefix_key( 'ical-feed.php' ), + ); + } + + /** + * Get sanitized endpoint url for a given slug, post and query parameter. + * + * Inspired by get_post_embed_url() + * + * @see https://developer.wordpress.org/reference/functions/get_post_embed_url/ + * + * @param string $endpoint_slug The visible suffix to the posts permalink. + * @param WP_Post|int|null $post The post to get the endpoint for. + * @param string $query_var The internal query variable used by WordPress to route the request. + * + * @return string|false URL of the posts endpoint or false if something went wrong. + */ + public static function get_url( string $endpoint_slug, $post = null, string $query_var = self::QUERY_VAR ) { + $post = get_post( $post ); + + if ( ! $post ) { + return false; + } + + $is_feed_endpoint = strpos( 'feed/', $endpoint_slug ); + if ( false !== $is_feed_endpoint ) { + // Feels weird to use a *_comments_* function here, but it delivers clean results + // in the form of "domain.tld/event/my-sample-event/feed/ical/". + return (string) get_post_comments_feed_link( + $post->ID, + substr( $endpoint_slug, $is_feed_endpoint ) + ); + } + + $post_url = get_permalink( $post ); + $endpoint_url = trailingslashit( $post_url ) . user_trailingslashit( $endpoint_slug ); + $path_conflict = get_page_by_path( + str_replace( home_url(), '', $endpoint_url ), + OBJECT, + get_post_types( array( 'public' => true ) ) + ); + + if ( ! get_option( 'permalink_structure' ) || $path_conflict ) { + $endpoint_url = add_query_arg( array( $query_var => $endpoint_slug ), $post_url ); + } + + /** + * Filters the endpoint URL of a specific post. + * + * @since 1.0.0 + * + * @param string $endpoint_url The post embed URL. + * @param WP_Post $post The corresponding post object. + */ + $endpoint_url = sanitize_url( + apply_filters( + 'gatherpress_endpoint_url', + $endpoint_url, + $post + ) + ); + + return (string) sanitize_url( $endpoint_url ); + } + + /** + * Get the Google Calendar add event link for the event. + * + * This method generates and returns a Google Calendar link that allows users to add the event to their + * Google Calendar. The link includes event details such as the event name, date, time, location, and a + * link to the event's details page. + * + * @since 1.0.0 + * + * @return string The Google Calendar add event link for the event. + * + * @throws Exception If there is an issue while generating the Google Calendar link. + */ + public function get_google_calendar_link(): string { + $event = new Event( get_queried_object_id() ); + $date_start = $event->get_formatted_datetime( 'Ymd', 'start', false ); + $time_start = $event->get_formatted_datetime( 'His', 'start', false ); + $date_end = $event->get_formatted_datetime( 'Ymd', 'end', false ); + $time_end = $event->get_formatted_datetime( 'His', 'end', false ); + $datetime = sprintf( '%sT%sZ/%sT%sZ', $date_start, $time_start, $date_end, $time_end ); + $venue = $event->get_venue_information(); + $location = $venue['name']; + $description = $event->get_calendar_description(); + + if ( ! empty( $venue['full_address'] ) ) { + $location .= sprintf( ', %s', $venue['full_address'] ); + } + + $params = array( + 'action' => 'TEMPLATE', + 'text' => sanitize_text_field( $event->event->post_title ), + 'dates' => sanitize_text_field( $datetime ), + 'details' => sanitize_text_field( $description ), + 'location' => sanitize_text_field( $location ), + 'sprop' => 'name:', + ); + + return add_query_arg( + rawurlencode_deep( $params ), + 'https://www.google.com/calendar/event' + ); + } + + /** + * Get the "Add to Yahoo! Calendar" link for the event. + * + * This method generates and returns a URL that allows users to add the event to their Yahoo! Calendar. + * The URL includes event details such as the event title, start time, duration, description, and location. + * + * @since 1.0.0 + * + * @return string The Yahoo! Calendar link for adding the event. + * + * @throws Exception If an error occurs while generating the Yahoo! Calendar link. + */ + public function get_yahoo_calendar_link(): string { + $event = new Event( get_queried_object_id() ); + $date_start = $event->get_formatted_datetime( 'Ymd', 'start', false ); + $time_start = $event->get_formatted_datetime( 'His', 'start', false ); + $datetime_start = sprintf( '%sT%sZ', $date_start, $time_start ); + + // Figure out duration of event in hours and minutes: hhmm format. + $diff_start = $event->get_formatted_datetime( $event::DATETIME_FORMAT, 'start', false ); + $diff_end = $event->get_formatted_datetime( $event::DATETIME_FORMAT, 'end', false ); + $duration = ( ( strtotime( $diff_end ) - strtotime( $diff_start ) ) / 60 / 60 ); + $full = intval( $duration ); + $fraction = ( $duration - $full ); + $hours = str_pad( strval( $duration ), 2, '0', STR_PAD_LEFT ); + $minutes = str_pad( strval( $fraction * 60 ), 2, '0', STR_PAD_LEFT ); + $venue = $event->get_venue_information(); + $location = $venue['name']; + $description = $event->get_calendar_description(); + + if ( ! empty( $venue['full_address'] ) ) { + $location .= sprintf( ', %s', $venue['full_address'] ); + } + + $params = array( + 'v' => '60', + 'view' => 'd', + 'type' => '20', + 'title' => sanitize_text_field( $event->event->post_title ), + 'st' => sanitize_text_field( $datetime_start ), + 'dur' => sanitize_text_field( (string) $hours . (string) $minutes ), + 'desc' => sanitize_text_field( $description ), + 'in_loc' => sanitize_text_field( $location ), + ); + + return add_query_arg( + rawurlencode_deep( $params ), + 'https://calendar.yahoo.com/' + ); + } + + /** + * Wraps the provided calendar data in a VCALENDAR structure for iCal. + * + * This method generates the necessary headers for an iCal file (such as the `BEGIN:VCALENDAR` + * and `END:VCALENDAR` lines), wraps the provided calendar data, and returns the entire iCal + * content as a formatted string. It also includes the blog's title and the current locale + * in the `PRODID` header, ensuring that the calendar is properly identified. + * + * @since 1.0.0 + * + * @param string $calendar_data The events to be included in the iCal file. + * @return string The complete iCal data wrapped in the VCALENDAR format. + */ + public static function get_ical_wrap( string $calendar_data ): string { + + $args = array( + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + sprintf( + 'PRODID:-//%s//GatherPress//%s', + get_bloginfo( 'title' ), + // Prpeare 2-DIGIT lang code. + strtoupper( substr( get_locale(), 0, 2 ) ) + ), + $calendar_data, + 'END:VCALENDAR', + ); + + return implode( "\r\n", $args ); + } + + /** + * Get the ICS download link for the event. + * + * This method generates and returns a URL that allows users to download the event in ICS (iCalendar) format. + * The URL includes event details such as the event title, start time, end time, description, location, and more. + * + * @since 1.0.0 + * + * @return string The ICS download link for the event. + * + * @throws Exception If an error occurs while generating the ICS download link. + */ + public static function get_ical_event(): string { + + $event = new Event( get_queried_object_id() ); + $date_start = $event->get_formatted_datetime( 'Ymd', 'start', false ); + $time_start = $event->get_formatted_datetime( 'His', 'start', false ); + $date_end = $event->get_formatted_datetime( 'Ymd', 'end', false ); + $time_end = $event->get_formatted_datetime( 'His', 'end', false ); + $datetime_start = sprintf( '%sT%sZ', $date_start, $time_start ); + $datetime_end = sprintf( '%sT%sZ', $date_end, $time_end ); + $modified_date = strtotime( $event->event->post_modified ); + $datetime_stamp = sprintf( '%sT%sZ', gmdate( 'Ymd', $modified_date ), gmdate( 'His', $modified_date ) ); + $venue = $event->get_venue_information(); + $location = $venue['name']; + $description = $event->get_calendar_description(); + + if ( ! empty( $venue['full_address'] ) ) { + $location .= sprintf( ', %s', $venue['full_address'] ); + } + + $args = array( + 'BEGIN:VEVENT', + sprintf( 'URL:%s', esc_url_raw( get_permalink( $event->event->ID ) ) ), + sprintf( 'DTSTART:%s', sanitize_text_field( $datetime_start ) ), + sprintf( 'DTEND:%s', sanitize_text_field( $datetime_end ) ), + sprintf( 'DTSTAMP:%s', sanitize_text_field( $datetime_stamp ) ), + sprintf( 'SUMMARY:%s', self::eventorganiser_fold_ical_text( sanitize_text_field( $event->event->post_title ) ) ), + sprintf( 'DESCRIPTION:%s', self::eventorganiser_fold_ical_text( sanitize_text_field( $description ) ) ), + sprintf( 'LOCATION:%s', self::eventorganiser_fold_ical_text( sanitize_text_field( $location ) ) ), + 'UID:gatherpress_' . intval( $event->event->ID ), + 'END:VEVENT', + ); + + return implode( "\r\n", $args ); + } + + /** + * Generates a list of events in iCal format based on the current query. + * + * This method generates iCal data for a list of events, + * taking into account the currently queried archive or taxonomy context. + * + * It supports fetching events from: + * - The `gatherpress_event` post type archive (upcoming and past events). + * - Single `gatherpress_venue` requests (events specific to a venue). + * - and `gatherpress_topic` taxonomy (events tagged with specific topics). + * + * It builds an iCal event list, wraps each event in the appropriate iCal format, and returns + * the entire list of events as a single string. + * + * @since 1.0.0 + * + * @return string The iCal formatted list of events, ready for export or download. + */ + public static function get_ical_list(): string { + + $event_list_type = ''; // Keep empty, to get all events from upcoming & past. + $number = ( is_feed( self::ICAL_SLUG ) ) ? -1 : get_option( 'posts_per_page' ); + $topics = array(); + $venues = array(); + $output = array(); + + if ( is_singular( 'gatherpress_venue' ) ) { + $slug = '_' . get_queried_object()->post_name; + $venues = array( $slug ); + } elseif ( is_tax() ) { + $term = get_queried_object(); + + // @todo How to be prepared for foreign taxonomies that might be registered by 3rd-parties? + if ( $term && is_object_in_taxonomy( 'gatherpress_event', $term->taxonomy ) ) { + // Add the tax to the query here. + + if ( is_tax( 'gatherpress_topic' ) ) { + $topics = array( $term->slug ); + } + } + } + + $query = Event_Query::get_instance()->get_events_list( $event_list_type, $number, $topics, $venues ); + while ( $query->have_posts() ) { + $query->the_post(); + $output[] = self::get_ical_event(); + } + + // Restore original Post Data. + wp_reset_postdata(); + + return implode( "\r\n", $output ); + } + + /** + * Generates the complete iCal file content for an event. + * + * This method calls the `get_ical_event()` method to retrieve the event data in iCal format + * and then wraps the event data using `get_ical_wrap()` to include the necessary VCALENDAR + * headers and footers. It returns the fully formatted iCal file content as a string. + * + * @since 1.0.0 + * + * @return string The complete iCal file content for the event, ready for download or export. + */ + public static function get_ical_file(): string { + return self::get_ical_wrap( self::get_ical_event() ); + } + + /** + * Generates the complete iCal file content for a list of events. + * + * This method calls the `get_ical_list()` method to retrieve the data for all (queried) events in iCal format + * and then wraps the events using `get_ical_wrap()` to include the necessary VCALENDAR + * headers and footers. + * + * @since 1.0.0 + * + * @return string The complete iCal feed containing the list of events. + */ + public static function get_ical_feed(): string { + return self::get_ical_wrap( self::get_ical_list() ); + } + + /** + * Generate the .ics filename based on the queried object. + * + * @return string Name of the calendar ics file. + */ + public static function generate_ics_filename() { + $queried_object = get_queried_object(); + $filename = 'calendar'; + + if ( is_singular( 'gatherpress_event' ) ) { + $event = new Event( $queried_object->ID ); + $date = $event->get_datetime_start( 'Y-m-d' ); + $post_name = $event->event->post_name; + $filename = $date . '_' . $post_name; + } elseif ( is_singular( 'gatherpress_venue' ) ) { + $filename = $queried_object->post_name; + } elseif ( is_tax() ) { + + // @todo How to be prepared for foreign taxonomies that might be registered by 3rd-parties? + if ( is_object_in_taxonomy( 'gatherpress_event', $queried_object->taxonomy ) ) { + $filename = $queried_object->slug; + } + } + + return $filename . '.ics'; + } + + /** + * Send the necessary headers for the iCalendar file download. + * + * @param string $filename Generated name of the file. + * + * @return void + */ + public static function send_ics_headers( string $filename ) { + + $charset = strtolower( get_option( 'blog_charset' ) ); + + header( 'Content-Description: File Transfer' ); + + // Ensure proper content type for the calendar file. + header( 'Content-Type: text/calendar; charset=' . $charset ); + + // Force download in most browsers. + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + + // Avoid browser caching issues. + header( 'Cache-Control: no-store, no-cache, must-revalidate' ); + header( 'Cache-Control: post-check=0, pre-check=0', false ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + // Prevent content sniffing which might lead to MIME type mismatch. + header( 'X-Content-Type-Options: nosniff' ); + } + + /** + * Output the event(s) as an iCalendar (.ics) file. + * + * @return void + */ + public static function send_ics_file() { + // Start output buffering to capture all output. + ob_start(); + + // Prepare the filename. + $filename = self::generate_ics_filename(); + + // Send headers for downloading the .ics file. + self::send_ics_headers( $filename ); + + // Output the generated iCalendar content. + $get_ical_method = ( is_feed() ) ? 'get_ical_feed' : 'get_ical_file'; + echo wp_kses_post( self::$get_ical_method() ); + + // Get the generated output and calculate file size. + $ics_content = ob_get_contents(); + $filesize = strlen( $ics_content ); + + // Send the file size in the header. + header( 'Content-Length: ' . $filesize ); + + // End output buffering and clean up. + ob_end_clean(); + + // Output the iCalendar content. + echo wp_kses_post( $ics_content ); + + exit(); // Terminate the script after the file has been output. + } + + /** + * @author Stephen Harris (@stephenharris) + * @source https://github.com/stephenharris/Event-Organiser/blob/develop/includes/event-organiser-utility-functions.php#L1663 + * + * Fold text as per [iCal specifications](http://www.ietf.org/rfc/rfc2445.txt) + * + * Lines of text SHOULD NOT be longer than 75 octets, excluding the line + * break. Long content lines SHOULD be split into a multiple line + * representations using a line "folding" technique. That is, a long + * line can be split between any two characters by inserting a CRLF + * immediately followed by a single linear white space character (i.e., + * SPACE, US-ASCII decimal 32 or HTAB, US-ASCII decimal 9). Any sequence + * of CRLF followed immediately by a single linear white space character + * is ignored (i.e., removed) when processing the content type. + * + * @ignore + * @since 2.7 + * @param string $text The string to be escaped. + * @return string The escaped string. + */ + private static function eventorganiser_fold_ical_text( string $text ): string { + + $text_arr = array(); + + $lines = ceil( mb_strlen( $text ) / 75 ); + + for ( $i = 0; $i < $lines; $i++ ) { + $text_arr[ $i ] = mb_substr( $text, $i * 75, 75 ); + } + + return join( "\r\n ", $text_arr ); + } +} diff --git a/includes/core/classes/class-event-setup.php b/includes/core/classes/class-event-setup.php index 2907ac116..4de47972a 100644 --- a/includes/core/classes/class-event-setup.php +++ b/includes/core/classes/class-event-setup.php @@ -15,6 +15,7 @@ defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore use Exception; +use GatherPress\Core\Event; use GatherPress\Core\Traits\Singleton; use WP_Post; diff --git a/includes/core/classes/class-event.php b/includes/core/classes/class-event.php index b919d6042..d88230b26 100644 --- a/includes/core/classes/class-event.php +++ b/includes/core/classes/class-event.php @@ -14,6 +14,7 @@ // Exit if accessed directly. defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore +use GatherPress\Core\Calendars; use DateTimeZone; use Exception; use WP_Post; @@ -447,164 +448,23 @@ public function get_calendar_links(): array { return array( 'google' => array( 'name' => __( 'Google Calendar', 'gatherpress' ), - 'link' => $this->get_google_calendar_link(), + 'link' => Calendars::get_url( 'google-calendar', $this->event->ID ), ), 'ical' => array( 'name' => __( 'iCal', 'gatherpress' ), - 'download' => $this->get_ics_calendar_download(), + 'download' => Calendars::get_url( 'ical', $this->event->ID ), ), 'outlook' => array( 'name' => __( 'Outlook', 'gatherpress' ), - 'download' => $this->get_ics_calendar_download(), + 'download' => Calendars::get_url( 'outlook', $this->event->ID ), ), 'yahoo' => array( 'name' => __( 'Yahoo Calendar', 'gatherpress' ), - 'link' => $this->get_yahoo_calendar_link(), + 'link' => Calendars::get_url( 'yahoo-calendar', $this->event->ID ), ), ); } - /** - * Get the Google Calendar add event link for the event. - * - * This method generates and returns a Google Calendar link that allows users to add the event to their - * Google Calendar. The link includes event details such as the event name, date, time, location, and a - * link to the event's details page. - * - * @since 1.0.0 - * - * @return string The Google Calendar add event link for the event. - * - * @throws Exception If there is an issue while generating the Google Calendar link. - */ - protected function get_google_calendar_link(): string { - $date_start = $this->get_formatted_datetime( 'Ymd', 'start', false ); - $time_start = $this->get_formatted_datetime( 'His', 'start', false ); - $date_end = $this->get_formatted_datetime( 'Ymd', 'end', false ); - $time_end = $this->get_formatted_datetime( 'His', 'end', false ); - $datetime = sprintf( '%sT%sZ/%sT%sZ', $date_start, $time_start, $date_end, $time_end ); - $venue = $this->get_venue_information(); - $location = $venue['name']; - $description = $this->get_calendar_description(); - - if ( ! empty( $venue['full_address'] ) ) { - $location .= sprintf( ', %s', $venue['full_address'] ); - } - - $params = array( - 'action' => 'TEMPLATE', - 'text' => sanitize_text_field( $this->event->post_title ), - 'dates' => sanitize_text_field( $datetime ), - 'details' => sanitize_text_field( $description ), - 'location' => sanitize_text_field( $location ), - 'sprop' => 'name:', - ); - - return add_query_arg( - rawurlencode_deep( $params ), - 'https://www.google.com/calendar/event' - ); - } - - /** - * Get the "Add to Yahoo! Calendar" link for the event. - * - * This method generates and returns a URL that allows users to add the event to their Yahoo! Calendar. - * The URL includes event details such as the event title, start time, duration, description, and location. - * - * @since 1.0.0 - * - * @return string The Yahoo! Calendar link for adding the event. - * - * @throws Exception If an error occurs while generating the Yahoo! Calendar link. - */ - protected function get_yahoo_calendar_link(): string { - $date_start = $this->get_formatted_datetime( 'Ymd', 'start', false ); - $time_start = $this->get_formatted_datetime( 'His', 'start', false ); - $datetime_start = sprintf( '%sT%sZ', $date_start, $time_start ); - - // Figure out duration of event in hours and minutes: hhmm format. - $diff_start = $this->get_formatted_datetime( self::DATETIME_FORMAT, 'start', false ); - $diff_end = $this->get_formatted_datetime( self::DATETIME_FORMAT, 'end', false ); - $duration = ( ( strtotime( $diff_end ) - strtotime( $diff_start ) ) / 60 / 60 ); - $full = intval( $duration ); - $fraction = ( $duration - $full ); - $hours = str_pad( strval( $duration ), 2, '0', STR_PAD_LEFT ); - $minutes = str_pad( strval( $fraction * 60 ), 2, '0', STR_PAD_LEFT ); - $venue = $this->get_venue_information(); - $location = $venue['name']; - $description = $this->get_calendar_description(); - - if ( ! empty( $venue['full_address'] ) ) { - $location .= sprintf( ', %s', $venue['full_address'] ); - } - - $params = array( - 'v' => '60', - 'view' => 'd', - 'type' => '20', - 'title' => sanitize_text_field( $this->event->post_title ), - 'st' => sanitize_text_field( $datetime_start ), - 'dur' => sanitize_text_field( (string) $hours . (string) $minutes ), - 'desc' => sanitize_text_field( $description ), - 'in_loc' => sanitize_text_field( $location ), - ); - - return add_query_arg( - rawurlencode_deep( $params ), - 'https://calendar.yahoo.com/' - ); - } - - /** - * Get the ICS download link for the event. - * - * This method generates and returns a URL that allows users to download the event in ICS (iCalendar) format. - * The URL includes event details such as the event title, start time, end time, description, location, and more. - * - * @since 1.0.0 - * - * @return string The ICS download link for the event. - * - * @throws Exception If an error occurs while generating the ICS download link. - */ - protected function get_ics_calendar_download(): string { - $date_start = $this->get_formatted_datetime( 'Ymd', 'start', false ); - $time_start = $this->get_formatted_datetime( 'His', 'start', false ); - $date_end = $this->get_formatted_datetime( 'Ymd', 'end', false ); - $time_end = $this->get_formatted_datetime( 'His', 'end', false ); - $datetime_start = sprintf( '%sT%sZ', $date_start, $time_start ); - $datetime_end = sprintf( '%sT%sZ', $date_end, $time_end ); - $modified_date = strtotime( $this->event->post_modified ); - $datetime_stamp = sprintf( '%sT%sZ', gmdate( 'Ymd', $modified_date ), gmdate( 'His', $modified_date ) ); - $venue = $this->get_venue_information(); - $location = $venue['name']; - $description = $this->get_calendar_description(); - - if ( ! empty( $venue['full_address'] ) ) { - $location .= sprintf( ', %s', $venue['full_address'] ); - } - - $args = array( - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//GatherPress//RemoteApi//EN', - 'BEGIN:VEVENT', - sprintf( 'URL:%s', esc_url_raw( get_permalink( $this->event->ID ) ) ), - sprintf( 'DTSTART:%s', sanitize_text_field( $datetime_start ) ), - sprintf( 'DTEND:%s', sanitize_text_field( $datetime_end ) ), - sprintf( 'DTSTAMP:%s', sanitize_text_field( $datetime_stamp ) ), - sprintf( 'SUMMARY:%s', sanitize_text_field( $this->event->post_title ) ), - sprintf( 'DESCRIPTION:%s', sanitize_text_field( $description ) ), - sprintf( 'LOCATION:%s', sanitize_text_field( $location ) ), - 'UID:gatherpress_' . intval( $this->event->ID ), - 'END:VEVENT', - 'END:VCALENDAR', - ); - - return 'data:text/calendar;charset=utf8,' . implode( '%0A', $args ); - } - /** * Generate a calendar event description with a link to the event details. * diff --git a/includes/core/classes/class-setup.php b/includes/core/classes/class-setup.php index f1e9763ef..b3e1f24e5 100644 --- a/includes/core/classes/class-setup.php +++ b/includes/core/classes/class-setup.php @@ -58,6 +58,7 @@ protected function __construct() { protected function instantiate_classes(): void { Assets::get_instance(); Block::get_instance(); + Calendars::get_instance(); Cli::get_instance(); Event_Query::get_instance(); Event_Rest_Api::get_instance(); diff --git a/includes/core/classes/endpoints/class-endpoint-redirect.php b/includes/core/classes/endpoints/class-endpoint-redirect.php new file mode 100644 index 000000000..73af5c4c2 --- /dev/null +++ b/includes/core/classes/endpoints/class-endpoint-redirect.php @@ -0,0 +1,94 @@ +url = ( $this->callback )(); + if ( $this->url ) { + // Add the target host to the list of allowed redirect hosts. + add_filter( 'allowed_redirect_hosts', array( $this, 'allowed_redirect_hosts' ) ); + // Perform a safe redirection to the target URL. Defaults to a 302 status code. + wp_safe_redirect( $this->url ); + exit; // Always exit after redirecting. + } + } + + /** + * Filters the list of allowed hosts to include the redirect target. + * + * This method ensures that the host of the target URL is added to the list of allowed + * redirect hosts, allowing the redirection to proceed safely. It is hooked into the + * `allowed_redirect_hosts` filter, which controls the domains that `wp_safe_redirect()` + * is allowed to redirect to. + * + * @see https://developer.wordpress.org/reference/hooks/allowed_redirect_hosts/ + * + * @since 1.0.0 + * + * @param string[] $hosts An array of allowed host names. + * @return string[] The updated array of allowed host names, including the redirect target. + */ + public function allowed_redirect_hosts( array $hosts ): array { + return array_merge( + $hosts, + array( + wp_parse_url( $this->url, PHP_URL_HOST ), + ) + ); + } +} diff --git a/includes/core/classes/endpoints/class-endpoint-template.php b/includes/core/classes/endpoints/class-endpoint-template.php new file mode 100644 index 000000000..4861a066c --- /dev/null +++ b/includes/core/classes/endpoints/class-endpoint-template.php @@ -0,0 +1,210 @@ +plugin_template_dir = ( ! empty( $plugin_template_dir ) ) ? $plugin_template_dir : sprintf( + '%s/includes/templates/endpoints', + GATHERPRESS_CORE_PATH + ); + } + + + /** + * Activate Endpoint_Type by hooking into relevant parts. + * + * @since 1.0.0 + * + * @param Endpoint|null $endpoint Class for custom rewrite endpoints and their query handling in GatherPress. + * @return void + */ + public function activate( ?Endpoint $endpoint = null ): void { + + // A call to any /feed/ endpoint is handled different by WordPress + // and as such the 'Endpoint_Template's template_include hook would fail. + $feed_slug = ( null !== $endpoint ) ? $endpoint->has_feed() : false; + if ( $feed_slug ) { + // Hook into WordPress' feed handling to load the custom feed template. + add_action( sprintf( 'do_feed_%s', $feed_slug ), array( $this, 'load_feed_template' ) ); + } else { + // Filters the path of the current template before including it. + add_filter( 'template_include', array( $this, 'template_include' ) ); + } + } + + /** + * Load the theme-overridable feed template from the plugin. + * + * This method ensures that a feed template is loaded when a request is made to + * a custom feed endpoint. If the theme provides an override for the feed template, + * it will be used; otherwise, the default template from the plugin is loaded. The + * method ensures that WordPress does not return a 404 for custom feed URLs. + * + * A call to any post types /feed/anything endpoint is handled by WordPress + * prior 'Endpoint_Template's template_include hook would run. + * Therefore WordPress will throw an xml'ed 404 error, + * if nothing is hooked onto the 'do_feed_anything' action. + * + * That's the reason for this method, it delivers what WordPress wants + * and re-uses the parameters provided by the class. + * + * We expect that a endpoint, that contains the /feed/ string, only has one 'Redirect_Template' attached. + * This might be wrong or short sightened, please open an issue in that case: https://github.com/GatherPress/gatherpress/issues + * + * Until then, we *just* use the first of the provided endpoint-types, + * to hook into WordPress, which should be the valid template endpoint. + * + * @since 1.0.0 + * + * @return void + */ + public function load_feed_template() { + load_template( $this->template_include() ); + } + + /** + * Filters the path of the current template before including it. + * + * This method checks if the theme or child theme provides a custom template for the + * current endpoint. If a theme template exists, it will use that; otherwise, it will + * fall back to the default template provided by the plugin. The template information + * is provided by the callback set during the construction of the endpoint. + * + * @since 1.0.0 + * + * @param string $template The path of the default template to include, + * defaults to '' so that the template loader keeps looking for templates. + * @return string The path of the template to include, either from the theme or plugin. + */ + public function template_include( string $template = '' ): string { + $presets = $this->get_template_presets(); + + $file_name = $presets['file_name']; + $dir_path = $presets['dir_path'] ?? $this->plugin_template_dir; + + // Check if the theme provides a custom template. + $theme_template = $this->get_template_from_theme( $file_name ); + if ( $theme_template ) { + return $theme_template; + } + + // Check if the plugin has a template file. + $plugin_template = $this->get_template_from_plugin( $file_name, $dir_path, ); + if ( $plugin_template ) { + return $plugin_template; + } + + // Fallback to the default template. + return $template; + } + + + /** + * Retrieve template presets by invoking the callback. + * + * @return array Template preset data including file_name and optional dir_path. + */ + protected function get_template_presets(): array { + return ( $this->callback )(); + } + + /** + * Locate a template in the theme or child theme. + * + * @todo Maybe better put in the Utility class? + * + * @param string $file_name The name of the template file. + * @return string The path to the theme template or an empty string if not found. + */ + protected function get_template_from_theme( string $file_name ): string { + + // locate_template() doesn't cares, + // but locate_block_template() needs this to be an array. + $templates = array( $file_name ); + + // First, search for PHP templates, which block themes can also use. + $template = locate_template( $templates ); + + // Pass the result into the block template locator and let it figure + // out whether block templates are supported and this template exists. + $template = locate_block_template( + $template, + pathinfo( $file_name, PATHINFO_FILENAME ), // Name of the file without extension. + $templates + ); + + return $template; + } + + /** + * Build the full path to the plugin's template file. + * + * @todo Maybe better put in the Utility class? + * + * @param string $file_name The name of the template file. + * @param string $dir_path The directory path where the template is stored. + * @return string The full path to the template file or an empty string if file not exists. + */ + protected function get_template_from_plugin( string $file_name, string $dir_path ): string { + // Remove prefix to keep file-names simple, + // for templates of core GatherPress. + if ( $this->plugin_template_dir === $dir_path ) { + $file_name = Utility::unprefix_key( $file_name ); + } + + $template = trailingslashit( $dir_path ) . $file_name; + return file_exists( $template ) ? $template : ''; + } +} diff --git a/includes/core/classes/endpoints/class-endpoint-type.php b/includes/core/classes/endpoints/class-endpoint-type.php new file mode 100644 index 000000000..1630a4e30 --- /dev/null +++ b/includes/core/classes/endpoints/class-endpoint-type.php @@ -0,0 +1,135 @@ +slug = $slug; + $this->callback = $callback; + } + + /** + * Activate Endpoint_Type by hooking into relevant parts. + * + * @since 1.0.0 + * + * @param Endpoint|null $endpoint Class for custom rewrite endpoints and their query handling in GatherPress. + * @return void + */ + abstract public function activate( ?Endpoint $endpoint = null ): void; + + + /** + * Checks if the given endpoint type is an instance of the specified class. + * + * This method verifies whether the provided `$type` is an instance of the `$entity` + * class. It first checks if the `$entity` exists in the defined list of valid endpoint + * classes by calling `is_in_class()`. If the entity is valid, it further checks if the + * `$type` is an instance of that class. + * + * @since 1.0.0 + * + * @param string $entity The class name of the entity to check against (e.g., 'Endpoint_Redirect' or 'Endpoint_Template'). + * @return bool True if the `$type` is an instance of the `$entity` class, false otherwise. + */ + public function is_of_class( string $entity ): bool { + return self::is_in_class( $entity ) && $this instanceof $entity; + } + + /** + * Checks if the given entity is a valid endpoint class in the current namespace. + * + * This method verifies whether the provided `$entity` exists in the predefined list + * of valid endpoint classes within the current namespace. It helps ensure that only + * valid classes (like `Endpoint_Redirect` or `Endpoint_Template`) are used when + * checking endpoint types. + * + * @since 1.0.0 + * + * @param string $entity The class name of the entity to check (e.g., 'Endpoint_Redirect' or 'Endpoint_Template'). + * @return bool True if the `$entity` is a valid endpoint class, false otherwise. + */ + private static function is_in_class( string $entity ): bool { + return in_array( + $entity, + array( + __NAMESPACE__ . '\Endpoint_Redirect', + __NAMESPACE__ . '\Endpoint_Template', + ), + true + ); + } +} diff --git a/includes/core/classes/endpoints/class-endpoint.php b/includes/core/classes/endpoints/class-endpoint.php new file mode 100644 index 000000000..a0744193a --- /dev/null +++ b/includes/core/classes/endpoints/class-endpoint.php @@ -0,0 +1,444 @@ +is_valid_registration( $type_name, $types, $object_type ) ) { + $this->query_var = $query_var; + $this->validation_callback = $validation_callback; + $this->types = $types; + $this->reg_ex = $reg_ex; + $this->object_type = $object_type; + + // Maybe its pointless to hook this onto the next round? + // $this->setup_hooks(); + // Maybe just start ? + $this->init(); + } + } + + /** + * Set up hooks for various purposes. + * + * This method adds hooks for different purposes as needed. + * + * @since 1.0.0 + * + * @return void + + protected function setup_hooks(): void { + global $wp_filter; + $current_filter = current_filter(); + $current_priority = $wp_filter[ $current_filter ]->current_priority(); + + add_action( $current_filter, array( $this, 'init' ), $current_priority + 1 ); + } */ + + /** + * Initializes the endpoint by registering rewrite rules and handling query variables. + * + * The method generates rewrite rules for the endpoint based on the post type or taxonomy rewrite base + * and matches against the provided slugs. It also filters allowed query variables to include the custom query variable for the endpoint. + * The method hooks into the `template_redirect` action to handles template loading + * or redirecting based on the endpoint type. + * + * @since 1.0.0 + * + * @return void + */ + public function init(): void { + + // Build the regular expression pattern for matching the custom endpoint URL structure. + $reg_ex_pattern = $this->get_regex_pattern(); + + // Define the URL structure for handling matched requests via query vars. + // Example result: 'index.php?gatherpress_event=$matches[1]&gatherpress_ext_calendar=$matches[2]'. + $rewrite_url = add_query_arg( $this->get_rewrite_atts(), 'index.php' ); + + // Add the rewrite rule to WordPress. + add_rewrite_rule( $reg_ex_pattern, $rewrite_url, 'top' ); + + // Trigger a flush of rewrite rules. + $this->maybe_flush_rewrite_rules( $reg_ex_pattern, $rewrite_url ); + + // Allow the custom query variable by filtering the public query vars. + add_filter( 'query_vars', array( $this, 'allow_query_vars' ) ); + + // Handle whether to include a template or redirect the request. + add_action( 'template_redirect', array( $this, 'template_redirect' ) ); + } + + /** + * Build the regular expression pattern for matching the custom endpoint URL structure, + * based on the rewrite base (slug) for the post type or taxonomy. + * + * @return string + */ + private function get_regex_pattern(): string { + $rewrite_base = $this->type_object->rewrite['slug']; + $slugs = join( '|', $this->get_slugs() ); + return sprintf( + $this->reg_ex, + $rewrite_base, + $slugs + ); + } + + /** + * Defines the rewrite replacement attributes for the custom feed endpoint. + * + * This method defines the rewrite replacement attributes + * for the custom feed endpoint to be further processed by add_rewrite_rule(). + * + * @since 1.0.0 + * + * @return array The rewrite replacement attributes for add_rewrite_rule(). + */ + public function get_rewrite_atts(): array { + return array( + $this->type_object->name => '$matches[1]', + $this->query_var => '$matches[2]', + ); + } + + /** + * Creates a flag option to indicate that rewrite rules need to be flushed. + * + * This method checks if the generated rewrite rules already exist in the DB, + * and if its rewrite URL matches the recently generated rewrite URL. + * If any of this checks fail, the option to flush the rewrite rules will be set. + * + * This method DOES NO checks if the 'gatherpress_flush_rewrite_rules_flag' option + * exists. It just adds the option and sets it to true. This flag + * is being used to determine when rewrite rules should be flushed. + * + * @since 1.0.0 + * + * @param string $reg_ex_pattern The regular expression pattern for matching the custom endpoint URL structure. + * @param string $rewrite_url The URL structure for handling matched requests via query vars. + * @return void + */ + private function maybe_flush_rewrite_rules( string $reg_ex_pattern, string $rewrite_url ): void { + $rules = get_option( 'rewrite_rules' ); + + if ( ! isset( $rules[ $reg_ex_pattern ] ) || $rules[ $reg_ex_pattern ] !== $rewrite_url ) { + // Event_Setup->maybe_create_flush_rewrite_rules_flag // @todo maybe make this a public method ?! + // @see https://github.com/GatherPress/gatherpress/blob/3d91f2bcb30b5d02ebf459cd5a42d4f43bc05ea5/includes/core/classes/class-settings.php#L760C1-L761C63 . + add_option( 'gatherpress_flush_rewrite_rules_flag', true ); + } + } + + /** + * Validates the registration of the endpoint based on timing, object type and given endpoint types. + * + * This method ensures that: + * - The action `init` has been fired, meaning the WordPress environment is fully set up. + * - The provided object type (post type or taxonomy) is registered. + * - Rewrites are enabled for the object type (e.g., post type or taxonomy) to support custom endpoints. + * + * If the validation fails, appropriate warnings are triggered using `wp_trigger_error()`. + * + * @since 1.0.0 + * + * @param string $type_name The name of the post type or taxonomy to validate. + * @param array $types Array of endpoint types to register (redirects/templates). + * @param string $object_type The type of object ('post' or 'taxonomy'). + * @return bool Returns true if registration is valid, false otherwise. + */ + private function is_valid_registration( string $type_name, array $types, string $object_type ): bool { + + if ( 0 === did_action( 'init' ) ) { + wp_trigger_error( + __CLASS__, + 'was called too early! Run on init to make all the rewrite-vodoo work.', + E_USER_WARNING + ); + return false; + } + + if ( empty( $types ) ) { + wp_trigger_error( + __CLASS__, + 'can not be called without endpoint types. Add at least one of either "Endpoint_Redirect" or "Endpoint_Template" to the list of types.', + E_USER_WARNING + ); + return false; + } + + if ( ! in_array( $object_type, array( 'post_type', 'taxonomy' ), true ) ) { + wp_trigger_error( + __CLASS__, + "called on '$type_name' doesn't work, because '$object_type' is no supported object type. Use either 'post_type' or 'taxonomy'.", + E_USER_WARNING + ); + return false; + } + + // Store the validated post type or taxonomy object for later use. + switch ( $object_type ) { + case 'taxonomy': + $this->type_object = get_taxonomy( $type_name ); + break; + + case 'post_type': + $this->type_object = get_post_type_object( $type_name ); + break; + } + + if ( ! $this->type_object instanceof WP_Post_Type && ! $this->type_object instanceof WP_Taxonomy ) { + wp_trigger_error( + __CLASS__, + "was called too early! Make sure the '$type_name' $object_type is already registered.", + E_USER_WARNING + ); + return false; + } + + if ( false === $this->type_object->rewrite ) { + wp_trigger_error( + __CLASS__, + "called on '$type_name' doesn't work, because this $object_type has rewrites disabled.", + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Filters the query variables allowed before processing. + * + * Adds the custom query variable used by the endpoint to the list of allowed + * public query variables so that it can be recognized and used by WordPress. + * + * @since 1.0.0 + * + * @param string[] $public_query_vars The array of allowed query variable names. + * @return string[] The updated array of allowed query variable names. + */ + public function allow_query_vars( array $public_query_vars ): array { + $public_query_vars[] = $this->query_var; + return $public_query_vars; + } + + /** + * Fires before determining which template to load or whether to redirect. + * + * This method is responsible for: + * - Validating the query to ensure the endpoint is correctly matched. + * - Performing redirects if the current endpoint has associated redirects. + * - Loading a custom template if the endpoint defines one. + * + * @since 1.0.0 + * + * @see https://developer.wordpress.org/reference/hooks/template_redirect/ + * + * @return void + */ + public function template_redirect(): void { + + if ( ! $this->is_valid_query() ) { + return; + } + + // Get the currently requested endpoint from the list of registered endpoint types. + $endpoint_type = current( + wp_list_filter( + $this->types, + array( + 'slug' => get_query_var( $this->query_var ), + ) + ) + ); + $endpoint_type->activate( $this ); + } + + /** + * Determine whether the endpoint is meant for a feed + * and if it has a proper Endpoint_Template defined. + * + * @return string The slug of the endpoint or an empty string if not a feed template. + */ + public function has_feed(): string { + if ( false !== strpos( $this->reg_ex, '/feed/' ) ) { + $feed_slug = current( $this->get_slugs( __NAMESPACE__ . '\Endpoint_Template' ) ); + if ( ! empty( $feed_slug ) ) { + return $feed_slug; + } + } + return ''; + } + + /** + * Checks if the current query is valid for this endpoint. + * + * This method uses the validation callback provided during construction + * to ensure that the query is valid. It also checks if the custom query + * variable is populated. + * + * @since 1.0.0 + * + * @return bool True if the query is valid, false otherwise. + */ + public function is_valid_query(): bool { + return ( $this->validation_callback )() && ! empty( get_query_var( $this->query_var ) ); + } + + /** + * Retrieves the slugs of the specified endpoint types. + * + * This method filters the `types` array to get the slugs for either a specific type of endpoint + * (e.g., `Endpoint_Redirect` or `Endpoint_Template`) or returns slugs for all types if no type + * is specified. + * + * @since 1.0.0 + * + * @param string|null $entity Optional. The class name of the endpoint type to filter by (e.g., 'Endpoint_Redirect' or 'Endpoint_Template'). + * If null, it retrieves slugs for all types. + * @return string[] An array of slugs for the specified or all types. + */ + protected function get_slugs( ?string $entity = null ): array { + // Determine Endpoint_Types to get slug names from. + $types = ( null === $entity ) + // All? + ? $this->types + // Or a specific type? + : array_filter( + $this->types, + function ( $type ) use ( $entity ) { + return $type->is_of_class( $entity ); + } + ); + return wp_list_pluck( $types, 'slug' ); + } +} diff --git a/includes/core/classes/endpoints/class-posttype-feed-endpoint.php b/includes/core/classes/endpoints/class-posttype-feed-endpoint.php new file mode 100644 index 000000000..e1c8f5fdd --- /dev/null +++ b/includes/core/classes/endpoints/class-posttype-feed-endpoint.php @@ -0,0 +1,96 @@ +type_object->name ) && is_feed(); + } + + /** + * Defines the rewrite replacement attributes for the custom feed endpoint. + * + * This method defines the rewrite replacement attributes + * for the custom feed endpoint to be further processed by add_rewrite_rule(). + * + * @since 1.0.0 + * + * @return array The rewrite replacement attributes for add_rewrite_rule(). + */ + public function get_rewrite_atts(): array { + return array( + $this->object_type => $this->type_object->name, + 'feed' => '$matches[1]', + ); + } +} diff --git a/includes/core/classes/endpoints/class-posttype-single-endpoint.php b/includes/core/classes/endpoints/class-posttype-single-endpoint.php new file mode 100644 index 000000000..4c7a00a92 --- /dev/null +++ b/includes/core/classes/endpoints/class-posttype-single-endpoint.php @@ -0,0 +1,79 @@ +type_object->name ); + } +} diff --git a/includes/core/classes/endpoints/class-posttype-single-feed-endpoint.php b/includes/core/classes/endpoints/class-posttype-single-feed-endpoint.php new file mode 100644 index 000000000..da28777f7 --- /dev/null +++ b/includes/core/classes/endpoints/class-posttype-single-feed-endpoint.php @@ -0,0 +1,95 @@ +type_object->name ) && is_feed(); + } + + /** + * Defines the rewrite replacement attributes for the custom feed endpoint. + * + * This method defines the rewrite replacement attributes + * for the custom feed endpoint to be further processed by add_rewrite_rule(). + * + * @since 1.0.0 + * + * @return array The rewrite replacement attributes for add_rewrite_rule(). + */ + public function get_rewrite_atts(): array { + return array( + $this->object_type => $this->type_object->name, + $this->type_object->name => '$matches[1]', + 'feed' => '$matches[2]', + ); + } +} diff --git a/includes/core/classes/endpoints/class-taxonomy-feed-endpoint.php b/includes/core/classes/endpoints/class-taxonomy-feed-endpoint.php new file mode 100644 index 000000000..b59242b47 --- /dev/null +++ b/includes/core/classes/endpoints/class-taxonomy-feed-endpoint.php @@ -0,0 +1,96 @@ +type_object->name ) && is_feed(); + } + + /** + * Defines the rewrite replacement attributes for the custom feed endpoint. + * + * This method defines the rewrite replacement attributes + * for the custom feed endpoint to be further processed by add_rewrite_rule(). + * + * @since 1.0.0 + * + * @return array The rewrite replacement attributes for add_rewrite_rule(). + */ + public function get_rewrite_atts(): array { + return array( + $this->object_type => $this->type_object->name, + $this->type_object->name => '$matches[1]', + 'feed' => '$matches[2]', + ); + } +} diff --git a/includes/templates/endpoints/ical-download.php b/includes/templates/endpoints/ical-download.php new file mode 100644 index 000000000..f5122a1bc --- /dev/null +++ b/includes/templates/endpoints/ical-download.php @@ -0,0 +1,21 @@ +save_datetimes( $params ); - $output = $event->get_calendar_links(); - + /* $expected_google_link = 'https://www.google.com/calendar/event?action=TEMPLATE&text=Unit%20Test%20Event&dates=20200511T150000Z%2F20200511T170000Z&details=' . rawurlencode( $description ) . '&location=Unit%20Test%20Venue%2C%20123%20Main%20Street%2C%20Montclair%2C%20NJ%2007042&sprop=name%3A'; $expected_yahoo_link = 'https://calendar.yahoo.com/?v=60&view=d&type=20&title=Unit%20Test%20Event&st=20200511T150000Z&dur=0200&desc=' . rawurlencode( $description ) . '&in_loc=Unit%20Test%20Venue%2C%20123%20Main%20Street%2C%20Montclair%2C%20NJ%2007042'; @@ -444,7 +440,56 @@ public function test_get_calendar_links(): void { 'link' => $expected_yahoo_link, ), ); + */ + $output = $event->get_calendar_links(); + $expects = array( + 'google' => array( + 'name' => 'Google Calendar', + 'link' => home_url( '/?gatherpress_event=unit-test-event&gatherpress_calendars=google-calendar' ), + ), + 'ical' => array( + 'name' => 'iCal', + 'download' => home_url( '/?gatherpress_event=unit-test-event&gatherpress_calendars=ical' ), + ), + 'outlook' => array( + 'name' => 'Outlook', + 'download' => home_url( '/?gatherpress_event=unit-test-event&gatherpress_calendars=outlook' ), + ), + 'yahoo' => array( + 'name' => 'Yahoo Calendar', + 'link' => home_url( '/?gatherpress_event=unit-test-event&gatherpress_calendars=yahoo-calendar' ), + ), + ); + + $this->assertSame( $expects, $output ); + // Update permalink structure to '/%postname%/'. + update_option( 'permalink_structure', '/%postname%/' ); + + // Reload the global rewrite rules object to ensure it reflects the changes. + global $wp_rewrite; + $wp_rewrite->init(); + flush_rewrite_rules(); + + $output = $event->get_calendar_links(); + $expects = array( + 'google' => array( + 'name' => 'Google Calendar', + 'link' => home_url( '/event/unit-test-event/google-calendar' ), + ), + 'ical' => array( + 'name' => 'iCal', + 'download' => home_url( '/event/unit-test-event/ical' ), + ), + 'outlook' => array( + 'name' => 'Outlook', + 'download' => home_url( '/event/unit-test-event/outlook' ), + ), + 'yahoo' => array( + 'name' => 'Yahoo Calendar', + 'link' => home_url( '/event/unit-test-event/yahoo-calendar' ), + ), + ); $this->assertSame( $expects, $output ); Utility::set_and_get_hidden_property( $event, 'event', null ); diff --git a/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-redirect.php b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-redirect.php new file mode 100644 index 000000000..f050c7974 --- /dev/null +++ b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-redirect.php @@ -0,0 +1,94 @@ +assertSame( + $slug, + $instance->slug, + 'Failed to assert, that the endpoint slug is persisted.' + ); + + $this->assertSame( + $callback, + Utility::get_hidden_property( $instance, 'callback' ), + 'Failed to assert, that the endpoint callback is persisted.' + ); + } + + /** + * Coverage for activate method. + * + * @covers ::activate + * + * @return void + */ + public function test_activate(): void { + $slug = 'endpoint-redirect'; + $callback = function () { + return 'https://example.org/'; + }; + $instance = new Endpoint_Redirect( $slug, $callback ); + + $this->assert_redirect_to( + 'https://example.org/', + array( $instance, 'activate' ) + ); + } + + /** + * Coverage for allowed_redirect_hosts method. + * + * @covers ::allowed_redirect_hosts + * + * @return void + */ + public function test_allowed_redirect_hosts(): void { + $slug = 'endpoint-redirect'; + $callback = function () { + return 'https://example.org/'; + }; + $instance = new Endpoint_Redirect( $slug, $callback ); + Utility::set_and_get_hidden_property( $instance, 'url', ( $callback )() ); + + $this->assertSame( + array( + 'apples', + 'oranges', + 'example.org', + ), + $instance->allowed_redirect_hosts( array( 'apples', 'oranges' ) ), + 'Failed to assert, that the redirect url got merged into allowed_redirect_hosts.' + ); + } +} diff --git a/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-template.php b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-template.php new file mode 100644 index 000000000..8e7c9efa9 --- /dev/null +++ b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-template.php @@ -0,0 +1,107 @@ + 'endpoint-template.php', + 'dir_path' => '/path/to/theme', + ); + }; + $instance = new Endpoint_Template( $slug, $callback ); + + $this->assertIsString( Utility::get_hidden_property( $instance, 'plugin_template_dir' ) ); + $this->assertNotEmpty( Utility::get_hidden_property( $instance, 'plugin_template_dir' ) ); + $this->assertSame( + sprintf( + '%s/includes/templates/endpoints', + GATHERPRESS_CORE_PATH + ), + Utility::get_hidden_property( $instance, 'plugin_template_dir' ), + 'Failed to assert, plugin_template_dir is set to fallback directory.' + ); + + $plugin_default = '/mock/plugin/templates'; + $instance = new Endpoint_Template( $slug, $callback, $plugin_default ); + + $this->assertSame( + '/mock/plugin/templates', + Utility::get_hidden_property( $instance, 'plugin_template_dir' ), + 'Failed to assert, plugin_template_dir is set to test directory.' + ); + } + + /** + * Coverage for activate method. + * + * @covers ::activate + * + * @return void + */ + public function test_activate(): void { + $slug = 'endpoint-template'; + $callback = function () { + return array( + 'file_name' => 'endpoint-template.php', + 'dir_path' => '/path/to/theme', + ); + }; + $instance = new Endpoint_Template( $slug, $callback ); + } + + /** + * Tests template_include method when the theme has the template. + * + * @covers ::template_include + * + * @return void + */ + public function test_template_include_with_theme_template(): void { + $slug = 'custom-endpoint'; + $callback = function () { + return array( + 'file_name' => 'endpoint-template.php', + 'dir_path' => '/path/to/theme', + ); + }; + $plugin_default = '/mock/plugin/templates'; + $template_default = '/default/template.php'; + + $instance = new Endpoint_Template( $slug, $callback, $plugin_default ); + + // Simulate theme template existing. + // ...???? + + $template = $instance->template_include( $template_default ); + + // Assert that the theme template is used. + // $this->assertSame('/path/to/theme/theme-endpoint-template.php', $template); // ..???? + $this->assertSame( '/default/template.php', $template ); + } +} diff --git a/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-type.php b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-type.php new file mode 100644 index 000000000..f69267b33 --- /dev/null +++ b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint-type.php @@ -0,0 +1,93 @@ + 'endpoint-template.php', + 'dir_path' => '/path/to/theme', + ); + }; + + // Create a mock for Endpoint. + $endpoint_template = new Endpoint_Template( $slug, $callback ); + + $this->assertIsString( $endpoint_template->slug ); + $this->assertIsCallable( Utility::get_hidden_property( $endpoint_template, 'callback' ) ); + } + + /** + * Coverage for is_of_class method. + * + * @covers ::is_of_class + * + * @return void + */ + public function test_is_of_class(): void { + $instance = new Endpoint_Redirect( 'slug', function () {} ); + + $this->assertTrue( + Utility::invoke_hidden_method( $instance, 'is_of_class', array( 'GatherPress\Core\Endpoints\Endpoint_Redirect' ) ), + 'Failed to validate class in namespace.' + ); + + $this->assertFalse( + Utility::invoke_hidden_method( $instance, 'is_of_class', array( 'GatherPress\Core\Endpoints\Endpoint_Template' ) ), + 'Failed to validate non-used class in namespace.' + ); + } + + /** + * Coverage for is_in_class method. + * + * @covers ::is_in_class + * + * @return void + */ + public function test_is_in_class(): void { + $instance = new Endpoint_Redirect( 'slug', function () {} ); + + $this->assertTrue( + Utility::invoke_hidden_method( $instance, 'is_in_class', array( 'GatherPress\Core\Endpoints\Endpoint_Redirect' ) ), + 'Failed to validate class in namespace.' + ); + + $this->assertTrue( + Utility::invoke_hidden_method( $instance, 'is_in_class', array( 'GatherPress\Core\Endpoints\Endpoint_Template' ) ), + 'Failed to validate class in namespace.' + ); + + $this->assertFalse( + Utility::invoke_hidden_method( $instance, 'is_in_class', array( 'WP_Post' ) ), + 'Failed to validate class is not in namespace.' + ); + } +} diff --git a/test/unit/php/includes/core/classes/endpoints/class-test-endpoint.php b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint.php new file mode 100644 index 000000000..52091fdea --- /dev/null +++ b/test/unit/php/includes/core/classes/endpoints/class-test-endpoint.php @@ -0,0 +1,397 @@ +assertSame( $query_var, $instance->query_var, 'Failed to assert that query_var is persisted.' ); + $this->assertSame( get_post_type_object( $post_type ), $instance->type_object, 'Failed to assert that type_object is persisted.' ); + $this->assertSame( $callback, $instance->validation_callback, 'Failed to assert that validation_callback is persisted.' ); + $this->assertSame( $types, $instance->types, 'Failed to assert that endpoint types are persisted.' ); + $this->assertSame( $reg_ex, $instance->reg_ex, 'Failed to assert that reg_ex is persisted.' ); + $this->assertSame( 'post_type', $instance->object_type, 'Failed to assert that object_type is set by default.' ); + } + + /** + * Coverage for get_regex_pattern method. + * + * @covers ::get_regex_pattern + * + * @return void + */ + public function test_get_regex_pattern(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + // Regular expression to match singular event endpoints. + // Example: 'event/my-sample-event/(custom-endpoint)(/)'. + $reg_ex = '%s/([^/]+)/(%s)/?$'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertSame( + 'event/([^/]+)/(endpoint_template_1|endpoint_template_2|endpoint_redirect_1)/?$', + Utility::invoke_hidden_method( $instance, 'get_regex_pattern' ), + 'Failed to assert that the generated regex pattern matches.' + ); + } + + /** + * Coverage for get_rewrite_atts method. + * + * @covers ::get_rewrite_atts + * + * @return void + */ + public function test_get_rewrite_atts(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertSame( + array( + 'gatherpress_event' => '$matches[1]', + 'query_var' => '$matches[2]', + ), + $instance->get_rewrite_atts(), + 'Failed to assert that rewrite attributes match.' + ); + } + + /** + * Coverage for maybe_flush_rewrite_rules method. + * + * @covers ::maybe_flush_rewrite_rules + * + * @return void + */ + public function test_maybe_flush_rewrite_rules(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + // Regular expression to match singular event endpoints. + // Example: 'event/my-sample-event/(custom-endpoint)(/)'. + $reg_ex = '%s/([^/]+)/(%s)/?$'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + delete_option( 'rewrite_rules' ); + delete_option( 'gatherpress_flush_rewrite_rules_flag' ); + + $this->assertEmpty( get_option( 'rewrite_rules' ), 'Failed to assert that rewrite_rules are unset.' ); + $this->assertFalse( get_option( 'gatherpress_flush_rewrite_rules_flag' ), 'Failed to assert that GatherPress\' flag to flush the rewrite_rules is unset.' ); + + // Build the regular expression pattern for matching the custom endpoint URL structure. + $reg_ex_pattern = Utility::invoke_hidden_method( $instance, 'get_regex_pattern' ); + + // Define the URL structure for handling matched requests via query vars. + // Example result: 'index.php?gatherpress_event=$matches[1]&gatherpress_ext_calendar=$matches[2]'. + $rewrite_url = add_query_arg( $instance->get_rewrite_atts(), 'index.php' ); + + Utility::invoke_hidden_method( $instance, 'maybe_flush_rewrite_rules', array( $reg_ex_pattern, $rewrite_url ) ); + $this->assertTrue( get_option( 'gatherpress_flush_rewrite_rules_flag' ), 'Failed to assert that GatherPress\' flag to flush the rewrite_rules is set now.' ); + + // Normally done automatically via ... + flush_rewrite_rules( false ); + delete_option( 'gatherpress_flush_rewrite_rules_flag' ); + + $this->assertContains( + $reg_ex_pattern, + array_keys( get_option( 'rewrite_rules' ) ), + 'Failed to assert that the GatherPress rewrite_rules are now part of the rewrite_rules option.' + ); + $this->assertSame( + $rewrite_url, + get_option( 'rewrite_rules' )[ $reg_ex_pattern ], + 'Failed to assert that the GatherPress rewrite_rules have been saved correctly.' + ); + + // Run again. + Utility::invoke_hidden_method( $instance, 'maybe_flush_rewrite_rules', array( $reg_ex_pattern, $rewrite_url ) ); + $this->assertFalse( get_option( 'gatherpress_flush_rewrite_rules_flag' ), 'Failed to assert that the GatherPress\' flag to flush the rewrite_rules is not set again after the rewrite_rules were flushed.' ); + } + + /** + * Coverage for allow_query_vars method. + * + * @covers ::allow_query_vars + * + * @return void + */ + public function test_allow_query_vars(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertSame( + array( + 'apples', + 'oranges', + 'query_var', + ), + $instance->allow_query_vars( array( 'apples', 'oranges' ) ), + 'Failed to assert that merged query variables match.' + ); + } + + /** + * Coverage for has_feed method. + * + * @covers ::has_feed + * + * @return void + */ + public function test_has_feed(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertEmpty( + $instance->has_feed(), + 'Failed to assert, endpoint is not for feeds.' + ); + + $types = array( + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex/feed/'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertEmpty( + $instance->has_feed(), + 'Failed to assert, endpoint is for feeds, but has no Endpoint_Template type.' + ); + + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + ); + $reg_ex = 'reg_ex/feed/'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertSame( + 'endpoint_template_1', + $instance->has_feed(), + 'Failed to assert, that feed template is found.' + ); + } + + /** + * Coverage for is_valid_query method. + * + * @covers ::is_valid_query + * + * @return void + */ + public function test_is_valid_query(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = '__return_true'; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->mock->wp( + array( + 'query_vars' => array( + $query_var => 'endpoint_template_1', + ), + ) + ); + + $this->assertTrue( + $instance->is_valid_query(), + 'Failed to validate the prepared query.' + ); + + $callback = '__return_false'; + $instance_2 = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertFalse( + $instance_2->is_valid_query(), + 'Failed to validate the prepared query.' + ); + $this->mock->wp()->reset(); + + $this->mock->wp( + array( + 'is_category' => true, + 'query_vars' => array( + 'cat' => 'category-slug', + ), + ) + ); + + $this->assertFalse( + $instance->is_valid_query(), + 'Failed to validate the prepared query.' + ); + + $this->mock->wp()->reset(); + } + + /** + * Coverage for get_slugs method. + * + * @covers ::get_slugs + * + * @return void + */ + public function test_get_slugs(): void { + $query_var = 'query_var'; + $post_type = 'gatherpress_event'; + $callback = function () {}; + $types = array( + new Endpoint_Template( 'endpoint_template_1', $callback ), + new Endpoint_Template( 'endpoint_template_2', $callback ), + new Endpoint_Redirect( 'endpoint_redirect_1', $callback ), + ); + $reg_ex = 'reg_ex'; + $instance = new Endpoint( + $query_var, + $post_type, + $callback, + $types, + $reg_ex, + ); + + $this->assertSame( + array( + 'endpoint_template_1', + 'endpoint_template_2', + 'endpoint_redirect_1', + ), + Utility::invoke_hidden_method( $instance, 'get_slugs' ), + 'Failed to assert that endpoint slugs match.' + ); + } +}