post_type ) { self::queue( $post->ID ); } } /** * Term changes. * * @param int $object_id Object ID. * @param array $terms Terms. * @param array $tt_ids Term taxonomy IDs. * @param string $taxonomy Taxonomy. */ public static function on_terms( $object_id, $terms, $tt_ids, $taxonomy ) { if ( in_array( $taxonomy, array( 'bcal_city', 'bcal_venue', 'bcal_category', 'bcal_organizer' ), true ) && BCAL_PT === get_post_type( $object_id ) ) { self::queue( $object_id ); } } /** * Meta changes on our keys. * * @param mixed $meta_id Meta ID(s). * @param int $object_id Post ID. * @param string $meta_key Meta key. */ public static function on_meta( $meta_id, $object_id, $meta_key ) { if ( 0 === strpos( (string) $meta_key, '_bcal_' ) && BCAL_PT === get_post_type( $object_id ) ) { self::queue( $object_id ); } } /** * Syncs queued events. */ public static function flush() { // Handlers of bcal_event_synced may change meta and queue events again. $rounds = 0; while ( self::$queue && $rounds++ < 5 ) { $ids = array_keys( self::$queue ); self::$queue = array(); foreach ( $ids as $id ) { self::sync_event( $id ); } } } /** * Rebuilds the rows of one event. * * @param int $post_id Event ID. * @return int Number of rows written. */ public static function sync_event( $post_id ) { global $wpdb; $post_id = (int) $post_id; unset( self::$queue[ $post_id ] ); self::$syncing = true; $table = BCal_Install::table(); $wpdb->delete( $table, array( 'event_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $post = get_post( $post_id ); $written = 0; if ( $post && BCAL_PT === $post->post_type && 'publish' === $post->post_status ) { $event = bcal_get_event( $post_id, true ); if ( $event['start'] ) { $months = max( 1, (int) bcal_opt( 'window_months' ) ); $horizon = ( new DateTimeImmutable( 'now', wp_timezone() ) )->modify( '+' . $months . ' months' )->format( 'Y-m-d H:i:s' ); $items = bcal_expand_occurrences( $event['start'], $event['end_effective'], $event['rrule'], $event['exdates'], $horizon, 500, bcal_is_recurring( $event ) ? bcal_now() : '', array( 'rdates' => $event['rdates'], 'exrule' => $event['exrule'], ) ); $city = bcal_first_term( $post_id, 'bcal_city' ); $venue = bcal_first_term( $post_id, 'bcal_venue' ); $score = get_post_meta( $post_id, '_bcal_score', true ); $base = array( 'city_id' => $city ? (int) $city->term_id : 0, 'venue_id' => $venue ? (int) $venue->term_id : 0, 'featured' => $event['featured'] ? 1 : 0, 'pick' => get_post_meta( $post_id, '_bcal_pick', true ) ? 1 : 0, 'hidden' => get_post_meta( $post_id, '_bcal_hidden', true ) ? 1 : 0, 'score' => ( '' === $score || null === $score ) ? null : (int) $score, 'status' => substr( $event['status'], 0, 20 ), ); foreach ( $items as $item ) { list( $start, $end ) = $item; if ( '' === $end || $event['all_day'] ) { // Without an explicit end the event stays listed until the end of its day. $end = substr( '' !== $end ? $end : $start, 0, 10 ) . ' 23:59:59'; } $row = array_merge( $base, array( 'event_id' => $post_id, 'start_dt' => $start, 'end_dt' => $end, 'start_date' => substr( $start, 0, 10 ), 'all_day' => $event['all_day'] ? 1 : 0, ) ); if ( null === $row['score'] ) { unset( $row['score'] ); } $formats = array(); foreach ( array_keys( $row ) as $column ) { $formats[] = in_array( $column, array( 'status', 'start_dt', 'end_dt', 'start_date' ), true ) ? '%s' : '%d'; } $wpdb->insert( $table, $row, $formats ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery ++$written; } } } self::$syncing = false; self::touch(); do_action( 'bcal_event_synced', $post_id, $written ); return $written; } /** * Removes all rows of an event. * * @param int $post_id Post ID. */ public static function delete_event( $post_id ) { global $wpdb; $wpdb->delete( BCal_Install::table(), array( 'event_id' => (int) $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::touch(); } /** * Updates a flag column on all rows of an event without a full resync. * Used by the ranking and moderation features. * * @param int $post_id Event ID. * @param string $column hidden|pick|featured|score. * @param int|null $value Value. */ public static function set_flag( $post_id, $column, $value ) { global $wpdb; if ( ! in_array( $column, array( 'hidden', 'pick', 'featured', 'score' ), true ) ) { return; } $table = BCal_Install::table(); if ( null === $value ) { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET %i = NULL WHERE event_id = %d', $table, $column, $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } else { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET %i = %d WHERE event_id = %d', $table, $column, (int) $value, $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } self::touch(); } /** * Invalidates cached queries. */ public static function touch() { wp_cache_set( 'last_changed', microtime(), 'bcal' ); } /** * Rebuilds a batch of events. Offset 0 clears the table first. * * @param int $offset Offset. * @param int $limit Batch size. * @return array { done, total, next } */ public static function rebuild_batch( $offset, $limit = 200 ) { global $wpdb; if ( 0 === (int) $offset ) { $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', BCal_Install::table() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'", BCAL_PT ) ); // phpcs:ignore WordPress.DB $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' ORDER BY ID ASC LIMIT %d OFFSET %d", BCAL_PT, $limit, $offset ) ); // phpcs:ignore WordPress.DB foreach ( $ids as $id ) { self::sync_event( (int) $id ); } $next = (int) $offset + count( $ids ); return array( 'done' => $next >= $total || ! $ids, 'total' => $total, 'next' => $next, ); } /** * Re-syncs, in the background, every event that has no end of its own. * Used when the default duration changes: their end moves with it. */ public static function schedule_resync() { update_option( 'bcal_resync', array( 'last_id' => 0, 'done' => 0, 'total' => self::count_without_end(), ), false ); wp_clear_scheduled_hook( 'bcal_resync_step' ); wp_schedule_single_event( time() + 5, 'bcal_resync_step' ); } /** * Published events without a saved end. * * @return int */ private static function count_without_end() { global $wpdb; return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = 'publish' AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID AND m.meta_key = '_bcal_end' AND m.meta_value <> '' )", BCAL_PT ) ); } /** * One background batch (cron). */ public static function resync_step() { global $wpdb; $state = get_option( 'bcal_resync' ); if ( ! is_array( $state ) ) { return; } $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = 'publish' AND p.ID > %d AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID AND m.meta_key = '_bcal_end' AND m.meta_value <> '' ) ORDER BY p.ID ASC LIMIT 300", BCAL_PT, (int) $state['last_id'] ) ); if ( ! $ids ) { delete_option( 'bcal_resync' ); return; } foreach ( $ids as $id ) { self::sync_event( (int) $id ); } $state['last_id'] = (int) end( $ids ); $state['done'] = (int) $state['done'] + count( $ids ); update_option( 'bcal_resync', $state, false ); wp_schedule_single_event( time() + 10, 'bcal_resync_step' ); } /** * Background re-sync progress, or null when idle. * * @return array|null { done, total } */ public static function resync_status() { $state = get_option( 'bcal_resync' ); return is_array( $state ) ? array( 'done' => (int) $state['done'], 'total' => max( (int) $state['total'], (int) $state['done'] ), ) : null; } /** * Daily job: extend recurring events past the rolling horizon. */ public static function daily() { global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key IN ( %s, %s ) AND meta_value <> ''", '_bcal_rrule', '_bcal_rdates' ) ); // phpcs:ignore WordPress.DB foreach ( $ids as $id ) { self::sync_event( (int) $id ); } } /** * Index statistics for the tools screen. * * @return array */ public static function stats() { global $wpdb; $table = BCal_Install::table(); return array( 'rows' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery 'events' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT event_id) FROM %i', $table ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery 'upcoming' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE end_dt >= %s', $table, bcal_now() ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery ); } }