' . "\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;
}
}