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:
Keith Solomon
2026-07-06 10:26:36 -05:00
parent 6ba917bf9b
commit 42ff7754cb
16 changed files with 2205 additions and 344 deletions
+755
View File
@@ -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 );
}