- Introduced a new specification for adding an optional contact email field to events, including front-end output and compatibility testing. - Added a comprehensive design document for multi-day event occurrences, detailing the new occurrence data model, validation, normalization, and calendar architecture. - Created specifications for ACF field-key switching compatibility and local ACF override compatibility to ensure seamless integration with existing themes. - Implemented a desktop calendar view toggle design, allowing users to switch between calendar and list views, with persistent preferences and responsive behavior.
880 lines
30 KiB
PHP
880 lines
30 KiB
PHP
<?php
|
||
/**
|
||
* Event occurrence normalization, validation, and display helpers.
|
||
*
|
||
* @package AA_Events
|
||
*/
|
||
|
||
/**
|
||
* Safely convert scalar or stringable field input to a trimmed string.
|
||
*
|
||
* @param mixed $value Raw field value.
|
||
* @return string|null
|
||
*/
|
||
function aa_events_occurrence_string( $value ) {
|
||
if ( is_scalar( $value ) ) {
|
||
return trim( (string) $value );
|
||
}
|
||
if ( is_object( $value ) && method_exists( $value, '__toString' ) ) {
|
||
try {
|
||
return trim( (string) $value );
|
||
} catch ( Throwable $error ) {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Strictly parse and normalize a date or time value.
|
||
*
|
||
* @param mixed $value Value to parse.
|
||
* @param string $format Input format.
|
||
* @param string $output Output format.
|
||
* @return string|null
|
||
*/
|
||
function aa_events_parse_occurrence_value( $value, $format, $output ) {
|
||
$value = aa_events_occurrence_string( $value );
|
||
if ( null === $value || '' === $value ) {
|
||
return null;
|
||
}
|
||
|
||
$date = DateTimeImmutable::createFromFormat( '!' . $format, $value, wp_timezone() );
|
||
$errors = DateTimeImmutable::getLastErrors();
|
||
if ( false === $date || ( is_array( $errors ) && ( $errors['warning_count'] || $errors['error_count'] ) ) || $date->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<int,array{date:string,time:string,end_date:string,end_time:string,all_day:bool}>
|
||
*/
|
||
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 <time>. Ranges return an aria-hidden visible
|
||
* formatted label plus a screen-reader-only, localized pair of endpoint
|
||
* <time> elements. Each endpoint is date-only unless that endpoint has an
|
||
* explicit time; all returned dynamic text and attributes are escaped.
|
||
*
|
||
* @param array $occurrence Normalized occurrence from aa_events_get_occurrences().
|
||
* @return string Escaped HTML safe to print directly.
|
||
*/
|
||
function aa_events_occurrence_time_markup( array $occurrence ) {
|
||
$start = aa_events_occurrence_start( $occurrence );
|
||
$end = aa_events_occurrence_end( $occurrence );
|
||
$all_day = ! empty( $occurrence['all_day'] );
|
||
$has_start_time = ! $all_day && ! empty( $occurrence['start_time'] );
|
||
$has_end_time = ! $all_day && ! empty( $occurrence['end_time'] );
|
||
$is_range = $occurrence['start_date'] !== $occurrence['end_date'] || $has_end_time;
|
||
$visible = esc_html( aa_events_format_occurrence( $occurrence ) );
|
||
$start_value = $has_start_time ? $start->format( DATE_W3C ) : $occurrence['start_date'];
|
||
|
||
if ( ! $is_range ) {
|
||
return '<time datetime="' . esc_attr( $start_value ) . '">' . $visible . '</time>';
|
||
}
|
||
|
||
$end_value = $has_end_time ? $end->format( DATE_W3C ) : $occurrence['end_date'];
|
||
$start_label = wp_date( $has_start_time ? 'F j, Y, g:i A' : 'F j, Y', $start->getTimestamp(), wp_timezone() );
|
||
$end_label = wp_date( $has_end_time ? 'F j, Y, g:i A' : 'F j, Y', $end->getTimestamp(), wp_timezone() );
|
||
|
||
return '<span aria-hidden="true">' . $visible . '</span>'
|
||
. '<span class="screen-reader-text">' . esc_html( __( 'Starts:', 'aa-events' ) )
|
||
. ' <time datetime="' . esc_attr( $start_value ) . '">' . esc_html( $start_label ) . '</time>. '
|
||
. esc_html( __( 'Ends:', 'aa-events' ) )
|
||
. ' <time datetime="' . esc_attr( $end_value ) . '">' . esc_html( $end_label ) . '</time>.</span>';
|
||
}
|
||
|
||
/**
|
||
* Copy legacy local-override repeater rows into the canonical repeater.
|
||
*
|
||
* Existing legacy metadata is intentionally preserved. Canonical rows always
|
||
* win, making this migration safe to run repeatedly.
|
||
*
|
||
* @param int $post_id Event post ID.
|
||
* @return bool Whether canonical rows were written.
|
||
*/
|
||
function aa_events_migrate_multiple_dates_occurrences( $post_id ) {
|
||
$post_id = (int) $post_id;
|
||
if ( ! $post_id || 'event' !== get_post_type( $post_id ) ) {
|
||
return false;
|
||
}
|
||
|
||
$write_rows = static function ( $source_rows ) use ( $post_id ) {
|
||
$rows = array();
|
||
foreach ( $source_rows as $source_row ) {
|
||
if ( ! is_array( $source_row ) ) {
|
||
continue;
|
||
}
|
||
$occurrence = aa_events_normalize_occurrence_row( $source_row, 'event_dates', $post_id );
|
||
if ( null === $occurrence ) {
|
||
continue;
|
||
}
|
||
$rows[] = array(
|
||
'date' => $occurrence['start_date'],
|
||
'start_time' => $occurrence['start_time'],
|
||
'end_date' => empty( $source_row['end_date'] ) ? '' : $occurrence['end_date'],
|
||
'end_time' => $occurrence['end_time'],
|
||
'all_day' => $occurrence['all_day'] ? 1 : 0,
|
||
);
|
||
}
|
||
if ( ! $rows ) {
|
||
return false;
|
||
}
|
||
|
||
$selector = 'event_dates';
|
||
if ( function_exists( 'acf_get_field' ) ) {
|
||
$field = acf_get_field( 'event_dates' );
|
||
if ( is_array( $field ) && ! empty( $field['key'] ) ) {
|
||
$selector = $field['key'];
|
||
}
|
||
}
|
||
return (bool) update_field( $selector, $rows, $post_id );
|
||
};
|
||
|
||
if ( function_exists( 'get_post_meta' ) ) {
|
||
$raw_canonical_rows = get_post_meta( $post_id, 'event_dates', true );
|
||
if ( is_array( $raw_canonical_rows ) && $raw_canonical_rows ) {
|
||
return $write_rows( $raw_canonical_rows );
|
||
}
|
||
}
|
||
|
||
$canonical_rows = aa_events_get_active_field_value( 'event_dates', $post_id );
|
||
if ( is_array( $canonical_rows ) && $canonical_rows ) {
|
||
return false;
|
||
}
|
||
|
||
$legacy_rows = aa_events_get_active_field_value( 'multiple_dates', $post_id );
|
||
if ( ! is_array( $legacy_rows ) || ! $legacy_rows ) {
|
||
return false;
|
||
}
|
||
|
||
return $write_rows( $legacy_rows );
|
||
}
|
||
|
||
/**
|
||
* Migrate the first parseable legacy occurrence into the repeater.
|
||
*
|
||
* @param int $post_id Event post ID.
|
||
* @return bool Whether a repeater row was written.
|
||
*/
|
||
function aa_events_migrate_legacy_occurrence( $post_id ) {
|
||
$post_id = (int) $post_id;
|
||
$rows = get_field( 'event_dates', $post_id );
|
||
if ( is_array( $rows ) && $rows ) {
|
||
return false;
|
||
}
|
||
|
||
$occurrences = aa_events_get_occurrences( $post_id );
|
||
if ( ! isset( $occurrences[0] ) || ! in_array( $occurrences[0]['source'], array( 'event_datetime', 'legacy_separate' ), true ) ) {
|
||
return false;
|
||
}
|
||
|
||
$occurrence = $occurrences[0];
|
||
$row = array(
|
||
'date' => $occurrence['start_date'],
|
||
'start_time' => $occurrence['start_time'],
|
||
'end_date' => '',
|
||
'end_time' => '',
|
||
'all_day' => 0,
|
||
);
|
||
|
||
return (bool) update_field( 'event_dates', array( $row ), $post_id );
|
||
}
|
||
|
||
/**
|
||
* Migrate legacy occurrence data after ACF has saved an event.
|
||
*
|
||
* @param int $post_id Saved post ID.
|
||
* @return void
|
||
*/
|
||
function aa_events_migrate_legacy_on_save( $post_id ) {
|
||
$post_id = (int) $post_id;
|
||
if ( ! $post_id || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || 'event' !== get_post_type( $post_id ) ) {
|
||
return;
|
||
}
|
||
aa_events_migrate_multiple_dates_occurrences( $post_id );
|
||
aa_events_migrate_legacy_occurrence( $post_id );
|
||
}
|
||
|
||
/**
|
||
* Maintain the canonical earliest occurrence value used for event sorting.
|
||
*
|
||
* @param int $post_id Saved post ID.
|
||
* @return void
|
||
*/
|
||
function aa_events_sync_sort_start_on_save( $post_id ) {
|
||
$post_id = (int) $post_id;
|
||
if ( ! $post_id || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || 'event' !== get_post_type( $post_id ) ) {
|
||
return;
|
||
}
|
||
|
||
$occurrences = aa_events_get_occurrences( $post_id );
|
||
if ( ! isset( $occurrences[0] ) ) {
|
||
delete_post_meta( $post_id, '_aa_events_sort_start' );
|
||
delete_post_meta( $post_id, '_aa_events_sort_end' );
|
||
update_post_meta( $post_id, '_aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||
return;
|
||
}
|
||
|
||
$occurrence = $occurrences[0];
|
||
$sort_start = $occurrence['start_date'] . ' ' . ( $occurrence['start_time'] ? $occurrence['start_time'] : '00:00:00' );
|
||
$sort_end = aa_events_occurrence_end( $occurrence );
|
||
foreach ( $occurrences as $candidate ) {
|
||
$candidate_end = aa_events_occurrence_end( $candidate );
|
||
if ( $candidate_end > $sort_end ) {
|
||
$sort_end = $candidate_end;
|
||
}
|
||
}
|
||
update_post_meta( $post_id, '_aa_events_sort_start', $sort_start );
|
||
update_post_meta( $post_id, '_aa_events_sort_end', $sort_end->format( 'Y-m-d H:i:s' ) );
|
||
update_post_meta( $post_id, '_aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||
}
|
||
|
||
/**
|
||
* Schedule one bounded occurrence-index backfill batch when none is pending.
|
||
*
|
||
* @return void
|
||
*/
|
||
function aa_events_schedule_occurrence_index_backfill() {
|
||
if ( AA_EVENTS_VERSION !== get_option( 'aa_events_occurrence_index_version', '' ) && ! wp_next_scheduled( 'aa_events_occurrence_index_backfill' ) ) {
|
||
wp_schedule_single_event( time() + 60, 'aa_events_occurrence_index_backfill' );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Backfill one bounded batch of published events with missing/outdated indexes.
|
||
*
|
||
* @return void
|
||
*/
|
||
function aa_events_run_occurrence_index_backfill() {
|
||
$batch = new WP_Query(
|
||
array(
|
||
'post_type' => 'event',
|
||
'post_status' => 'publish',
|
||
'posts_per_page' => 50,
|
||
'fields' => 'ids',
|
||
'no_found_rows' => true,
|
||
'meta_query' => array(
|
||
'relation' => 'OR',
|
||
array(
|
||
'key' => '_aa_events_occurrence_index_version',
|
||
'value' => AA_EVENTS_VERSION,
|
||
'compare' => '!=',
|
||
),
|
||
array(
|
||
'key' => '_aa_events_occurrence_index_version',
|
||
'compare' => 'NOT EXISTS',
|
||
),
|
||
),
|
||
)
|
||
);
|
||
foreach ( $batch->posts as $post_id ) {
|
||
aa_events_sync_sort_start_on_save( $post_id );
|
||
}
|
||
if ( 50 === count( $batch->posts ) ) {
|
||
aa_events_schedule_occurrence_index_backfill();
|
||
} else {
|
||
update_option( 'aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check whether a metadata key can affect an event occurrence start.
|
||
*
|
||
* @param string $meta_key Metadata key.
|
||
* @return bool
|
||
*/
|
||
function aa_events_sort_source_meta_key( $meta_key ) {
|
||
if ( in_array( $meta_key, array( 'event_dates', 'multiple_dates', 'event_datetime', 'date', 'start_time' ), true ) ) {
|
||
return true;
|
||
}
|
||
|
||
return (bool) preg_match( '/^(event_dates|multiple_dates)_[0-9]+_(date|start_time|end_date|end_time|all_day)$/', $meta_key );
|
||
}
|
||
|
||
/**
|
||
* Queue an event for canonical sort synchronization after a metadata mutation.
|
||
*
|
||
* ACF and direct WordPress metadata updates can emit several hooks per logical
|
||
* change. The queue coalesces those writes so fresh values are read once at
|
||
* request shutdown, after all related row mutations have completed.
|
||
*
|
||
* @param mixed $meta_id Metadata ID or IDs.
|
||
* @param int $object_id Post ID.
|
||
* @param string $meta_key Metadata key.
|
||
* @param mixed $meta_value Metadata value.
|
||
* @return void
|
||
*/
|
||
function aa_events_queue_sort_sync_for_meta_change( $meta_id, $object_id, $meta_key, $meta_value ) {
|
||
unset( $meta_id, $meta_value );
|
||
$object_id = (int) $object_id;
|
||
if ( ! aa_events_sort_source_meta_key( (string) $meta_key ) || ! $object_id || 'event' !== get_post_type( $object_id ) || wp_is_post_autosave( $object_id ) || wp_is_post_revision( $object_id ) ) {
|
||
return;
|
||
}
|
||
|
||
if ( ! isset( $GLOBALS['aa_events_sort_sync_queue'] ) || ! is_array( $GLOBALS['aa_events_sort_sync_queue'] ) ) {
|
||
$GLOBALS['aa_events_sort_sync_queue'] = array();
|
||
}
|
||
$GLOBALS['aa_events_sort_sync_queue'][ $object_id ] = true;
|
||
}
|
||
|
||
/**
|
||
* Synchronize all canonical event start values queued during this request.
|
||
*
|
||
* @return void
|
||
*/
|
||
function aa_events_run_queued_sort_sync() {
|
||
$queue = isset( $GLOBALS['aa_events_sort_sync_queue'] ) && is_array( $GLOBALS['aa_events_sort_sync_queue'] ) ? $GLOBALS['aa_events_sort_sync_queue'] : array();
|
||
$GLOBALS['aa_events_sort_sync_queue'] = array();
|
||
|
||
foreach ( array_keys( $queue ) as $post_id ) {
|
||
if ( function_exists( 'acf_flush_value_cache' ) ) {
|
||
foreach ( array( 'event_dates', 'multiple_dates', 'event_datetime', 'date', 'start_time' ) as $field_name ) {
|
||
acf_flush_value_cache( $post_id, $field_name );
|
||
}
|
||
}
|
||
aa_events_sync_sort_start_on_save( $post_id );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determine whether an ACF occurrence repeater belongs to an event edit form.
|
||
*
|
||
* @param array $field ACF field definition.
|
||
* @return bool
|
||
*/
|
||
function aa_events_occurrence_validation_applies( $field ) {
|
||
if ( ! is_array( $field ) || ( isset( $field['type'] ) && 'repeater' !== $field['type'] ) ) {
|
||
return false;
|
||
}
|
||
|
||
if ( 'field_61b0c7f0a3e8f' === ( $field['key'] ?? '' ) ) {
|
||
return true;
|
||
}
|
||
if ( 'event_dates' !== ( $field['name'] ?? '' ) ) {
|
||
return false;
|
||
}
|
||
|
||
$post_id = function_exists( 'acf_get_form_data' ) ? acf_get_form_data( 'post_id' ) : 0;
|
||
if ( is_string( $post_id ) && 0 === strpos( $post_id, 'post_' ) ) {
|
||
$post_id = substr( $post_id, 5 );
|
||
}
|
||
|
||
return (int) $post_id > 0 && 'event' === get_post_type( (int) $post_id );
|
||
}
|
||
|
||
/**
|
||
* Normalize a submitted ACF date picker value for occurrence validation.
|
||
*
|
||
* @param mixed $value Submitted date value.
|
||
* @return mixed
|
||
*/
|
||
function aa_events_normalize_submitted_date( $value ) {
|
||
$string = aa_events_occurrence_string( $value );
|
||
if ( null === $string || ! preg_match( '/^[0-9]{8}$/', $string ) ) {
|
||
return $value;
|
||
}
|
||
|
||
$normalized = aa_events_parse_occurrence_value( $string, 'Ymd', 'Y-m-d' );
|
||
return null === $normalized ? $value : $normalized;
|
||
}
|
||
|
||
/**
|
||
* Validate submitted ACF repeater rows using stable subfield keys.
|
||
*
|
||
* @param mixed $valid Existing validation state.
|
||
* @param mixed $value Submitted repeater value.
|
||
* @param array $field ACF field definition.
|
||
* @param string $input Submitted input name.
|
||
* @return mixed
|
||
*/
|
||
function aa_events_validate_occurrence_repeater( $valid, $value, $field, $input ) {
|
||
unset( $input );
|
||
if ( true !== $valid || ! aa_events_occurrence_validation_applies( $field ) ) {
|
||
return $valid;
|
||
}
|
||
if ( '' === $value || null === $value ) {
|
||
return true;
|
||
}
|
||
if ( ! is_array( $value ) ) {
|
||
return __( 'Occurrences data is malformed.', 'aa-events' );
|
||
}
|
||
unset( $value['acfcloneindex'] );
|
||
|
||
$key_map = array(
|
||
'field_61b0c7f1a3e90' => 'date',
|
||
'field_61b0c7f2a3e91' => 'start_time',
|
||
'field_aa_events_end_date' => 'end_date',
|
||
'field_aa_events_end_time' => 'end_time',
|
||
'field_aa_events_all_day' => 'all_day',
|
||
);
|
||
foreach ( $field['sub_fields'] ?? array() as $sub_field ) {
|
||
if ( is_array( $sub_field ) && ! empty( $sub_field['key'] ) && ! empty( $sub_field['name'] ) ) {
|
||
$key_map[ $sub_field['key'] ] = $sub_field['name'];
|
||
}
|
||
}
|
||
foreach ( array( 'date', 'start_time', 'end_date', 'end_time', 'all_day' ) as $name ) {
|
||
$key_map[ $name ] = $name;
|
||
}
|
||
foreach ( array_values( $value ) as $index => $submitted_row ) {
|
||
if ( ! is_array( $submitted_row ) ) {
|
||
/* translators: %d: one-based occurrence row number. */
|
||
return sprintf( __( 'Occurrence %d: Occurrence data is malformed.', 'aa-events' ), $index + 1 );
|
||
}
|
||
$row = array(
|
||
'date' => '',
|
||
'start_time' => '',
|
||
'end_date' => '',
|
||
'end_time' => '',
|
||
'all_day' => '',
|
||
);
|
||
foreach ( $key_map as $key => $name ) {
|
||
if ( array_key_exists( $key, $submitted_row ) ) {
|
||
$row[ $name ] = $submitted_row[ $key ];
|
||
}
|
||
}
|
||
$row['date'] = aa_events_normalize_submitted_date( $row['date'] );
|
||
$row['end_date'] = aa_events_normalize_submitted_date( $row['end_date'] );
|
||
$error = aa_events_validate_occurrence_row( $row );
|
||
if ( '' !== $error ) {
|
||
/* translators: 1: one-based occurrence row number, 2: validation error. */
|
||
return sprintf( __( 'Occurrence %1$d: %2$s', 'aa-events' ), $index + 1, $error );
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
if ( function_exists( 'add_action' ) ) {
|
||
add_action( 'acf/save_post', 'aa_events_migrate_legacy_on_save', 20 );
|
||
add_action( 'acf/save_post', 'aa_events_sync_sort_start_on_save', 30 );
|
||
add_action( 'added_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||
add_action( 'updated_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||
add_action( 'deleted_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||
add_action( 'shutdown', 'aa_events_run_queued_sort_sync' );
|
||
add_action( 'init', 'aa_events_schedule_occurrence_index_backfill' );
|
||
add_action( 'aa_events_occurrence_index_backfill', 'aa_events_run_occurrence_index_backfill' );
|
||
}
|
||
if ( function_exists( 'add_filter' ) ) {
|
||
add_filter( 'acf/validate_value/key=field_61b0c7f0a3e8f', 'aa_events_validate_occurrence_repeater', 10, 4 );
|
||
add_filter( 'acf/validate_value/name=event_dates', 'aa_events_validate_occurrence_repeater', 10, 4 );
|
||
}
|