✨ feature: Add calendar layout and occurrence handling functionality
- Introduced `calendar-layout.php` to manage month grid generation, occurrence intersection checks, and segment assignments for events. - Added `occurrences.php` for normalizing, validating, and displaying event occurrences, including migration from legacy formats. - Implemented functions for parsing dates, validating occurrence ranges, and formatting occurrences for display. - Established hooks for synchronizing event occurrence data upon saving posts and validating ACF repeater fields.
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
<?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 time with or without seconds.
|
||||
*
|
||||
* @param mixed $value Raw time.
|
||||
* @return string|null
|
||||
*/
|
||||
function aa_events_parse_occurrence_time( $value ) {
|
||||
$time = aa_events_parse_occurrence_value( $value, 'H:i:s', 'H:i:s' );
|
||||
return null === $time ? aa_events_parse_occurrence_value( $value, 'H:i', 'H:i:s' ) : $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_value( $row['start_date'] ?? $row['date'] ?? '', 'Y-m-d', 'Y-m-d' );
|
||||
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_value( $end_input, 'Y-m-d', 'Y-m-d' );
|
||||
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 = get_field( 'event_dates', $post_id );
|
||||
$occurrences = array();
|
||||
|
||||
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_value( $row['start_date'] ?? $row['date'] ?? '', 'Y-m-d', 'Y-m-d' );
|
||||
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_value( $end_input, 'Y-m-d', 'Y-m-d' );
|
||||
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>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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', 'event_datetime', 'date', 'start_time' ), true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) preg_match( '/^event_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', '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 );
|
||||
}
|
||||
Reference in New Issue
Block a user