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
+185 -2
View File
@@ -9,10 +9,74 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Check whether an ACF field group created in the admin targets the event post type.
*
* @return bool True if an external field group is assigned to events.
*/
function events_has_admin_field_group() {
if ( ! function_exists( 'acf_get_field_groups' ) ) {
return false;
}
$groups = acf_get_field_groups( array( 'post_type' => 'event' ) );
foreach ( $groups as $group ) {
if ( isset( $group['active'] ) && ! $group['active'] ) {
continue;
}
if (
isset( $group['key'], $group['local'] )
&& 'group_61b0c5f5a3e7e' === $group['key']
&& 'php' === $group['local']
) {
continue;
}
$targets_events = false;
foreach ( $group['location'] ?? array() as $location_group ) {
foreach ( $location_group as $rule ) {
if (
isset( $rule['param'], $rule['operator'], $rule['value'] )
&& 'post_type' === $rule['param']
&& '==' === $rule['operator']
&& 'event' === $rule['value']
) {
$targets_events = true;
break 2;
}
}
}
if ( ! $targets_events || ! function_exists( 'acf_get_fields' ) ) {
continue;
}
foreach ( (array) acf_get_fields( $group ) as $field ) {
if (
isset( $field['name'] )
&& in_array( $field['name'], array( 'event_datetime', 'event_dates', 'multiple_dates', 'date' ), true )
) {
return true;
}
}
}
return false;
}
/**
* Register the ACF field group.
*
* Skips registration when an admin-created field group is already
* assigned to the event post type, allowing full editorial control.
*/
function events_register_acf_fields() {
if ( events_has_admin_field_group() ) {
return;
}
if ( function_exists( 'acf_add_local_field_group' ) ) :
acf_add_local_field_group(
@@ -25,8 +89,8 @@ function events_register_acf_fields() {
'label' => 'Event Date & Time',
'name' => 'event_datetime',
'type' => 'date_time_picker',
'instructions' => '',
'required' => 1,
'instructions' => 'Legacy compatibility field. Use Occurrences for new events.',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '',
@@ -56,6 +120,109 @@ function events_register_acf_fields() {
'append' => '',
'maxlength' => '',
),
array(
'key' => 'field_61b0c7f0a3e8f',
'label' => 'Occurrences',
'name' => 'event_dates',
'type' => 'repeater',
'instructions' => 'Each row is one occurrence. Add an end date for a continuous multi-day event; add more rows when the event recurs.',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '',
'class' => '',
'id' => '',
),
'collapsed' => '',
'min' => 0,
'max' => 0,
'layout' => 'row',
'button_label' => 'Add Occurrence',
'sub_fields' => array(
array(
'key' => 'field_61b0c7f1a3e90',
'label' => 'Date',
'name' => 'date',
'type' => 'date_picker',
'instructions' => '',
'required' => 1,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '25',
'class' => '',
'id' => '',
),
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
'first_day' => 1,
),
array(
'key' => 'field_61b0c7f2a3e91',
'label' => 'Start Time',
'name' => 'start_time',
'type' => 'time_picker',
'instructions' => '',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '20',
'class' => '',
'id' => '',
),
'display_format' => 'g:i a',
'return_format' => 'H:i:s',
),
array(
'key' => 'field_aa_events_end_date',
'label' => 'End Date',
'name' => 'end_date',
'type' => 'date_picker',
'instructions' => '',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '25',
'class' => '',
'id' => '',
),
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
'first_day' => 1,
),
array(
'key' => 'field_aa_events_end_time',
'label' => 'End Time',
'name' => 'end_time',
'type' => 'time_picker',
'instructions' => '',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '20',
'class' => '',
'id' => '',
),
'display_format' => 'g:i a',
'return_format' => 'H:i:s',
),
array(
'key' => 'field_aa_events_all_day',
'label' => 'All Day',
'name' => 'all_day',
'type' => 'true_false',
'instructions' => '',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '10',
'class' => '',
'id' => '',
),
'default_value' => 0,
'ui' => 1,
),
),
),
array(
'key' => 'field_61b0c6cba3e83',
'label' => 'Online/In-Person',
@@ -126,6 +293,22 @@ function events_register_acf_fields() {
'rows' => '',
'new_lines' => '',
),
array(
'key' => 'field_aa_events_contact_email',
'label' => 'Contact Email',
'name' => 'event_contact_email',
'type' => 'email',
'instructions' => '',
'required' => 0,
'conditional_logic' => 0,
'wrapper' => array(
'width' => '',
'class' => '',
'id' => '',
),
'default_value' => '',
'placeholder' => '',
),
),
'location' => array(
array(
+249
View File
@@ -0,0 +1,249 @@
<?php
/**
* Pure calendar layout helpers.
*
* @package AA_Events
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Build the complete week grid for one month.
*
* @param mixed $year Four-digit year.
* @param mixed $month Month number.
* @param mixed $start_of_week WordPress weekday number (0 Sunday through 6 Saturday).
* @return array
* @throws InvalidArgumentException For invalid arguments.
*/
function aa_events_calendar_month( $year, $month, $start_of_week ) {
if ( ! is_int( $year ) || ! is_int( $month ) || ! is_int( $start_of_week ) ) {
throw new InvalidArgumentException( 'Calendar year, month, and start of week must be integers.' );
}
if ( $year < 1 || $year > 9999 || $month < 1 || $month > 12 || $start_of_week < 0 || $start_of_week > 6 ) {
throw new InvalidArgumentException( 'Invalid calendar year, month, or start of week.' );
}
$month_value = sprintf( '%04d-%02d-01', $year, $month );
$month_start = DateTimeImmutable::createFromFormat( '!Y-m-d', $month_value, wp_timezone() );
$errors = DateTimeImmutable::getLastErrors();
if ( false === $month_start || ( is_array( $errors ) && ( $errors['warning_count'] || $errors['error_count'] ) ) || $month_start->format( 'Y-m-d' ) !== $month_value ) {
throw new InvalidArgumentException( 'Invalid calendar month.' );
}
$month_end = $month_start->modify( 'last day of this month' );
$leading_days = ( (int) $month_start->format( 'w' ) - $start_of_week + 7 ) % 7;
$grid_start = $month_start->modify( '-' . $leading_days . ' days' );
$trailing_days = ( $start_of_week + 6 - (int) $month_end->format( 'w' ) + 7 ) % 7;
$grid_end = $month_end->modify( '+' . $trailing_days . ' days' );
$weeks = array();
$week_start = $grid_start;
$week_index = 0;
while ( $week_start <= $grid_end ) {
$days = array();
for ( $day = 0; $day < 7; ++$day ) {
$days[] = $week_start->modify( '+' . $day . ' days' );
}
$weeks[] = array(
'index' => $week_index,
'key' => $week_start->format( 'Y-m-d' ),
'start' => $week_start,
'end' => $week_start->modify( '+6 days' ),
'days' => $days,
);
$week_start = $week_start->modify( '+7 days' );
++$week_index;
}
return compact( 'month_start', 'month_end', 'grid_start', 'grid_end', 'start_of_week', 'weeks' );
}
/**
* Determine whether an occurrence overlaps an inclusive date range.
*
* @param array $occurrence Occurrence metadata.
* @param DateTimeImmutable $start Range start.
* @param DateTimeImmutable $end Range end.
* @return bool
*/
function aa_events_occurrence_intersects_range( array $occurrence, DateTimeImmutable $start, DateTimeImmutable $end ) {
$parse_date = function ( $value ) {
if ( ! is_string( $value ) ) {
return null;
}
$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
$errors = DateTimeImmutable::getLastErrors();
return false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d' ) === $value ? $date : null;
};
$occurrence_start = $parse_date( $occurrence['start_date'] ?? null );
$occurrence_end = $parse_date( $occurrence['end_date'] ?? ( $occurrence['start_date'] ?? null ) );
return null !== $occurrence_start && null !== $occurrence_end && $occurrence_end >= $occurrence_start && $end >= $start && $occurrence_start <= $end && $occurrence_end >= $start;
}
/**
* Clip an occurrence to a month and split it at week boundaries.
*
* @param array $occurrence Occurrence metadata.
* @param array $month Calendar month shape.
* @return array
*/
function aa_events_calendar_segments( array $occurrence, array $month ) {
if ( ! isset( $month['month_start'], $month['month_end'], $month['weeks'] ) || ! $month['month_start'] instanceof DateTimeImmutable || ! $month['month_end'] instanceof DateTimeImmutable || ! aa_events_occurrence_intersects_range( $occurrence, $month['month_start'], $month['month_end'] ) ) {
return array();
}
$parse_date = function ( $value ) {
if ( ! is_string( $value ) ) {
return null;
}
$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
$errors = DateTimeImmutable::getLastErrors();
return false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d' ) === $value ? $date : null;
};
$true_start = $parse_date( $occurrence['start_date'] );
$true_end = $parse_date( $occurrence['end_date'] ?? $occurrence['start_date'] );
$visible_start = max( $true_start, $month['month_start'] );
$visible_end = min( $true_end, $month['month_end'] );
$segments = array();
foreach ( $month['weeks'] as $week_index => $week ) {
if ( $visible_start > $week['end'] || $visible_end < $week['start'] ) {
continue;
}
$segment_start = max( $visible_start, $week['start'] );
$segment_end = min( $visible_end, $week['end'] );
$segment = $occurrence;
$segment['week_index'] = (int) $week_index;
$segment['week_key'] = $week['key'] ?? $week['start']->format( 'Y-m-d' );
$segment['segment_start'] = $segment_start;
$segment['segment_end'] = $segment_end;
$segment['start_column'] = (int) $week['start']->diff( $segment_start )->days;
$segment['span'] = (int) $segment_start->diff( $segment_end )->days + 1;
$segment['continues_before'] = $true_start < $segment_start;
$segment['continues_after'] = $true_end > $segment_end;
$segments[] = $segment;
}
return $segments;
}
/**
* Sort segments and assign the first non-overlapping lane.
*
* @param array $segments Week segments.
* @return array
*/
function aa_events_assign_segment_lanes( array $segments ) {
$decorated = array();
foreach ( $segments as $index => $segment ) {
$decorated[] = array(
'segment' => $segment,
'order' => $index,
);
}
usort(
$decorated,
function ( $left, $right ) {
$comparison = (int) ( $left['segment']['start_column'] ?? 0 ) <=> (int) ( $right['segment']['start_column'] ?? 0 );
if ( 0 === $comparison ) {
$comparison = (int) ( $right['segment']['span'] ?? 0 ) <=> (int) ( $left['segment']['span'] ?? 0 );
}
if ( 0 === $comparison ) {
$comparison = (int) ( $left['segment']['post_id'] ?? 0 ) <=> (int) ( $right['segment']['post_id'] ?? 0 );
}
return 0 === $comparison ? $left['order'] <=> $right['order'] : $comparison;
}
);
$lane_ends = array();
$segments = array();
foreach ( $decorated as $item ) {
$segment = $item['segment'];
$start = (int) ( $segment['start_column'] ?? 0 );
$end = $start + max( 1, (int) ( $segment['span'] ?? 1 ) ) - 1;
$lane = 0;
while ( isset( $lane_ends[ $lane ] ) && $lane_ends[ $lane ] >= $start ) {
++$lane;
}
$segment['lane'] = $lane;
$lane_ends[ $lane ] = $end;
$segments[] = $segment;
}
return $segments;
}
/**
* Group all occurrence segments into the month's complete week list.
*
* @param array $occurrences Occurrences to lay out.
* @param array $month Calendar month shape.
* @return array
*/
function aa_events_group_calendar_weeks( array $occurrences, array $month ) {
$weeks = array();
foreach ( $month['weeks'] ?? array() as $index => $week ) {
$week['segments'] = array();
$week['lane_count'] = 0;
$weeks[ $index ] = $week;
}
foreach ( $occurrences as $occurrence ) {
if ( ! is_array( $occurrence ) ) {
continue;
}
foreach ( aa_events_calendar_segments( $occurrence, $month ) as $segment ) {
if ( isset( $weeks[ $segment['week_index'] ] ) ) {
$weeks[ $segment['week_index'] ]['segments'][] = $segment;
}
}
}
foreach ( $weeks as &$week ) {
$week['segments'] = aa_events_assign_segment_lanes( $week['segments'] );
if ( $week['segments'] ) {
$week['lane_count'] = max( array_column( $week['segments'], 'lane' ) ) + 1;
}
}
unset( $week );
return array_values( $weeks );
}
/**
* Sort calendar occurrences for the mobile agenda.
*
* @param array $occurrences Enriched normalized occurrences.
* @return array
*/
function aa_events_sort_calendar_occurrences( array $occurrences ) {
$decorated = array();
foreach ( $occurrences as $index => $occurrence ) {
if ( ! is_array( $occurrence ) ) {
continue;
}
$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 ) {
$comparison = strcasecmp( (string) ( $left_occurrence['title'] ?? '' ), (string) ( $right_occurrence['title'] ?? '' ) );
}
if ( 0 === $comparison ) {
$comparison = (int) ( $left_occurrence['post_id'] ?? 0 ) <=> (int) ( $right_occurrence['post_id'] ?? 0 );
}
foreach ( array( 'end_date', 'end_time' ) as $key ) {
if ( 0 !== $comparison ) {
break;
}
$comparison = strcmp( (string) ( $left_occurrence[ $key ] ?? '' ), (string) ( $right_occurrence[ $key ] ?? '' ) );
}
return 0 === $comparison ? $left['index'] <=> $right['index'] : $comparison;
}
);
return array_column( $decorated, 'occurrence' );
}
+42
View File
@@ -50,6 +50,8 @@ final class AA_Events {
* Include required core files.
*/
public function includes() {
include_once AA_EVENTS_PLUGIN_DIR . 'includes/occurrences.php';
include_once AA_EVENTS_PLUGIN_DIR . 'includes/calendar-layout.php';
include_once AA_EVENTS_PLUGIN_DIR . 'includes/post-types.php';
include_once AA_EVENTS_PLUGIN_DIR . 'includes/acf-fields.php';
include_once AA_EVENTS_PLUGIN_DIR . 'includes/template-loader.php';
@@ -61,8 +63,10 @@ final class AA_Events {
private function init_hooks() {
add_action( 'init', array( $this, 'load_plugin_textdomain' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
// Ensure the active theme supports thumbnails for the event post type.
add_action( 'after_setup_theme', array( $this, 'ensure_post_thumbnails_support' ) );
add_filter( 'query_vars', array( $this, 'register_calendar_query_vars' ) );
}
/**
@@ -88,6 +92,31 @@ final class AA_Events {
}
}
/**
* Enqueue admin assets for event editor UX fixes.
*
* @param string $hook_suffix Current admin page hook.
* @return void
*/
public function enqueue_admin_assets( $hook_suffix ) {
if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || 'event' !== $screen->post_type ) {
return;
}
wp_enqueue_script(
'aa-events-admin',
AA_EVENTS_PLUGIN_URL . 'assets/js/aa-events-admin.js',
array( 'jquery' ),
AA_EVENTS_VERSION,
true
);
}
/**
* Load Localisation files.
*/
@@ -125,4 +154,17 @@ final class AA_Events {
}
// If theme support is boolean true (all types), nothing to do.
}
/**
* Allow custom AA Events calendar query vars to persist through canonical redirects.
*
* @param array $vars Public query vars.
* @return array
*/
public function register_calendar_query_vars( $vars ) {
$vars[] = 'aa_month';
$vars[] = 'aa_year';
return $vars;
}
}
+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 );
}
+146 -19
View File
@@ -146,10 +146,15 @@ add_filter( 'manage_edit-event_columns', 'events_add_admin_columns' );
*/
function events_display_admin_column_content( $column, $post_id ) {
if ( 'event_datetime' === $column ) {
$datetime = get_field( 'event_datetime', $post_id );
$occurrences = aa_events_get_occurrences( $post_id );
if ( ! $occurrences ) {
echo esc_html( '—' );
return;
}
if ( $datetime ) {
echo esc_html( gmdate( 'F j, Y g:i a', strtotime( $datetime ) ) );
echo esc_html( aa_events_format_occurrence( $occurrences[0] ) );
if ( count( $occurrences ) > 1 ) {
echo ' <span class="aa-event-occurrence-count">+' . esc_html( count( $occurrences ) - 1 ) . '</span>';
}
}
}
@@ -193,13 +198,27 @@ function events_get_sortable_meta_map() {
* @param WP_Query $query The WordPress query object.
*/
function events_sort_by_custom_column( $query ) {
if ( ! is_admin() || ! $query->is_main_query() ) {
if ( ! is_admin() || ! $query->is_main_query() || 'event' !== $query->get( 'post_type' ) ) {
return;
}
$orderby = $query->get( 'orderby' );
$map = events_get_sortable_meta_map();
// Special handling for the event_datetime column in admin.
// Use a custom SQL sort across multiple possible date fields while keeping all posts visible.
if ( 'event_datetime' === $orderby ) {
$query->set( 'aa_events_date_sort', 1 );
$query->set( 'orderby', 'none' );
$order = strtoupper( (string) $query->get( 'order' ) );
if ( 'DESC' !== $order ) {
$order = 'ASC';
}
$query->set( 'order', $order );
return;
}
if ( isset( $map[ $orderby ] ) ) {
$meta_type = strtoupper( $map[ $orderby ] );
$query->set( 'meta_key', $orderby );
@@ -213,6 +232,115 @@ function events_sort_by_custom_column( $query ) {
}
add_action( 'pre_get_posts', 'events_sort_by_custom_column' );
/**
* Build a safe SQL expression for supported stored date formats.
*
* @param string $column Static SQL column expression.
* @param bool $allow_datetime Whether to accept a full legacy datetime.
* @return string
*/
function events_admin_date_meta_expression( $column, $allow_datetime = false ) {
$datetime = $allow_datetime ? "WHEN {$column} REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$' THEN STR_TO_DATE({$column}, '%Y-%m-%d %H:%i:%s') " : '';
return "CASE {$datetime}WHEN {$column} REGEXP '^[0-9]{8}$' THEN STR_TO_DATE({$column}, '%Y%m%d') WHEN {$column} REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN STR_TO_DATE({$column}, '%Y-%m-%d') ELSE NULL END";
}
/**
* Build the deterministic admin occurrence ORDER BY expression.
*
* @param string $posts_table Static posts table name.
* @param string $order Requested direction.
* @return string
*/
function events_admin_date_sort_orderby_sql( $posts_table, $order ) {
$order = 'DESC' === strtoupper( (string) $order ) ? 'DESC' : 'ASC';
$postmeta_table = events_date_sort_postmeta_table();
$canonical_expression = events_admin_date_meta_expression( 'aa_sort.meta_value', true );
$date_expression = events_admin_date_meta_expression( 'aa_er.meta_value' );
$datetime_expression = events_admin_date_meta_expression( 'aa_edt.meta_value', true );
$legacy_expression = events_admin_date_meta_expression( 'aa_d.meta_value' );
$start_time_value = "(SELECT aa_time.meta_value FROM {$postmeta_table} AS aa_time WHERE aa_time.post_id = aa_er.post_id AND aa_time.meta_key = CONCAT(LEFT(aa_er.meta_key, LENGTH(aa_er.meta_key) - 4), 'start_time') ORDER BY aa_time.meta_id ASC LIMIT 1)";
$all_day_value = "(SELECT aa_all_day.meta_value FROM {$postmeta_table} AS aa_all_day WHERE aa_all_day.post_id = aa_er.post_id AND aa_all_day.meta_key = CONCAT(LEFT(aa_er.meta_key, LENGTH(aa_er.meta_key) - 4), 'all_day') ORDER BY aa_all_day.meta_id ASC LIMIT 1)";
$start_time_expression = "CASE WHEN LOWER(TRIM(COALESCE({$all_day_value}, ''))) IN ('1', 'true', 'yes', 'on') THEN STR_TO_DATE('00:00:00', '%H:%i:%s') WHEN {$start_time_value} REGEXP '^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$' THEN CASE WHEN CHAR_LENGTH({$start_time_value}) = 5 THEN STR_TO_DATE({$start_time_value}, '%H:%i') ELSE STR_TO_DATE({$start_time_value}, '%H:%i:%s') END ELSE STR_TO_DATE('00:00:00', '%H:%i:%s') END";
$repeater = "(SELECT MIN(TIMESTAMP({$date_expression}, {$start_time_expression})) FROM {$postmeta_table} AS aa_er WHERE aa_er.post_id = {$posts_table}.ID AND aa_er.meta_key REGEXP '^event_dates_[0-9]+_date$')";
$legacy_datetime = "(SELECT MIN({$datetime_expression}) FROM {$postmeta_table} AS aa_edt WHERE aa_edt.post_id = {$posts_table}.ID AND aa_edt.meta_key = 'event_datetime')";
$legacy_date = "(SELECT MIN({$legacy_expression}) FROM {$postmeta_table} AS aa_d WHERE aa_d.post_id = {$posts_table}.ID AND aa_d.meta_key = 'date')";
return "COALESCE({$canonical_expression}, {$repeater}, {$legacy_datetime}, {$legacy_date}, {$posts_table}.post_date) {$order}, {$posts_table}.post_date {$order}, {$posts_table}.ID {$order}";
}
/**
* Return the postmeta table used by date-sort SQL helpers.
*
* @return string
*/
function events_date_sort_postmeta_table() {
global $wpdb;
return isset( $wpdb->postmeta ) ? $wpdb->postmeta : 'wp_postmeta';
}
/**
* Build grouped joins for normalized event date sort values.
*
* @param string $postmeta_table Static postmeta table name.
* @param string $posts_table Static posts table name.
* @return string
*/
function events_admin_date_sort_join_sql( $postmeta_table, $posts_table ) {
return " LEFT JOIN {$postmeta_table} AS aa_sort ON aa_sort.meta_id = (SELECT MIN(aa_sort_pick.meta_id) FROM {$postmeta_table} AS aa_sort_pick WHERE aa_sort_pick.post_id = {$posts_table}.ID AND aa_sort_pick.meta_key = '_aa_events_sort_start')";
}
/**
* Check whether an occurrence sort flag belongs to a supported event query.
*
* @param WP_Query $query Query object.
* @return bool
*/
function events_date_sort_query_applies( $query ) {
if ( ! $query->is_main_query() || ! $query->get( 'aa_events_date_sort' ) ) {
return false;
}
if ( is_admin() ) {
return 'event' === $query->get( 'post_type' );
}
return $query->is_post_type_archive( 'event' ) || $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' );
}
/**
* Add occurrence date sort joins to a flagged main query.
*
* @param string $join SQL JOIN clause.
* @param WP_Query $query Query object.
* @return string
*/
function events_date_sort_posts_join( $join, $query ) {
global $wpdb;
if ( ! events_date_sort_query_applies( $query ) ) {
return $join;
}
return $join . events_admin_date_sort_join_sql( $wpdb->postmeta, $wpdb->posts );
}
add_filter( 'posts_join', 'events_date_sort_posts_join', 10, 2 );
/**
* Apply deterministic occurrence date ordering to a flagged main query.
*
* @param string $orderby SQL ORDER BY clause.
* @param WP_Query $query Query object.
* @return string
*/
function events_date_sort_posts_orderby( $orderby, $query ) {
global $wpdb;
if ( ! events_date_sort_query_applies( $query ) ) {
return $orderby;
}
// Priority: earliest repeater date -> legacy datetime -> legacy date -> post date.
return events_admin_date_sort_orderby_sql( $wpdb->posts, $query->get( 'order' ) );
}
add_filter( 'posts_orderby', 'events_date_sort_posts_orderby', 10, 2 );
/**
* Extend REST API to allow ordering events by meta value.
* Adds support for `orderby=meta_value|meta_value_num` and `meta_key`.
@@ -287,8 +415,7 @@ function events_rest_adjust_query_for_meta_ordering( $args, $request ) {
add_filter( 'rest_event_query', 'events_rest_adjust_query_for_meta_ordering', 10, 2 );
/**
* Set default sort order for event post type by event_datetime ascending.
* Applies to both admin and frontend queries.
* Set occurrence-aware default sort order for event list queries.
*
* @param WP_Query $query The WordPress query object.
*/
@@ -298,17 +425,19 @@ function events_set_default_sort_order( $query ) {
return;
}
// Admin list table
// Admin list table: default to event start-date sorting.
if ( is_admin() && $query->get( 'post_type' ) === 'event' ) {
if ( ! $query->get( 'orderby' ) ) {
$query->set( 'meta_key', 'event_datetime' );
$query->set( 'orderby', 'meta_value' );
$query->set( 'aa_events_date_sort', 1 );
$query->set( 'orderby', 'none' );
$query->set( 'order', 'ASC' );
}
}
// Frontend: event archives and taxonomies
if ( ! is_admin() && $query->get( 'post_type' ) === 'event' ) {
// Frontend: event archives and taxonomies. Taxonomy queries do not always expose post_type.
$is_event_archive = ! is_admin() && $query->is_post_type_archive( 'event' );
$is_event_taxonomy = ! is_admin() && ( $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' ) );
if ( $is_event_archive || $is_event_taxonomy ) {
$map = events_get_sortable_meta_map();
$orderby = $query->get( 'orderby' );
@@ -326,20 +455,18 @@ function events_set_default_sort_order( $query ) {
}
// Main event archive: newest first (descending by date/time)
if ( $query->is_post_type_archive( 'event' ) ) {
if ( $is_event_archive ) {
if ( ! $query->get( 'orderby' ) ) {
$query->set( 'meta_key', 'event_datetime' );
$query->set( 'meta_type', 'DATETIME' );
$query->set( 'orderby', 'meta_value' );
$query->set( 'aa_events_date_sort', 1 );
$query->set( 'orderby', 'none' );
$query->set( 'order', 'DESC' );
}
}
// Taxonomy views: upcoming first (ascending)
if ( ( $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' ) ) && ! $query->get( 'orderby' ) ) {
$query->set( 'meta_key', 'event_datetime' );
$query->set( 'meta_type', 'DATETIME' );
$query->set( 'orderby', 'meta_value' );
if ( $is_event_taxonomy && ! $query->get( 'orderby' ) ) {
$query->set( 'aa_events_date_sort', 1 );
$query->set( 'orderby', 'none' );
$query->set( 'order', 'ASC' );
}
}
+17
View File
@@ -9,6 +9,23 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Build safe contact-email markup for event templates.
*
* @param mixed $value Raw contact email value.
* @return string Escaped mailto anchor, or an empty string for invalid input.
*/
function aa_events_contact_email_markup( $value ) {
$raw = trim( (string) $value );
$email = sanitize_email( $raw );
if ( '' === $email || $email !== $raw ) {
return '';
}
$protected = antispambot( $email );
return '<a href="' . esc_attr( 'mailto:' . $protected ) . '">' . wp_kses_post( $protected ) . '</a>';
}
/**
* Load a template.
*