' . "\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; } // Expansion walks from DTSTART to the requested day, so a day before // the series or beyond the horizon is refused before any of it runs. $day_start = $requested->setTime( 0, 0, 0 ); $day_end = $requested->setTime( 23, 59, 59 ); if ( $day_end < $event->start() || ! ExpansionWindow::allows( $day_start, $day_end ) ) { 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( $day_start, $day_end ); $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; } }