|null $preloaded Pre-loaded data (participants, category, attachments). * @return array */ public function transform_event( Event $event, ?array $preloaded = null ): array { if ( $preloaded ) { $participants = $preloaded['participants'] ?? []; $attachments = $preloaded['attachments'] ?? []; $category = $preloaded['category'] ?? [ 'id' => null, 'color' => null, ]; $featured_image_id = $preloaded['featured_image_id'] ?? 0; $tags = $preloaded['tags'] ?? []; } else { $container = Container::get_instance(); $participant_repo = $container->get_participant_repository(); $participants = $participant_repo->get_participants( $event->id()->value() ); $attachments = get_option( 'webcal_attachments_' . $event->id()->value(), [] ); $category = $this->get_category_for_event( $event ); $fi_raw = get_option( 'webcal_featured_image_' . $event->id()->value(), 0 ); $featured_image_id = is_numeric( $fi_raw ) ? (int) $fi_raw : 0; $tags = $this->get_tags_for_event( $event ); } // Resolve featured image ID to URL /** @var int $featured_image_id */ $featured_image_url = ''; if ( $featured_image_id > 0 ) { $url = \wp_get_attachment_url( $featured_image_id ); if ( $url ) { $featured_image_url = $url; } } // Resolve attachment IDs to fresh URLs and ensure filenames are present $resolved_attachments = []; if ( ! empty( $attachments ) && is_array( $attachments ) ) { foreach ( $attachments as $attachment ) { if ( is_array( $attachment ) && ! empty( $attachment['id'] ) ) { $aid = is_scalar( $attachment['id'] ) ? (int) $attachment['id'] : 0; $url = \wp_get_attachment_url( $aid ); if ( $url ) { $attachment['url'] = $url; if ( empty( $attachment['filename'] ) ) { $attachment['filename'] = \basename( $url ); } } } /** @var array{id?: int|string, url?: string, filename?: string} $attachment */ $resolved_attachments[] = $attachment; } } // All-day events use date-only format $date_format = $event->isAllDay() ? 'Y-m-d' : 'Y-m-d\TH:i:s'; /** @var array{id: int|null, color: string|null} $safe_category */ $safe_category = is_array( $category ) ? $category : [ 'id' => null, 'color' => null, ]; $desc = self::render_description( $event ); return [ 'id' => $event->id()->value(), 'title' => $event->name(), 'start' => $event->start()->format( $date_format ), 'end' => $event->isAllDay() ? $event->start()->format( $date_format ) : $event->end()->format( $date_format ), 'description' => \wp_kses_post( $desc ), 'description_format' => $desc !== wp_strip_all_tags( $desc ) ? 'html' : 'text', 'location' => $event->location(), 'url' => SingleEventPage::get_event_url( $event ), 'duration' => $event->duration(), 'allDay' => $event->isAllDay(), 'access' => $event->access()->value, 'rrule' => $event->recurrence()->rule()?->toString() ?? '', 'createdBy' => $event->createdBy(), 'isHoliday' => $event->createdBy() === '__holidays__', 'participants' => $participants, 'attachments' => $resolved_attachments, 'category_id' => $safe_category['id'], 'category_color' => $safe_category['color'], 'tags' => $this->normalize_tag_names( $tags ), 'conference_url' => $event->conferenceUrl(), 'conference_label' => $event->conferenceLabel(), 'status' => $event->status(), 'featured_image_id' => $featured_image_id > 0 ? $featured_image_id : null, 'featured_image_url' => $featured_image_url !== '' ? $featured_image_url : null, 'venue' => $this->venue_payload( $event ), 'organizer' => $this->organizer_payload( $event ), 'extras' => $this->extras_payload( $event ), ]; } /** * The event description with shortcodes expanded only for trusted authors. * * Shortcodes run with the authority of whoever is viewing, so they are * expanded only when the event's creator is a WordPress user WordPress * would let run them in post content (edit_posts), and never for a * pending submission. Otherwise they are stripped: Subscriber * submissions and remote-feed events (whose creator is the feed's * calendar, not a user) used to run any site shortcode for every reader, * and core's [gallery] alone lists media attached to unpublished posts. * Events an administrator imports from a file are attributed to that * administrator and still expand. Filter webcal_expand_description_shortcodes * to change the decision. */ public static function render_description( Event $event ): string { $creator = get_user_by( 'login', $event->createdBy() ); $trusted = $creator instanceof \WP_User && 'TENTATIVE' !== $event->status() && user_can( $creator, 'edit_posts' ); $expand = (bool) apply_filters( 'webcal_expand_description_shortcodes', $trusted, $event ); return $expand ? do_shortcode( $event->description() ) : strip_shortcodes( $event->description() ); } /** * Prepare an occurrence for the REST response. * * @param \WebCalendarDeps\Icalendar\Recurrence\Occurrence $occurrence Recurrence occurrence. * @param Event $event Event entity. * @param array|null $preloaded Pre-loaded data. * @return array * * @psalm-suppress PossiblyUnusedMethod -- called from EventsController::expand_recurrence_via_core() to render each occurrence */ public function transform_occurrence( \WebCalendarDeps\Icalendar\Recurrence\Occurrence $occurrence, Event $event, ?array $preloaded = null ): array { $data = $this->transform_event( $event, $preloaded ); $occ_start = $occurrence->getStart(); $end = $occurrence->getEnd(); if ( ! $end ) { $end = $occ_start->modify( '+' . $event->duration() . ' minutes' ); } $date_format = $event->isAllDay() ? 'Y-m-d' : 'Y-m-d\TH:i:s'; $data['start'] = $occ_start->format( $date_format ); $data['end'] = $event->isAllDay() ? $occ_start->format( $date_format ) : $end->format( $date_format ); $data['isOccurrence'] = true; // Update URL to include occurrence date so the single event page shows // this instance's date instead of the base event's original date. $data['url'] = SingleEventPage::get_event_url( $event, $occ_start ); return $data; } /** * Get the primary category info for an event. * * @param Event $event Event entity. * @return array{id: int|null, color: string|null} */ public function get_category_for_event( Event $event ): array { try { $category_repo = Container::get_instance()->get_category_repository(); // Category assignments belong to the event's creator (they are stored // per assigning user), so read them as the creator, not the viewer — // otherwise the event shows uncategorised to other users. $categories = $category_repo->getForEvent( $event->id(), $event->createdBy() ); foreach ( $categories as $category ) { // getForEvent() returns tags too — they share the junction // table. Taking [0] blindly would report a tag (and its null // colour) as the event's category. if ( $category->isTag() ) { continue; } return [ 'id' => $category->id(), 'color' => $category->color(), ]; } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- category lookup is non-critical } catch ( \Exception $e ) { // Category unavailable; show event without color. } return [ 'id' => null, 'color' => null, ]; } /** * Batch-load attachment data for multiple events. * * Replaces per-event get_option() calls with a single SQL IN query so that * large date-range requests do not bloat the WP object cache. * * @param array $event_ids Event IDs. * @return array Map of event_id => attachment value. Event IDs * with no stored attachments are absent; callers use ?? []. */ public function get_attachments_batch( array $event_ids ): array { if ( empty( $event_ids ) ) { return []; } global $wpdb; $option_names = array_map( fn( $id ) => 'webcal_attachments_' . $id, $event_ids ); $placeholders = implode( ',', array_fill( 0, count( $option_names ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a dynamically-built list of %s tokens $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->prefix}options WHERE option_name IN ($placeholders)", ...$option_names ), // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare ARRAY_A ); $map = []; if ( $results ) { foreach ( $results as $row ) { $id = (int) substr( $row['option_name'], strlen( 'webcal_attachments_' ) ); $map[ $id ] = maybe_unserialize( $row['option_value'] ); } } return $map; } /** * Batch-load featured image IDs for multiple events. * * Single SQL query for all webcal_featured_image_* options matching the * given event IDs. * * @param array $event_ids Event IDs. * @return array Map of event_id => attachment_id. */ public function get_featured_images_batch( array $event_ids ): array { if ( empty( $event_ids ) ) { return []; } global $wpdb; $option_names = array_map( fn( $id ) => 'webcal_featured_image_' . $id, $event_ids ); $placeholders = implode( ',', array_fill( 0, count( $option_names ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a dynamically-built list of %s tokens $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->prefix}options WHERE option_name IN ($placeholders)", ...$option_names ), // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare ARRAY_A ); $map = []; if ( $results ) { foreach ( $results as $row ) { $id = (int) substr( $row['option_name'], strlen( 'webcal_featured_image_' ) ); $map[ $id ] = (int) $row['option_value']; } } return $map; } /** * Batch-load categories for multiple events. * * @param array $event_ids Event IDs. * @param string $user_login User login for category ownership. * @return array */ public function get_categories_batch( array $event_ids, string $user_login ): array { if ( empty( $event_ids ) ) { return []; } $category_repo = Container::get_instance()->get_category_repository(); return $category_repo->getForEventsBatch( array_map( fn( int $id ) => new \WebCalendar\Core\Domain\ValueObject\EventId( $id ), $event_ids ), $user_login ); } /** * Batch-load tag names per event. * * Events with no tags are absent from the map, matching * getForEventsBatch(); callers use `?? []`. * * @param int[] $event_ids Event IDs. * @param string $user_login Assignment owner to read as. * @return array> */ public function get_tags_batch( array $event_ids, string $user_login ): array { if ( empty( $event_ids ) ) { return []; } $category_repo = Container::get_instance()->get_category_repository(); return $category_repo->getTagsForEventsBatch( array_map( fn( int $id ) => new \WebCalendar\Core\Domain\ValueObject\EventId( $id ), $event_ids ), $user_login ); } /** * Flatten either wire shape — the `{id, name}` rows the repository and * batch loader produce, or bare name strings — to a list of names. * * @param mixed $tags Raw tags from a preload or a repository read. * @return list */ private function normalize_tag_names( $tags ): array { if ( ! is_array( $tags ) ) { return []; } $names = []; foreach ( $tags as $tag ) { if ( is_array( $tag ) && isset( $tag['name'] ) && is_scalar( $tag['name'] ) ) { $names[] = (string) $tag['name']; } elseif ( is_scalar( $tag ) ) { $names[] = (string) $tag; } } return $names; } /** * Tag names assigned to a single event, read as its creator for the same * reason categories are. * * @return list */ public function get_tags_for_event( Event $event ): array { try { $category_repo = Container::get_instance()->get_category_repository(); $tags = $category_repo->getTagsForEvent( $event->id(), $event->createdBy() ); return array_values( array_map( static fn( $tag ): array => [ 'id' => $tag->id(), 'name' => $tag->name(), ], $tags ) ); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- tags are non-critical } catch ( \Exception $e ) { // Tags unavailable; show the event without them. } return []; } /** * Venue and organizer lookups, resolved once per request. * * A month view transforms hundreds of events, so a findById() per event * would be a textbook N+1 against two tables that hold tens of rows. Both * are read whole and indexed by id on first use; the transformer lives * for one request, so the cache expires with it. * * @var array|null */ private ?array $venues = null; /** @var array|null */ private ?array $organizers = null; /** * 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. That must * render as "no venue", not as a failed request. * * @return array|null */ private function venue_payload( Event $event ): ?array { $id = $event->venueId()?->value(); if ( null === $id ) { return null; } if ( null === $this->venues ) { $this->venues = []; try { foreach ( Container::get_instance()->get_venue_repository()->findAll() as $venue ) { $this->venues[ $venue->id()->value() ] = $venue; } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- a place lookup is not worth failing the event over } catch ( \Exception $e ) { // Venue unavailable; the event still renders without it. } } $venue = $this->venues[ $id ] ?? null; if ( null === $venue ) { return null; } return [ 'id' => $venue->id()->value(), 'name' => $venue->name(), 'address' => $venue->address(), 'city' => $venue->city(), 'state' => $venue->state(), 'zip' => $venue->zip(), 'country' => $venue->country(), 'latitude' => $venue->latitude(), 'longitude' => $venue->longitude(), 'url' => $venue->url(), 'phone' => $venue->phone(), ]; } /** * The event's organizer, or null when it has none or the row is gone. * * Contact details are included. They describe an organisation, the site * owner typed them in for publication, and the event page shows them — * withholding them here while printing them there would be theatre. The * /organizers list endpoint stays id-and-name, because it only feeds a * picker and has no reason to hand out addresses in bulk. * * @return array|null */ private function organizer_payload( Event $event ): ?array { $id = $event->organizerId()?->value(); if ( null === $id ) { return null; } if ( null === $this->organizers ) { $this->organizers = []; try { foreach ( Container::get_instance()->get_organizer_repository()->findAll() as $organizer ) { $this->organizers[ $organizer->id()->value() ] = $organizer; } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- a place lookup is not worth failing the event over } catch ( \Exception $e ) { // Organizer unavailable; the event still renders without it. } } $organizer = $this->organizers[ $id ] ?? null; if ( null === $organizer ) { return null; } return [ 'id' => $organizer->id()->value(), 'name' => $organizer->name(), 'email' => $organizer->email(), 'phone' => $organizer->phone(), 'url' => $organizer->url(), ]; } /** * The event's custom field values, keyed by field name. * * Read per event rather than batched: unlike venues and organizers, which * are a handful of rows shared by everything, extras are per-event and a * batched read would be a second query returning the same total number of * rows. Sites with no fields defined pay nothing — the lookup is skipped * entirely rather than querying for values that cannot be rendered. * * @return array */ private function extras_payload( Event $event ): array { if ( array() === \WebCalendar\WP\SiteExtraFields::names() ) { return array(); } try { $values = Container::get_instance()->get_site_extra_service() ->getExtrasForEvent( $event->id()->value() ); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- custom fields are not worth failing the event over } catch ( \Exception $e ) { return array(); } $payload = array(); foreach ( \WebCalendar\WP\SiteExtraFields::all() as $field ) { $value = $values[ $field['name'] ] ?? null; if ( null === $value || '' === $value ) { continue; } $payload[ $field['name'] ] = is_scalar( $value ) ? (string) $value : ''; } return $payload; } }