this site's custom field name. * * @var array */ private array $extra_field_map = array(); /** * Set the custom field mapping for this import. * * @param array $map TEC meta key => field name. */ public function set_extra_field_map( array $map ): void { $this->extra_field_map = $map; } public function __construct( private readonly DateTimeZone $site_timezone, private readonly string $uid_host, private readonly TecRecurrenceConverter $recurrence_converter, ) { } /** * Map one TEC event and its related records. * * @param TecEvent $tec_event The event. * @param ?TecVenue $venue Its venue, if any. * @param ?TecOrganizer $organizer Its organizer, if any. * @param list $categories The event's categories. * @param string $creator_login webcal cal_create_by login. * @param bool $append_organizer Wizard checkbox: append the * organizer block or drop it. * @throws \InvalidArgumentException On unparseable event dates. */ public function map( TecEvent $tec_event, ?TecVenue $venue, ?TecOrganizer $organizer, array $categories, string $creator_login, bool $append_organizer ): MappedTecEvent { $warnings = array(); $event_tz = $this->event_timezone( $tec_event->timezone, $warnings ); $start_local = $this->parse_local( $tec_event->start_local, $event_tz, 'start' ); $end_local = $this->parse_local( $tec_event->end_local, $event_tz, 'end' ); if ( $tec_event->all_day ) { // Keep the calendar date: re-anchor the wall-clock in the site zone. $start = new DateTimeImmutable( $start_local->format( 'Y-m-d H:i:s' ), $this->site_timezone ); $days = max( 1, $start_local->diff( $end_local )->days ); $duration = $days * 1440; } else { $start = $start_local->setTimezone( $this->site_timezone ); $duration = max( 0, (int) ( ( $end_local->getTimestamp() - $start_local->getTimestamp() ) / 60 ) ); } $name = trim( $tec_event->title ); if ( mb_strlen( $name ) > self::NAME_MAX ) { // webcal_entry.cal_name is VARCHAR(255); TEC's post_title is TEXT. $name = mb_substr( $name, 0, self::NAME_MAX ); $warnings[] = sprintf( /* translators: 1: The Events Calendar post id, 2: maximum title length */ __( 'Event %1$d has a title longer than %2$d characters; it was shortened.', 'agenticdaisy-calendar' ), $tec_event->id, self::NAME_MAX ); } if ( '' === $name ) { $name = __( '(Untitled event)', 'agenticdaisy-calendar' ); $warnings[] = sprintf( /* translators: %d: The Events Calendar post id. */ __( 'Event %d has no title; imported as "(Untitled event)".', 'agenticdaisy-calendar' ), $tec_event->id ); } $description = $tec_event->description; if ( $append_organizer && null !== $organizer ) { $description = $this->append_organizer_block( $description, $organizer ); } list( $recurrence, $type, $needs_expansion ) = $this->map_recurrence( $tec_event, $start_local, $event_tz, $warnings ); $status = $this->map_status( $tec_event->status, $warnings ); $event = new Event( id: new EventId( 0 ), uid: sprintf( 'tec-%d@%s', $tec_event->id, $this->uid_host ), name: $name, description: $description, location: null !== $venue ? mb_substr( $this->format_location( $venue ), 0, self::NAME_MAX ) : '', start: $start, duration: $duration, createdBy: $creator_login, type: $type, access: AccessLevel::PUBLIC, recurrence: $recurrence, status: $status, allDay: $tec_event->all_day, ); // Before the DTO: core_venue() appends to $warnings by reference. $core_venue = $this->core_venue( $venue, $warnings ); return new MappedTecEvent( event: $event, category_names: array_map( static fn( TecCategory $c ): string => $c->name, $categories ), thumbnail_id: $tec_event->thumbnail_id, lat: $venue?->lat, lng: $venue?->lng, needs_instance_expansion: $needs_expansion, warnings: $warnings, venue: $core_venue, organizer: $this->core_organizer( $organizer ), tag_names: $tec_event->tag_names, extras: self::map_extras( $tec_event->extras, $this->extra_field_map ), ); } /** * The TEC venue as a core entity (unsaved) — core 4.6 adoption. The * flattened location string stays on the event for back-compat; the * entity carries the structured fields for map views and filtering. * * @param list $warnings By reference. */ private function core_venue( ?TecVenue $venue, array &$warnings ): ?\WebCalendar\Core\Domain\Entity\Venue { if ( null === $venue || '' === trim( $venue->name ) ) { return null; } $lat = $venue->lat; $lng = $venue->lng; if ( ( null === $lat ) !== ( null === $lng ) ) { $warnings[] = sprintf( 'Venue "%s": incomplete coordinates dropped.', $venue->name ); $lat = null; $lng = null; } try { return new \WebCalendar\Core\Domain\Entity\Venue( id: new \WebCalendar\Core\Domain\ValueObject\VenueId( 0 ), name: $venue->name, address: $venue->address, city: $venue->city, state: $venue->state, zip: $venue->zip, country: $venue->country, latitude: $lat, longitude: $lng, url: $venue->website, phone: $venue->phone, ); } catch ( \InvalidArgumentException $e ) { $warnings[] = sprintf( 'Venue "%s" not saved as an entity: %s', $venue->name, $e->getMessage() ); return null; } } /** * The TEC organizer as a core entity (unsaved), independent of the * append-to-description choice. */ private function core_organizer( ?TecOrganizer $organizer ): ?\WebCalendar\Core\Domain\Entity\Organizer { if ( null === $organizer || '' === trim( $organizer->name ) ) { return null; } try { return new \WebCalendar\Core\Domain\Entity\Organizer( id: new \WebCalendar\Core\Domain\ValueObject\OrganizerId( 0 ), name: $organizer->name, email: $organizer->email, phone: $organizer->phone, url: $organizer->website, ); } catch ( \InvalidArgumentException ) { // A malformed email must not drop the organizer entirely. return new \WebCalendar\Core\Domain\Entity\Organizer( id: new \WebCalendar\Core\Domain\ValueObject\OrganizerId( 0 ), name: $organizer->name, phone: $organizer->phone, url: $organizer->website, ); } } /** * Rebase raw occurrence stamps ('Y-m-d H:i:s' in the event's timezone) * into site-timezone starts, using the same rules as the event start: * timed occurrences convert, all-day occurrences keep their date. * Unparseable stamps are skipped. * * @param TecEvent $tec_event The event the occurrences belong to. * @param list $stamps Raw occurrence starts. * @return list<\DateTimeImmutable> */ public function map_occurrences( TecEvent $tec_event, array $stamps ): array { $ignored = array(); $event_tz = $this->event_timezone( $tec_event->timezone, $ignored ); $starts = array(); foreach ( $stamps as $stamp ) { $local = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $stamp, $event_tz ); if ( false === $local ) { continue; } $starts[] = $tec_event->all_day ? new DateTimeImmutable( $local->format( 'Y-m-d H:i:s' ), $this->site_timezone ) : $local->setTimezone( $this->site_timezone ); } return $starts; } /** * The event's own timezone, degrading to the site zone with a warning. * * @param string $timezone Olson id from TEC meta. * @param list $warnings Accumulator (by reference). */ private function event_timezone( string $timezone, array &$warnings ): DateTimeZone { try { if ( '' === $timezone ) { throw new \Exception( 'empty timezone' ); } return new DateTimeZone( $timezone ); } catch ( \Exception $e ) { $warnings[] = sprintf( /* translators: %s: invalid timezone identifier. */ __( 'Unknown timezone "%s"; the site timezone was used instead.', 'agenticdaisy-calendar' ), $timezone ); return $this->site_timezone; } } /** * Parse a 'Y-m-d H:i:s' local stamp in the given zone. * * @throws \InvalidArgumentException When the stamp is unparseable — the * engine catches per-event and skips. */ private function parse_local( string $stamp, DateTimeZone $tz, string $which ): DateTimeImmutable { $dt = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $stamp, $tz ); if ( false === $dt ) { throw new \InvalidArgumentException( sprintf( 'Unparseable %s date "%s".', esc_html( $which ), esc_html( $stamp ) ) ); } return $dt; } /** * Recurrence VO, entry type and expansion flag for the event. * * @param TecEvent $tec_event The event. * @param DateTimeImmutable $start_local Start in the event's own zone. * @param DateTimeZone $event_tz The event's own zone. * @param list $warnings Accumulator (by reference). * @return array{0: Recurrence, 1: EventType, 2: bool} */ private function map_recurrence( TecEvent $tec_event, DateTimeImmutable $start_local, DateTimeZone $event_tz, array &$warnings ): array { if ( null === $tec_event->recurrence ) { return array( new Recurrence(), EventType::EVENT, false ); } $conversion = $this->recurrence_converter->convert( $tec_event->recurrence, $start_local ); foreach ( $conversion->warnings as $warning ) { $warnings[] = $warning; } if ( ! $conversion->is_convertible() ) { return array( new Recurrence(), EventType::EVENT, true ); } $exdates = array(); foreach ( $conversion->exdates as $exdate ) { $exdates[] = $this->parse_local( $exdate, $event_tz, 'exclusion' )->setTimezone( $this->site_timezone ); } return array( new Recurrence( new RecurrenceRule( (string) $conversion->rrule ), new ExDate( $exdates ) ), EventType::REPEATING_EVENT, false, ); } /** * "Name, Address, City, ST ZIP, Country" — present parts only. */ private function format_location( TecVenue $venue ): string { $state_zip = trim( implode( ' ', array_filter( array( $venue->state, $venue->zip ), static fn( ?string $v ): bool => null !== $v && '' !== $v ) ) ); $parts = array_filter( array( $venue->name, $venue->address, $venue->city, $state_zip, $venue->country ), static fn( ?string $v ): bool => null !== $v && '' !== $v ); return implode( ', ', $parts ); } /** * Append the labeled organizer block to a description. */ private function append_organizer_block( string $description, TecOrganizer $organizer ): string { $lines = array( /* translators: %s: organizer name. */ sprintf( __( 'Organizer: %s', 'agenticdaisy-calendar' ), $organizer->name ), ); if ( null !== $organizer->phone ) { /* translators: %s: organizer phone number. */ $lines[] = sprintf( __( 'Phone: %s', 'agenticdaisy-calendar' ), $organizer->phone ); } if ( null !== $organizer->email ) { /* translators: %s: organizer email address. */ $lines[] = sprintf( __( 'Email: %s', 'agenticdaisy-calendar' ), $organizer->email ); } if ( null !== $organizer->website ) { /* translators: %s: organizer website URL. */ $lines[] = sprintf( __( 'Website: %s', 'agenticdaisy-calendar' ), $organizer->website ); } $block = implode( "\n", $lines ); return '' === trim( $description ) ? $block : $description . "\n\n" . $block; } /** * Translate The Events Calendar's meta keys into this site's field names. * * The map is built once per import, from the definitions, so a value * whose field has since been deleted in The Events Calendar is dropped * rather than written under a key nothing will render. * * @param array $values Keyed by TEC meta key. * @param array $map TEC meta key => this site's field name. * @return array */ private static function map_extras( array $values, array $map ): array { $mapped = array(); foreach ( $values as $key => $value ) { if ( isset( $map[ $key ] ) && '' !== trim( $value ) ) { $mapped[ $map[ $key ] ] = $value; } } return $mapped; } /** * The Events Calendar's Event Status as the RFC 5545 STATUS this plugin * stores in cal_status. * * Only "canceled" has an equivalent, and it matters: a canceled event * stays published in TEC, so the deletion sweep never sees it and this is * the only thing that stops the mirror advertising an event that is off. * * TENTATIVE — the obvious home for "postponed" — is already spoken for: * it is this plugin's moderation marker, and EventVisibility hides a * TENTATIVE event from everyone but its submitter and approvers. Reusing * it would quietly remove postponed events from the public calendar, so * anything that is not a cancellation is reported and left unset, on the * same principle as the Additional Fields reader: declining to carry data * beats carrying it somewhere it means something else. * * @param ?string $raw Raw _tec_event_status meta value. * @param list $warnings Warning sink, appended to by reference. */ private function map_status( ?string $raw, array &$warnings ): ?string { $status = strtolower( trim( (string) $raw ) ); if ( '' === $status ) { return null; } // TEC spells it with one L; RFC 5545 uses two. Accept both. if ( in_array( $status, array( 'canceled', 'cancelled' ), true ) ) { return 'CANCELLED'; } $warnings[] = sprintf( /* translators: %s: an event status from The Events Calendar, e.g. "postponed". */ __( 'Event status "%s" has no WebCalendar equivalent and was not carried across; the event was imported as going ahead.', 'agenticdaisy-calendar' ), $status ); return null; } }