$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() ); // A venue that carries coordinates is mappable on its own. Requiring // free text as well meant an event with a proper venue and no // location string — which is what the TEC import produces when the // venue has no address lines — showed no map at all. $mappable = ( $event->location() !== '' && 'physical' === $location_type ) || null !== self::venue_for( $event )?->latitude(); if ( $mappable ) { $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( 'webcal-leaflet', $plugin_url . 'vendor-assets/leaflet/leaflet.css', [], '1.9.4' ); wp_enqueue_script( 'webcal-leaflet', $plugin_url . 'vendor-assets/leaflet/leaflet.js', [], '1.9.4', true ); // Prefer the venue's stored coordinates. The Events Calendar // import brings them along and nothing has ever read them, so // every map view geocoded a text address through Nominatim — // a third-party round trip, rate-limited, and wrong whenever // the free-text location is ambiguous. With a pin we skip it. $venue_for_map = self::venue_for( $event ); $fixed_lat = $venue_for_map?->latitude(); $fixed_lon = $venue_for_map?->longitude(); $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; $draw = "function draw(lat, 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"; if ( null !== $fixed_lat && null !== $fixed_lon ) { // Encode rather than concatenate: a float dropped into a // string is formatted by PHP's number-to-string rules, // which can yield scientific notation and is not // guaranteed to be valid JS. Everything else in this // snippet is JSON-encoded for the same reason. $coords_raw = wp_json_encode( [ $fixed_lat, $fixed_lon ] ); $coords = false === $coords_raw ? '[0,0]' : $coords_raw; $plot = "draw.apply(null, {$coords});\n"; } else { $plot = "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" . "draw(parseFloat(d[0].lat), parseFloat(d[0].lon));\n" . "})\n" . ".catch(function() { el.innerHTML = fb; });\n"; } $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" . $draw . $plot . '})();'; // The handle here was 'leaflet', which is not the handle the // script is registered under. wp_add_inline_script() returns // false for an unknown handle without saying anything, so the // initialiser was never printed and the OpenStreetMap embed // has been an empty grey box the whole time. wp_add_inline_script( 'webcal-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; // Venue and organizer. Both were imported and stored from the day the // TEC migration landed and shown nowhere, which is why the importer // grew a checkbox to paste the organizer's details into the // description instead — the only way they ever reached a reader. $venue = self::venue_for( $event ); $organizer = self::organizer_for( $event ); $extras = self::extras_for( $event ); wp_enqueue_style( 'webcal-single-event', WEBCALENDAR_WP_URL . 'includes/css/single-event.css', [], WEBCALENDAR_WP_VERSION ); ob_start(); ?>
← 0 ) : ?>
start()->format( 'Ymd' ) === $event->end()->format( 'Ymd' ) ? $time_fmt : $date_fmt . ' ' . $time_fmt; ?>
isAllDay() ) : ?> start(), $date_fmt ) ); ?> — start(), $date_fmt ) ); ?> — start(), $time_fmt ) ); ?> – end(), $end_fmt ) ); ?>
location() ); ?> location() !== '' ) : ?>
location() ); ?> ↗ location() ); ?>
conferenceUrl(); ?>
name() ); ?> url() ) : ?> ↗ phone() ) : ?>
phone() ); ?>
↗
name() ) ); ?>
phone() ) : ?>
phone() ); ?>
email() ) : ?> email() ); ?>
url() ) : ?>
location() !== '' && 'physical' === $loc_type ) || ( null !== $venue && null !== $venue->latitude() ); ?>
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'; } // The event's own organizer when it has one. This said "the site" for // every event regardless, which is wrong in the ordinary case where a // calendar carries other people's events — and search engines read it. $schema_organizer = self::organizer_for( $event ); if ( null !== $schema_organizer ) { // No email here on purpose. The visible link runs through // antispambot(), and repeating the address in plain text inside // JSON-LD a few elements further up the same page would hand it // straight back to anything scraping for addresses. $schema['organizer'] = array_filter( [ '@type' => 'Organization', 'name' => $schema_organizer->name(), 'url' => $schema_organizer->url(), 'telephone' => $schema_organizer->phone(), ], static fn( $value ): bool => null !== $value ); } else { $schema['organizer'] = [ '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url(), ]; } $schema_venue = self::venue_for( $event ); if ( null !== $schema_venue ) { $place = [ '@type' => 'Place', 'name' => $schema_venue->name(), ]; $address = array_filter( [ '@type' => 'PostalAddress', 'streetAddress' => $schema_venue->address(), 'addressLocality' => $schema_venue->city(), 'addressRegion' => $schema_venue->state(), 'postalCode' => $schema_venue->zip(), 'addressCountry' => $schema_venue->country(), ], static fn( $value ): bool => null !== $value ); if ( count( $address ) > 1 ) { $place['address'] = $address; } if ( null !== $schema_venue->latitude() && null !== $schema_venue->longitude() ) { $place['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => $schema_venue->latitude(), 'longitude' => $schema_venue->longitude(), ]; } $schema['location'] = $place; } $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 = self::format_local( $event->start(), webcal_date_format() . ' ' . webcal_time_php_format() ); 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"; } /** * An event time in the given PHP format, in the event's own zone, with * month and day names in the site language. * * The page used to hard-code 'l, F j, Y' and 'g:i A', so it ignored the * Date Format and Time Format settings and always printed English names. * * @param \DateTimeImmutable $when The instant. * @param string $format PHP date format. */ private static function format_local( \DateTimeImmutable $when, string $format ): string { return (string) wp_date( $format, $when->getTimestamp(), $when->getTimezone() ); } /** * 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 = self::format_local( $event->start(), webcal_date_format() . ' ' . webcal_time_php_format() ); 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' ) ); // Display-only entity, but it still has to look like the event it // stands for: rebuilding it field by field once lost an occurrence // page the image, venue, organizer and conference link that the // series' own page shows. return $event->with( start: $new_start ); } /** * 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; } /** * The event's venue, or null when it has none or the row is gone. * * A deleted venue leaves cal_venue_id pointing at nothing; the page still * has to render. */ private static function venue_for( \WebCalendar\Core\Domain\Entity\Event $event ): ?\WebCalendar\Core\Domain\Entity\Venue { $id = $event->venueId(); if ( null === $id ) { return null; } try { return \WebCalendar\WP\Container::get_instance()->get_venue_repository()->findById( $id ); } catch ( \Exception $e ) { return null; } } /** * The event's organizer, or null when it has none or the row is gone. */ private static function organizer_for( \WebCalendar\Core\Domain\Entity\Event $event ): ?\WebCalendar\Core\Domain\Entity\Organizer { $id = $event->organizerId(); if ( null === $id ) { return null; } try { return \WebCalendar\WP\Container::get_instance()->get_organizer_repository()->findById( $id ); } catch ( \Exception $e ) { return null; } } /** * The event's custom field values, paired with their definitions. * * Definition order, definition labels: the stored row knows only a name, * and a page showing "dress_code" where somebody configured "Dress code" * is showing them our database rather than their site. Values whose field * has since been removed are skipped — kept in the table, not displayed. * * @return list */ private static function extras_for( \WebCalendar\Core\Domain\Entity\Event $event ): array { $fields = \WebCalendar\WP\SiteExtraFields::all(); if ( array() === $fields ) { return array(); } try { $values = \WebCalendar\WP\Container::get_instance() ->get_site_extra_service() ->getExtrasForEvent( $event->id()->value() ); } catch ( \Exception $e ) { return array(); } $rows = array(); foreach ( $fields as $field ) { $value = $values[ $field['name'] ] ?? ''; if ( ! is_scalar( $value ) || '' === (string) $value ) { continue; } $rows[] = array( 'label' => $field['label'], 'type' => $field['type'], 'value' => (string) $value, ); } return $rows; } }