(string) $post->post_title, 'content' => (string) $post->post_content, 'excerpt' => (string) $post->post_excerpt, 'status' => (string) $post->post_status, 'password' => (string) $post->post_password, 'author' => (int) $post->post_author, 'start' => $start, 'duration' => recurkit_seconds( $end ) - recurkit_seconds( $start ), 'timezone' => (string) get_post_meta( $id, '_EventTimezone', true ), 'all_day' => 'yes' === get_post_meta( $id, '_EventAllDay', true ), 'venue' => (int) get_post_meta( $id, '_EventVenueID', true ), 'organizers' => array_values( array_filter( array_map( 'intval', (array) get_post_meta( $id, '_EventOrganizerID' ) ) ) ), 'cats' => array_map( 'intval', (array) wp_get_object_terms( $id, 'tribe_events_cat', array( 'fields' => 'ids' ) ) ), 'tags' => array_map( 'intval', (array) wp_get_object_terms( $id, 'post_tag', array( 'fields' => 'ids' ) ) ), 'thumbnail' => (int) get_post_thumbnail_id( $id ), ); } /** * Creates one occurrence on $date through TEC; returns its id or 0. * * @param array $snap Snapshot of the original. * @param string $date Y-m-d. */ function recurkit_create_occurrence( array $snap, string $date ): int { $start = $date . substr( $snap['start'], 10 ); $args = array( 'title' => $snap['title'], 'post_content' => $snap['content'], 'post_excerpt' => $snap['excerpt'], 'status' => $snap['status'], 'post_author' => $snap['author'], 'start_date' => $start, 'end_date' => recurkit_add_seconds( $start, $snap['duration'] ), ); if ( '' !== $snap['password'] ) { $args['post_password'] = $snap['password']; // A protected event never turns into public copies. } if ( '' !== $snap['timezone'] ) { $args['timezone'] = $snap['timezone']; } if ( $snap['all_day'] ) { $args['all_day'] = true; } if ( $snap['venue'] ) { $args['venue'] = $snap['venue']; } if ( $snap['organizers'] ) { $args['organizer'] = $snap['organizers']; } $event = tribe_events()->set_args( $args )->create(); $id = ( is_object( $event ) && isset( $event->ID ) ) ? (int) $event->ID : 0; if ( ! $id ) { return 0; } // Set terms directly: TEC drops them from create() when the current user cannot assign terms (Day-0 probe). wp_set_object_terms( $id, $snap['cats'], 'tribe_events_cat' ); wp_set_object_terms( $id, $snap['tags'], 'post_tag' ); if ( $snap['thumbnail'] ) { set_post_thumbnail( $id, $snap['thumbnail'] ); } return $id; } /** * Ids of every occurrence of a series, sorted by start date. * * @param string $series Series id. * @return int[] */ function recurkit_series_event_ids( string $series ): array { if ( '' === $series ) { return array(); } $ids = array_map( 'intval', get_posts( array( 'post_type' => RECURKIT_POST_TYPE, 'post_status' => recurkit_statuses(), 'numberposts' => -1, 'fields' => 'ids', 'meta_key' => '_recurkit_series', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin-only, one series. 'meta_value' => $series, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- admin-only, one series. 'tribe_suppress_query_filters' => true, 'suppress_filters' => true, ) ) ); return recurkit_sort_by_start( $ids ); } /** * Event ids sorted by _EventStartDate, then id. * * @param int[] $ids Events. * @return int[] */ function recurkit_sort_by_start( array $ids ): array { $starts = array(); foreach ( $ids as $id ) { $starts[ $id ] = (string) get_post_meta( $id, '_EventStartDate', true ); } usort( $ids, static function ( $a, $b ) use ( $starts ) { return array( $starts[ $a ], $a ) <=> array( $starts[ $b ], $b ); } ); return $ids; } /** * Applies $fields to an occurrence and every later one in its series. Returns how many events changed. * Fields (all optional, already sanitized): title, content, venue (int), start_time and end_time (H:i, both or none). * * @param int $event_id Occurrence the user picked. * @param array $fields Changes. */ function recurkit_update_from( int $event_id, array $fields ): int { $series = (string) get_post_meta( $event_id, '_recurkit_series', true ); $from = (string) get_post_meta( $event_id, '_EventStartDate', true ); if ( '' === $series || ! recurkit_is_datetime( $from ) ) { return 0; } $args = array(); if ( isset( $fields['title'] ) && '' !== $fields['title'] ) { $args['title'] = (string) $fields['title']; } if ( isset( $fields['content'] ) ) { $args['post_content'] = (string) $fields['content']; } if ( ! empty( $fields['venue'] ) ) { $args['venue'] = (int) $fields['venue']; } $times = isset( $fields['start_time'], $fields['end_time'] ) && recurkit_is_hm( $fields['start_time'] ) && recurkit_is_hm( $fields['end_time'] ); if ( ! $args && ! $times ) { return 0; } $changed = 0; recurkit_is_busy( true ); try { foreach ( recurkit_series_event_ids( $series ) as $id ) { $start = (string) get_post_meta( $id, '_EventStartDate', true ); if ( $start < $from ) { continue; } $these = $args; if ( $times ) { // Keep how many days each event spans; the end rolls one more day only if it would not be after the start. $day = substr( $start, 0, 10 ); $old_end = (string) get_post_meta( $id, '_EventEndDate', true ); $span = recurkit_is_datetime( $old_end ) ? max( 0, (int) round( ( recurkit_seconds( substr( $old_end, 0, 10 ) . ' 00:00:00' ) - recurkit_seconds( $day . ' 00:00:00' ) ) / DAY_IN_SECONDS ) ) : 0; $these['start_date'] = $day . ' ' . $fields['start_time'] . ':00'; $end = recurkit_add_seconds( $day . ' ' . $fields['end_time'] . ':00', $span * DAY_IN_SECONDS ); $these['end_date'] = $end <= $these['start_date'] ? recurkit_add_seconds( $end, DAY_IN_SECONDS ) : $end; } if ( tribe_events()->where( 'id', $id )->set_args( $these )->save() ) { ++$changed; } } } finally { recurkit_is_busy( false ); } return $changed; } /** * Deletes every occurrence of a series through TEC. Returns how many are gone. * * @param string $series Series id. */ function recurkit_delete_series( string $series ): int { $gone = 0; recurkit_is_busy( true ); try { foreach ( recurkit_series_event_ids( $series ) as $id ) { if ( recurkit_delete_event( $id ) ) { ++$gone; } } } finally { recurkit_is_busy( false ); } return $gone; } /** * Deletes one event for good: through TEC first; core wp_delete_post() when TEC is inactive or skipped it (TEC's ORM * ignores trashed events; Playground E2E 2026-11-05). TEC's delete_post handling clears its own tables. True when gone. * * @param int $id Event. */ function recurkit_delete_event( int $id ): bool { if ( function_exists( 'tribe_events' ) ) { tribe_events()->where( 'id', $id )->delete(); } if ( null !== get_post( $id ) ) { wp_delete_post( $id, true ); } return null === get_post( $id ); } /** * Every series with its title, rule, count and next occurrence on or after $today, oldest first. * * @param string $today Y-m-d. * @return array[] */ function recurkit_list_series( string $today ): array { $all = get_posts( array( 'post_type' => RECURKIT_POST_TYPE, 'post_status' => recurkit_statuses(), 'numberposts' => -1, 'fields' => 'ids', 'meta_key' => '_recurkit_series', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin-only listing. 'tribe_suppress_query_filters' => true, 'suppress_filters' => true, ) ); $groups = array(); foreach ( recurkit_sort_by_start( array_map( 'intval', $all ) ) as $id ) { $groups[ (string) get_post_meta( $id, '_recurkit_series', true ) ][] = $id; } $out = array(); foreach ( $groups as $series => $ids ) { $next = ''; foreach ( $ids as $id ) { $start = (string) get_post_meta( $id, '_EventStartDate', true ); if ( substr( $start, 0, 10 ) >= $today ) { $next = $start; break; } } $rule = get_post_meta( $ids[0], '_recurkit_rule', true ); $out[] = array( 'series' => (string) $series, 'parent' => $ids[0], 'title' => get_the_title( $ids[0] ), 'rule' => is_array( $rule ) ? $rule : array(), 'count' => count( $ids ), 'next' => $next, 'ids' => $ids, ); } return $out; } /** * Every real post status, trash included, so a trashed occurrence still belongs to its series (delete, uninstall). * * @return string[] */ function recurkit_statuses(): array { return array_values( array_diff( array_keys( get_post_stati() ), array( 'auto-draft', 'inherit' ) ) ); } /** * True for "Y-m-d H:i:s". * * @param mixed $value Candidate. */ function recurkit_is_datetime( $value ): bool { return is_string( $value ) && 19 === strlen( $value ) && recurkit_is_ymd( substr( $value, 0, 10 ) ) && (bool) preg_match( '/^ ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/', substr( $value, 10 ) ); } /** * True for "H:i" (24-hour). * * @param mixed $value Candidate. */ function recurkit_is_hm( $value ): bool { return is_string( $value ) && (bool) preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $value ); } /** * Wall-clock seconds of a "Y-m-d H:i:s" (read as UTC so DST never shifts a duration). * * @param string $datetime Datetime. */ function recurkit_seconds( string $datetime ): int { return ( new DateTimeImmutable( $datetime, new DateTimeZone( 'UTC' ) ) )->getTimestamp(); } /** * "Y-m-d H:i:s" plus $seconds of wall-clock time. * * @param string $datetime Datetime. * @param int $seconds Seconds to add. */ function recurkit_add_seconds( string $datetime, int $seconds ): string { return gmdate( 'Y-m-d H:i:s', recurkit_seconds( $datetime ) + $seconds ); } /** * Uninstall clean-up for the current site: deletes generated events when the owner chose that, then removes every * _recurkit_* post meta. The events the owner created stay. Uses TEC's API when TEC is loaded, else core. */ function recurkit_purge_site(): void { if ( 'delete' === recurkit_get_uninstall_mode() ) { $generated = get_posts( array( 'post_type' => RECURKIT_POST_TYPE, 'post_status' => recurkit_statuses(), 'numberposts' => -1, 'fields' => 'ids', 'meta_key' => '_recurkit_generated', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- runs once, at uninstall. 'tribe_suppress_query_filters' => true, 'suppress_filters' => true, ) ); foreach ( array_map( 'intval', $generated ) as $id ) { recurkit_delete_event( $id ); } } foreach ( array( '_recurkit_series', '_recurkit_rule', '_recurkit_generated' ) as $key ) { delete_post_meta_by_key( $key ); } } /** Runs recurkit_purge_site() on every site of a network, or the single site. Called by uninstall.php. */ function recurkit_purge_all_sites(): void { if ( ! is_multisite() ) { recurkit_purge_site(); return; } foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0, ) ) as $blog_id ) { switch_to_blog( (int) $blog_id ); recurkit_purge_site(); restore_current_blog(); } }