format( $format ) !== $value ) { return null; } return $date->format( $output ); } /** * Normalize an ACF-style boolean without treating the string "false" as true. * * @param mixed $value Raw value. * @return bool */ function aa_events_occurrence_boolean( $value ) { $value = aa_events_occurrence_string( $value ); return null !== $value && in_array( strtolower( $value ), array( '1', 'true', 'yes', 'on' ), true ); } /** * Parse a date returned by the built-in or supported override ACF fields. * * @param mixed $value Raw date. * @return string|null */ function aa_events_parse_occurrence_date( $value ) { foreach ( array( 'Y-m-d', 'Ymd', 'F j, Y' ) as $format ) { $date = aa_events_parse_occurrence_value( $value, $format, 'Y-m-d' ); if ( null !== $date ) { return $date; } } return null; } /** * Parse a time with or without seconds. * * @param mixed $value Raw time. * @return string|null */ function aa_events_parse_occurrence_time( $value ) { foreach ( array( 'H:i:s', 'H:i', 'g:i a' ) as $format ) { $time = aa_events_parse_occurrence_value( $value, $format, 'H:i:s' ); if ( null !== $time ) { return $time; } } return null; } /** * Read a field through its active ACF definition when possible. * * Hidden ACF metadata references can point to the inactive built-in or * override field key when both groups use the same field name. Resolving the * active definition by name keeps reads independent of that stale reference. * * @param string $name Field name. * @param int $post_id Event post ID. * @return mixed */ function aa_events_get_active_field_value( $name, $post_id ) { if ( function_exists( 'acf_get_field' ) && function_exists( 'acf_get_value' ) && function_exists( 'acf_format_value' ) ) { $field = acf_get_field( $name ); if ( is_array( $field ) && $name === ( $field['name'] ?? '' ) ) { $value = acf_format_value( acf_get_value( $post_id, $field ), $post_id, $field ); if ( is_array( $value ) ) { return $value; } if ( function_exists( 'get_post_meta' ) ) { $raw_value = get_post_meta( $post_id, $name, true ); if ( is_array( $raw_value ) ) { return $raw_value; } } return $value; } } return get_field( $name, $post_id ); } /** * Confirm a normalized time exists on its local calendar date. * * PHP deterministically selects an offset for ambiguous fall-back wall times; * exact round-trip checking rejects only nonexistent spring-forward wall times. * * @param string $date Date in Y-m-d format. * @param string $time Time in H:i:s format. * @return bool */ function aa_events_occurrence_local_time_is_valid( $date, $time ) { $value = $date . ' ' . $time; $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, wp_timezone() ); $errors = DateTimeImmutable::getLastErrors(); return false !== $parsed && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $parsed->format( 'Y-m-d H:i:s' ) === $value; } /** * Validate already-parsed occurrence values without calling public validation. * * @param string $start_date Start date. * @param string $start_time Optional start time. * @param string $end_date Effective end date. * @param string $end_time Optional end time. * @param bool $all_day Whether times are ignored. * @return bool */ function aa_events_occurrence_range_is_valid( $start_date, $start_time, $end_date, $end_time, $all_day ) { if ( $end_date < $start_date ) { return false; } if ( $all_day ) { return true; } if ( $start_time && ! aa_events_occurrence_local_time_is_valid( $start_date, $start_time ) ) { return false; } if ( $end_time && ! aa_events_occurrence_local_time_is_valid( $end_date, $end_time ) ) { return false; } return $start_date !== $end_date || ! $start_time || ! $end_time || $end_time >= $start_time; } /** * Normalize a single occurrence row. * * The public shape records provenance without temporary keys. start_inferred is * true when the start came from a legacy combined/separate source rather than * a current repeater row. end_inferred is true when the effective end was * defaulted because no explicit end was supplied, and for all legacy sources. * * @param array $row Raw occurrence fields. * @param string $source Source field format. * @param int $post_id Event post ID. * @return array|null */ function aa_events_normalize_occurrence_row( array $row, $source = 'event_dates', $post_id = 0 ) { $source = in_array( $source, array( 'event_dates', 'event_datetime', 'legacy_separate' ), true ) ? $source : 'event_dates'; $start_inferred = 'event_dates' !== $source; $start_date = aa_events_parse_occurrence_date( $row['start_date'] ?? $row['date'] ?? '' ); if ( null === $start_date ) { return null; } $end_input = aa_events_occurrence_string( $row['end_date'] ?? '' ); if ( null === $end_input ) { return null; } $end_date = '' === $end_input ? $start_date : aa_events_parse_occurrence_date( $end_input ); if ( null === $end_date ) { return null; } $all_day = aa_events_occurrence_boolean( $row['all_day'] ?? false ); $end_time_input = aa_events_occurrence_string( $row['end_time'] ?? '' ); $end_inferred = $start_inferred || ( '' === $end_input && ( null === $end_time_input || '' === $end_time_input ) ); $start_time = ''; $end_time = ''; if ( ! $all_day ) { if ( ! empty( $row['start_time'] ) ) { $start_time = aa_events_parse_occurrence_time( $row['start_time'] ); if ( null === $start_time ) { return null; } } if ( ! empty( $row['end_time'] ) ) { $end_time = aa_events_parse_occurrence_time( $row['end_time'] ); if ( null === $end_time ) { return null; } } } if ( ! aa_events_occurrence_range_is_valid( $start_date, $start_time, $end_date, $end_time, $all_day ) ) { return null; } return array( 'post_id' => (int) $post_id, 'start_date' => $start_date, 'start_time' => $start_time ? $start_time : '', 'end_date' => $end_date, 'end_time' => $end_time ? $end_time : '', 'all_day' => (bool) $all_day, 'source' => $source, 'start_inferred' => $start_inferred, 'end_inferred' => $end_inferred, ); } /** * Read all occurrences for an event, preferring the current repeater format. * * @param int $post_id Event post ID. * @return array */ function aa_events_get_occurrences( $post_id ) { $post_id = (int) $post_id; $rows = aa_events_get_active_field_value( 'event_dates', $post_id ); $occurrences = array(); if ( ! is_array( $rows ) || ! $rows ) { $rows = aa_events_get_active_field_value( 'multiple_dates', $post_id ); } if ( is_array( $rows ) && $rows ) { foreach ( $rows as $row ) { if ( is_array( $row ) ) { $occurrence = aa_events_normalize_occurrence_row( $row, 'event_dates', $post_id ); if ( null !== $occurrence ) { $occurrences[] = $occurrence; } } } } else { $legacy = aa_events_occurrence_string( get_field( 'event_datetime', $post_id ) ); if ( null !== $legacy && '' !== $legacy ) { $date = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $legacy, wp_timezone() ); $errors = DateTimeImmutable::getLastErrors(); if ( false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d H:i:s' ) === $legacy ) { $occurrences[] = aa_events_normalize_occurrence_row( array( 'date' => $date->format( 'Y-m-d' ), 'start_time' => $date->format( 'H:i:s' ), ), 'event_datetime', $post_id ); } } else { $date = get_field( 'date', $post_id ); if ( null !== aa_events_occurrence_string( $date ) && '' !== aa_events_occurrence_string( $date ) ) { $occurrence = aa_events_normalize_occurrence_row( array( 'date' => $date, 'start_time' => get_field( 'start_time', $post_id ), ), 'legacy_separate', $post_id ); if ( null !== $occurrence ) { $occurrences[] = $occurrence; } } } } $decorated = array(); foreach ( $occurrences as $index => $occurrence ) { $decorated[] = array( 'occurrence' => $occurrence, 'index' => $index, ); } usort( $decorated, function ( $left, $right ) { $left_occurrence = $left['occurrence']; $right_occurrence = $right['occurrence']; $comparison = aa_events_occurrence_start( $left_occurrence )->getTimestamp() <=> aa_events_occurrence_start( $right_occurrence )->getTimestamp(); if ( 0 !== $comparison ) { return $comparison; } foreach ( array( 'end_date', 'end_time' ) as $key ) { $comparison = strcmp( $left_occurrence[ $key ], $right_occurrence[ $key ] ); if ( 0 !== $comparison ) { return $comparison; } } $comparison = (int) $left_occurrence['all_day'] <=> (int) $right_occurrence['all_day']; if ( 0 !== $comparison ) { return $comparison; } $comparison = strcmp( $left_occurrence['source'], $right_occurrence['source'] ); return 0 !== $comparison ? $comparison : $left['index'] <=> $right['index']; } ); return array_column( $decorated, 'occurrence' ); } /** * Get event dates in the legacy theme-helper shape. * * @deprecated Use aa_events_get_occurrences() instead. * * @param int $post_id Event post ID. * @return array */ function events_get_all_event_dates( $post_id ) { return array_map( function ( $occurrence ) { return array( 'date' => $occurrence['start_date'], 'time' => $occurrence['start_time'], 'end_date' => $occurrence['end_date'], 'end_time' => $occurrence['end_time'], 'all_day' => $occurrence['all_day'], ); }, aa_events_get_occurrences( $post_id ) ); } /** * Validate a raw or normalized occurrence row. * * @param array $row Occurrence fields. * @return string Empty when valid, otherwise an error message. */ function aa_events_validate_occurrence_row( array $row ) { $start = aa_events_parse_occurrence_date( $row['start_date'] ?? $row['date'] ?? '' ); if ( null === $start ) { return __( 'Start date is required and must be valid.', 'aa-events' ); } $end_input = aa_events_occurrence_string( $row['end_date'] ?? '' ); if ( null === $end_input ) { return __( 'End date must be valid.', 'aa-events' ); } $end = '' === $end_input ? $start : aa_events_parse_occurrence_date( $end_input ); if ( null === $end ) { return __( 'End date must be valid.', 'aa-events' ); } if ( $end < $start ) { return __( 'End date cannot be earlier than start date.', 'aa-events' ); } if ( aa_events_occurrence_boolean( $row['all_day'] ?? false ) ) { return ''; } $start_time = empty( $row['start_time'] ) ? '' : aa_events_parse_occurrence_time( $row['start_time'] ); $end_time = empty( $row['end_time'] ) ? '' : aa_events_parse_occurrence_time( $row['end_time'] ); if ( ! empty( $row['start_time'] ) && null === $start_time ) { return __( 'Start time must be valid.', 'aa-events' ); } if ( ! empty( $row['end_time'] ) && null === $end_time ) { return __( 'End time must be valid.', 'aa-events' ); } if ( $start_time && ! aa_events_occurrence_local_time_is_valid( $start, $start_time ) ) { return __( 'Start time must be valid.', 'aa-events' ); } if ( $end_time && ! aa_events_occurrence_local_time_is_valid( $end, $end_time ) ) { return __( 'End time must be valid.', 'aa-events' ); } if ( $start === $end && $start_time && $end_time && $end_time < $start_time ) { return __( 'End time cannot be earlier than start time on the same date.', 'aa-events' ); } return ''; } /** * Build an occurrence start value. * * @param array $occurrence Normalized occurrence. * @return DateTimeImmutable */ function aa_events_occurrence_start( array $occurrence ) { $time = empty( $occurrence['all_day'] ) && ! empty( $occurrence['start_time'] ) ? $occurrence['start_time'] : '00:00:00'; return new DateTimeImmutable( $occurrence['start_date'] . ' ' . $time, wp_timezone() ); } /** * Build an occurrence end value. * * @param array $occurrence Normalized occurrence. * @return DateTimeImmutable */ function aa_events_occurrence_end( array $occurrence ) { $end_date = $occurrence['end_date'] ? $occurrence['end_date'] : $occurrence['start_date']; if ( ! empty( $occurrence['all_day'] ) ) { $time = '00:00:00'; } elseif ( ! empty( $occurrence['end_time'] ) ) { $time = $occurrence['end_time']; } elseif ( $end_date === $occurrence['start_date'] && ! empty( $occurrence['start_time'] ) ) { $time = $occurrence['start_time']; } else { $time = '00:00:00'; } return new DateTimeImmutable( $end_date . ' ' . $time, wp_timezone() ); } /** * Format a normalized occurrence for display. * * @param array $occurrence Normalized occurrence. * @return string */ function aa_events_format_occurrence( array $occurrence ) { $timezone = wp_timezone(); $start = aa_events_occurrence_start( $occurrence ); $end = aa_events_occurrence_end( $occurrence ); $same_day = $occurrence['start_date'] === $occurrence['end_date']; if ( ! empty( $occurrence['all_day'] ) ) { if ( $same_day ) { $label = wp_date( 'F j, Y', $start->getTimestamp(), $timezone ); } elseif ( $start->format( 'Y-m' ) === $end->format( 'Y-m' ) ) { $label = wp_date( 'F j', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'j, Y', $end->getTimestamp(), $timezone ); } elseif ( $start->format( 'Y' ) === $end->format( 'Y' ) ) { $label = wp_date( 'F j', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'F j, Y', $end->getTimestamp(), $timezone ); } else { $label = wp_date( 'F j, Y', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'F j, Y', $end->getTimestamp(), $timezone ); } return $label . ' — ' . __( 'All day', 'aa-events' ); } $start_date = wp_date( 'F j, Y', $start->getTimestamp(), $timezone ); $start_time = empty( $occurrence['start_time'] ) ? '' : wp_date( 'g:i A', $start->getTimestamp(), $timezone ); $end_time = empty( $occurrence['end_time'] ) ? '' : wp_date( 'g:i A', $end->getTimestamp(), $timezone ); if ( $same_day ) { if ( $start_time && $end_time ) { return $start_date . ', ' . $start_time . '–' . $end_time; } if ( $end_time ) { /* translators: 1: localized occurrence date, 2: localized end time. */ return sprintf( __( '%1$s, until %2$s', 'aa-events' ), $start_date, $end_time ); } return $start_date . ( $start_time ? ', ' . $start_time : '' ); } $start_label = $start_date . ( $start_time ? ', ' . $start_time : '' ); $end_label = wp_date( 'F j, Y', $end->getTimestamp(), $timezone ) . ( $end_time ? ', ' . $end_time : '' ); return $start_label . '–' . $end_label; } /** * Render safe, accessible time markup for one normalized occurrence. * * Points return one visible