$vars Existing query vars. * @return array */ public function register_query_vars( array $vars ): array { $vars[] = self::QUERY_VAR; return $vars; } /** * Inject a virtual WP_Post for event pages. * * Fires on the_posts filter. When the webcal_event_id query var is present, * replaces the empty results with a virtual post so WordPress renders the * event through the theme's page template. * * @param array<\WP_Post> $posts Posts from the main query. * @param \WP_Query $query The query object. * @return array<\WP_Post> */ public function inject_virtual_post( array $posts, \WP_Query $query ): array { if ( is_admin() || ! $query->is_main_query() ) { return $posts; } $event_id_raw = $query->get( self::QUERY_VAR ); if ( $event_id_raw === '' || $event_id_raw === 0 || $event_id_raw === '0' ) { return $posts; } $event_id = is_numeric( $event_id_raw ) ? (int) $event_id_raw : 0; $event = $this->get_event( $event_id ); if ( ! $event ) { return $posts; } if ( self::creator_opted_out( $event->createdBy() ) ) { return $posts; } // In 'public' mode, only public events get pages if ( self::get_mode() === 'public' && $event->access()->value !== 'P' ) { return $posts; } // Non-public events require authentication and access rights if ( $event->access()->value !== 'P' ) { if ( ! is_user_logged_in() ) { return $posts; } // Logged-in user must be owner or participant $current_login = wp_get_current_user()->user_login; if ( $event->createdBy() !== $current_login ) { $container = Container::get_instance(); $part_repo = $container->get_participant_repository(); $participants = $part_repo->get_participants( $event->id()->value() ); if ( ! in_array( $current_login, $participants, true ) ) { return $posts; } } } // For recurring events, override the display date when ?date= is provided // so the single event page shows the occurrence the user clicked, not the // base event's original date. $occurrence_date = $this->resolve_occurrence_date( $event ); if ( $occurrence_date !== null ) { $event = $this->with_occurrence_date( $event, $occurrence_date ); } // Store event and occurrence date for meta tag output $GLOBALS['webcal_current_event'] = $event; $GLOBALS['webcal_current_occurrence_date'] = $occurrence_date; // Enqueue Leaflet for OpenStreetMap embed when event has a physical location $location_type = self::classify_location( $event->location() ); if ( $event->location() !== '' && 'physical' === $location_type ) { $map_prov_raw = get_option( 'webcal_map_provider', 'osm' ); $map_prov = is_scalar( $map_prov_raw ) ? (string) $map_prov_raw : 'osm'; if ( $map_prov === 'osm' ) { $plugin_url = plugin_dir_url( __DIR__ ); wp_enqueue_style( 'leaflet', $plugin_url . 'vendor-assets/leaflet/leaflet.css', [], '1.9.4' ); wp_enqueue_script( 'leaflet', $plugin_url . 'vendor-assets/leaflet/leaflet.js', [], '1.9.4', true ); $loc_json_raw = wp_json_encode( $event->location() ); $loc_json = $loc_json_raw === false ? '""' : $loc_json_raw; $fallback_msg_raw = wp_json_encode( '

' . esc_html__( 'Map not available for this location.', 'agenticdaisy-calendar' ) . '

' ); $fallback_msg = $fallback_msg_raw === false ? '""' : $fallback_msg_raw; $map_init = "(function() {\n" . "var el = document.getElementById('webcal-osm-map');\n" . "if (!el) return;\n" . "var loc = {$loc_json};\n" . "var fb = {$fallback_msg};\n" . "fetch('https://nominatim.openstreetmap.org/search?q=' + encodeURIComponent(loc) + '&format=json&limit=1')\n" . ".then(function(r) { return r.json(); })\n" . ".then(function(d) {\n" . "if (!d || !d.length) { el.innerHTML = fb; return; }\n" . "var lat = parseFloat(d[0].lat), lon = parseFloat(d[0].lon);\n" . "var m = L.map(el).setView([lat, lon], 15);\n" . "L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {\n" . "attribution: '© OpenStreetMap contributors'\n" . "}).addTo(m);\n" . "L.marker([lat, lon]).addTo(m);\n" . "})\n" . ".catch(function() { el.innerHTML = fb; });\n" . '})();'; wp_add_inline_script( 'leaflet', $map_init ); } } // Build and inject the virtual post $virtual_post = $this->build_virtual_post( $event ); // Override WP_Query flags to simulate a singular page $query->is_page = true; $query->is_singular = true; $query->is_single = false; $query->is_attachment = false; $query->is_archive = false; $query->is_category = false; $query->is_tag = false; $query->is_tax = false; $query->is_author = false; $query->is_date = false; $query->is_year = false; $query->is_month = false; $query->is_day = false; $query->is_time = false; $query->is_search = false; $query->is_feed = false; $query->is_comment_feed = false; $query->is_trackback = false; $query->is_home = false; $query->is_404 = false; $query->is_embed = false; $query->is_paged = false; $query->found_posts = 1; $query->max_num_pages = 1; $query->post_count = 1; $query->queried_object = $virtual_post; $query->queried_object_id = $virtual_post->ID; add_filter( 'document_title_parts', function ( array $parts ) use ( $event ): array { $parts['title'] = $event->name(); return $parts; } ); add_filter( 'pre_get_document_title', function () use ( $event ): string { return $event->name() . ' - ' . get_bloginfo( 'name' ); } ); return [ $virtual_post ]; } /** * Build a virtual WP_Post object for an event. * * @param Event $event The event entity. * @return \WP_Post */ private function build_virtual_post( Event $event ): \WP_Post { $container = Container::get_instance(); $part_repo = $container->get_participant_repository(); $participants = $part_repo->get_participants( $event->id()->value() ); $post_content = $this->build_post_content( $event, $participants ); return new \WP_Post( (object) [ 'ID' => 0, 'post_author' => '0', 'post_date' => $event->start()->format( 'Y-m-d H:i:s' ), 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $event->start()->getTimestamp() ), 'post_content' => $post_content, 'post_title' => $event->name(), 'post_excerpt' => wp_trim_words( wp_strip_all_tags( $event->description() ), 30, '...' ), 'post_status' => 'publish', 'comment_status' => 'closed', 'ping_status' => 'closed', 'post_password' => '', 'post_name' => sanitize_title( $event->name() ), 'to_ping' => '', 'pinged' => '', 'post_modified' => current_time( 'mysql' ), 'post_modified_gmt' => current_time( 'mysql', true ), 'post_content_filtered' => '', 'post_parent' => 0, 'guid' => self::get_event_url( $event ), 'menu_order' => 0, 'post_type' => 'page', 'post_mime_type' => '', 'comment_count' => '0', 'filter' => 'raw', ] ); } /** * Build the event detail HTML for post_content. * * The page title is rendered by the theme template from post_title, * so this method does not output an

. * * @param Event $event The event entity. * @param array $participants Participant logins. * @return string */ private function build_post_content( Event $event, array $participants = [] ): string { // Build "Add to Calendar" URLs $start_utc = $event->start()->format( 'Ymd\THis\Z' ); $end_utc = $event->end()->format( 'Ymd\THis\Z' ); $title_enc = rawurlencode( $event->name() ); $desc_enc = rawurlencode( wp_strip_all_tags( $event->description() ) ); $loc_enc = rawurlencode( $event->location() ); $google_url = 'https://www.google.com/calendar/render?action=TEMPLATE' . '&text=' . $title_enc . '&dates=' . $start_utc . '/' . $end_utc . '&details=' . $desc_enc . '&location=' . $loc_enc; $outlook_url = 'https://outlook.live.com/calendar/0/action/compose' . '?subject=' . $title_enc . '&startdt=' . rawurlencode( $event->start()->format( 'c' ) ) . '&enddt=' . rawurlencode( $event->end()->format( 'c' ) ) . '&body=' . $desc_enc . '&location=' . $loc_enc; $dur_secs = $event->end()->getTimestamp() - $event->start()->getTimestamp(); $dur_h = str_pad( (string) intdiv( max( 0, $dur_secs ), 3600 ), 2, '0', STR_PAD_LEFT ); $dur_m = str_pad( (string) intdiv( max( 0, $dur_secs ) % 3600, 60 ), 2, '0', STR_PAD_LEFT ); $yahoo_url = 'https://calendar.yahoo.com/?v=60' . '&title=' . $title_enc . '&st=' . $start_utc . '&dur=' . $dur_h . $dur_m . '&desc=' . $desc_enc . '&in_loc=' . $loc_enc; $ics_url = rest_url( 'webcal/v1/events/' . $event->id()->value() . '/export' ); // Build share URLs $event_url = self::get_event_url( $event ); $share_title = rawurlencode( $event->name() ); $share_url = rawurlencode( $event_url ); $share_desc = rawurlencode( wp_trim_words( wp_strip_all_tags( $event->description() ), 20, '...' ) ); $facebook_url = 'https://www.facebook.com/sharer/sharer.php?u=' . $share_url; $x_url = 'https://x.com/intent/tweet?text=' . $share_title . '&url=' . $share_url; $linkedin_url = 'https://www.linkedin.com/sharing/share-offsite/?url=' . $share_url; $email_url = 'mailto:?subject=' . $share_title . '&body=' . $share_desc . '%0A%0A' . $share_url; $map_provider_raw = get_option( 'webcal_map_provider', 'osm' ); $map_provider = is_scalar( $map_provider_raw ) ? (string) $map_provider_raw : 'osm'; $maps_api_key = get_option( 'webcal_google_maps_api_key', '' ); $fi_raw = get_option( 'webcal_featured_image_' . $event->id()->value(), 0 ); $featured_image_id = is_numeric( $fi_raw ) ? (int) $fi_raw : 0; wp_enqueue_style( 'webcal-single-event', WEBCALENDAR_WP_URL . 'includes/css/single-event.css', [], WEBCALENDAR_WP_VERSION ); ob_start(); ?>
← 0 ) : ?>
isAllDay() ) : ?> start()->format( 'l, F j, Y' ) ); ?> — start()->format( 'l, F j, Y' ) ); ?> — start()->format( 'g:i A' ) ); ?> – end()->format( 'g:i A' ) ); ?>
location() ); ?> location() !== '' ) : ?>
location() ); ?> ↗ location() ); ?>
location() !== '' && 'physical' === $loc_type ) : ?>
description() !== '' ) : ?>
description(); if ( $desc !== wp_strip_all_tags( $desc ) ) { echo wp_kses_post( $desc ); } else { echo wp_kses_post( nl2br( $desc ) ); } ?>

output_json_ld( $event, $url ); $this->output_open_graph( $event, $url ); $this->output_canonical( $url ); $this->output_meta_description( $event ); } /** * Output Schema.org Event JSON-LD. * * @param Event $event The event entity. */ private function output_json_ld( Event $event, string $url ): void { $schema = [ '@context' => 'https://schema.org', '@type' => 'Event', 'name' => $event->name(), 'url' => $url, 'startDate' => $event->start()->format( 'c' ), 'endDate' => $event->end()->format( 'c' ), ]; if ( $event->description() !== '' ) { $schema['description'] = wp_strip_all_tags( $event->description() ); } $loc_type = self::classify_location( $event->location() ); if ( $event->location() !== '' ) { if ( 'url' === $loc_type ) { $schema['location'] = [ '@type' => 'VirtualLocation', 'url' => $event->location(), ]; } elseif ( 'virtual' === $loc_type ) { $schema['location'] = [ '@type' => 'VirtualLocation', 'name' => $event->location(), ]; } else { $schema['location'] = [ '@type' => 'Place', 'name' => $event->location(), 'address' => $event->location(), ]; } } $image_url = self::get_event_image_url( $event ); if ( $image_url === '' ) { $image_url = self::get_fallback_image_url(); } if ( $image_url !== '' ) { $schema['image'] = $image_url; } $schema['eventStatus'] = 'https://schema.org/EventScheduled'; if ( 'physical' === $loc_type || '' === $event->location() ) { $schema['eventAttendanceMode'] = 'https://schema.org/OfflineEventAttendanceMode'; } else { $schema['eventAttendanceMode'] = 'https://schema.org/OnlineEventAttendanceMode'; } $schema['organizer'] = [ '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url(), ]; $json = wp_json_encode( $schema ); if ( false !== $json ) { echo '' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() is the recommended WP function for safe JSON output } } /** * Output Open Graph and Twitter Card meta tags. * * @param Event $event The event entity. */ private function output_open_graph( Event $event, string $url ): void { $title = $event->name(); $description = wp_trim_words( wp_strip_all_tags( $event->description() ), 30, '...' ); $site_name = (string) get_bloginfo( 'name' ); $date_str = $event->start()->format( 'F j, Y g:i A' ); if ( $description === '' ) { $description = sprintf( /* translators: 1: event date */ __( 'Event on %s', 'agenticdaisy-calendar' ), $date_str ); } echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; $event_image_url = self::get_event_image_url( $event ); $image_url = $event_image_url !== '' ? $event_image_url : self::get_fallback_image_url(); if ( $image_url !== '' ) { echo '' . "\n"; echo '' . "\n"; } $twitter_card = $event_image_url !== '' ? 'summary_large_image' : 'summary'; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; } /** * Output canonical URL tag, replacing WordPress's default. * * @param string $url The canonical URL. */ private function output_canonical( string $url ): void { remove_action( 'wp_head', 'rel_canonical' ); echo '' . "\n"; } /** * Output meta description tag. * * @param Event $event The event entity. */ private function output_meta_description( Event $event ): void { $description = wp_trim_words( wp_strip_all_tags( $event->description() ), 30, '...' ); $date_str = $event->start()->format( 'F j, Y g:i A' ); if ( $description === '' ) { $description = sprintf( /* translators: 1: event date */ __( 'Event on %s', 'agenticdaisy-calendar' ), $date_str ); } echo '' . "\n"; } /** * Get the featured image URL for an event, or empty string if none. * * Resolution order: * 1. A WP Media Library attachment the user picked in the admin UI * (stored in the `webcal_featured_image_{id}` option). * 2. The RFC 7986 IMAGE property imported from a VEVENT (stored in * the domain entity's `image` field via core's EventMapper). * Only HTTPS URLs are accepted to avoid mixed-content warnings * and to block local-path injection. * * @param Event $event The event entity. * @return string Image URL, or empty string. */ private static function get_event_image_url( Event $event ): string { $raw = get_option( 'webcal_featured_image_' . $event->id()->value(), 0 ); $attachment_id = is_numeric( $raw ) ? (int) $raw : 0; if ( $attachment_id > 0 ) { $url = wp_get_attachment_url( $attachment_id ); if ( is_string( $url ) && $url !== '' ) { return $url; } } // RFC 7986 IMAGE property fallback for events imported from iCal. $ical_image = $event->image(); if ( is_string( $ical_image ) && $ical_image !== '' ) { $validated = wp_http_validate_url( $ical_image ); if ( is_string( $validated ) && str_starts_with( $validated, 'https://' ) ) { return esc_url_raw( $validated ); } } return ''; } /** * Get a fallback image URL from the site's custom logo or site icon. * * @return string Image URL, or empty string if none configured. */ private static function get_fallback_image_url(): string { // Prefer custom logo (typically wider, better for social sharing) $logo_id_raw = get_theme_mod( 'custom_logo' ); if ( is_numeric( $logo_id_raw ) ) { $logo_src = wp_get_attachment_image_src( (int) $logo_id_raw, 'full' ); if ( is_array( $logo_src ) && ! empty( $logo_src[0] ) ) { return (string) $logo_src[0]; } } // Fall back to site icon (512x512) $icon_url = get_site_icon_url( 512 ); if ( $icon_url !== '' ) { return $icon_url; } return ''; } /** * Prevent WordPress from redirecting virtual event pages. * * @param string|false $redirect_url The canonical redirect URL. * @return string|false */ public function suppress_canonical_redirect( $redirect_url ) { if ( get_query_var( self::QUERY_VAR, '' ) !== '' ) { return false; } return $redirect_url; } /** * Hide the "Edit" link on virtual event pages. * * @param string $link The edit post link HTML. * @return string */ public function suppress_edit_link( string $link ): string { if ( get_query_var( self::QUERY_VAR, '' ) !== '' ) { return ''; } return $link; } /** * Close comments on virtual event pages. * * @param bool $open Whether comments are open. * @param int $post_id The post ID. * @return bool */ public function suppress_comments( bool $open, int $post_id ): bool { if ( get_query_var( self::QUERY_VAR, '' ) !== '' ) { return false; } return $open; } /** * Add a body class for event pages. * * @param array $classes Existing body classes. * @return array */ public function add_body_class( array $classes ): array { if ( get_query_var( self::QUERY_VAR, '' ) !== '' ) { $classes[] = 'webcal-single-event-page'; } return $classes; } /** * Set proper 404 status when event URL is matched but event is not found. * * The inject_virtual_post filter returns the original empty posts array * when the event doesn't exist, but WordPress doesn't know the URL was * intended to be an event page. This hook explicitly sets 404 status. */ public function handle_404(): void { $event_id_raw = get_query_var( self::QUERY_VAR, '' ); if ( $event_id_raw === '' || $event_id_raw === 0 || $event_id_raw === '0' ) { return; } // If the virtual post was injected, the global will be set if ( ! empty( $GLOBALS['webcal_current_event'] ) ) { return; } // Query var present but no event found -- this is a 404 global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); } /** * Retrieve an event by ID. * * @param int $event_id Event ID. * @return Event|null */ private function get_event( int $event_id ): ?Event { try { $repo = Container::get_instance()->get_event_repository(); return $repo->findById( new EventId( $event_id ) ); } catch ( \Exception $e ) { return null; } } /** * Check if an event creator has opted out of single event pages. * * @param string $creator_login The event creator's login. * @return bool True if the creator opted out. */ public static function creator_opted_out( string $creator_login ): bool { // System accounts (e.g. __holidays__) never opt out if ( str_starts_with( $creator_login, '__' ) ) { return false; } try { $user_repo = Container::get_instance()->get_user_repository(); $prefs = $user_repo->getPreferences( $creator_login ); foreach ( $prefs as $pref ) { if ( $pref->key() === 'single_event_pages' && $pref->value() === 'off' ) { return true; } } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- preference lookup failure should not block page rendering } catch ( \Throwable $e ) { // Preference unavailable; fall through to default behavior. } return false; } /** * Resolve the occurrence date from the ?date= query parameter. * * Validates that the date is a real occurrence of the event's recurrence * rule. Returns null for non-recurring events or if no valid date is given. * * @param Event $event The base event entity. * @return \DateTimeImmutable|null The validated occurrence date, or null. */ private function resolve_occurrence_date( Event $event ): ?\DateTimeImmutable { if ( ! $event->recurrence()->isRepeating() ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only page, no state change. $date_raw = isset( $_GET['date'] ) && is_string( $_GET['date'] ) ? sanitize_text_field( wp_unslash( $_GET['date'] ) ) : ''; if ( $date_raw === '' || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_raw ) ) { return null; } try { $requested = new \DateTimeImmutable( $date_raw ); } catch ( \Exception $e ) { return null; } // Validate that the requested date is an actual occurrence by expanding // the recurrence around that date (single-day window). try { $recurrence_svc = Container::get_instance()->get_recurrence_service(); $range = new \WebCalendar\Core\Domain\ValueObject\DateRange( $requested->setTime( 0, 0, 0 ), $requested->setTime( 23, 59, 59 ) ); $occurrences = $recurrence_svc->expand( $event, $range ); if ( empty( $occurrences ) ) { return null; } } catch ( \Throwable $e ) { return null; } return $requested; } /** * Create a copy of an event with its start date shifted to an occurrence date. * * Preserves the original time-of-day and duration; only the date portion changes. * * @param Event $event The base event entity. * @param \DateTimeImmutable $occurrence_date The occurrence date. * @return Event A new Event with the adjusted start date. */ private function with_occurrence_date( Event $event, \DateTimeImmutable $occurrence_date ): Event { $original_start = $event->start(); $new_start = $occurrence_date->setTime( (int) $original_start->format( 'H' ), (int) $original_start->format( 'i' ), (int) $original_start->format( 's' ) ); return new Event( id: $event->id(), uid: $event->uid(), name: $event->name(), description: $event->description(), location: $event->location(), start: $new_start, duration: $event->duration(), createdBy: $event->createdBy(), type: $event->type(), access: $event->access(), recurrence: $event->recurrence(), allDay: $event->isAllDay(), ); } /** * Generate the permalink URL for an event. * * @param Event $event The event entity. * @return string */ public static function get_event_url( Event $event, ?\DateTimeImmutable $occurrence_date = null ): string { $mode = self::get_mode(); if ( $mode === 'off' ) { return ''; } if ( $mode === 'public' && $event->access()->value !== 'P' ) { return ''; } // Respect creator opt-out if ( self::creator_opted_out( $event->createdBy() ) ) { return ''; } $slug = sanitize_title( $event->name() ); $id = $event->id()->value(); $url = home_url( 'webcalendar/event/' . $id . '-' . $slug . '/' ); // Append occurrence date for recurring events so the single event page // shows the correct instance date instead of the base event's original date. if ( $occurrence_date !== null && $event->recurrence()->isRepeating() ) { $url = add_query_arg( 'date', $occurrence_date->format( 'Y-m-d' ), $url ); } return $url; } }