isset( $settings['types'] ) && is_array( $settings['types'] ) ? $settings['types'] : array(), 'postponed' => isset( $settings['postponed'] ) ? (bool) $settings['postponed'] : true, ); } /** * Settings of a post type, or null when dates are off for it. * * @param string $post_type Post type. * @return array|null */ public static function type_settings( $post_type ) { $types = self::settings()['types']; if ( empty( $types[ $post_type ]['enabled'] ) ) { return null; } return wp_parse_args( $types[ $post_type ], array( 'enabled' => true, 'label' => '', 'category' => 0, 'source' => '', 'skip' => '', 'box' => 'none', ) ); } /** * Post types with dates switched on. * * @return array Settings keyed by post type. */ public static function enabled_types() { $enabled = array(); foreach ( array_keys( self::settings()['types'] ) as $post_type ) { $settings = self::type_settings( $post_type ); if ( $settings && post_type_exists( $post_type ) ) { $enabled[ $post_type ] = $settings; } } return $enabled; } /** * Post types that can carry a date. * * @return WP_Post_Type[] */ public static function available_types() { $types = get_post_types( array( 'public' => true, 'show_ui' => true, ), 'objects' ); unset( $types['attachment'] ); return $types; } /** * Label of the date field of a post type. * * @param array|null $settings Post type settings. * @return string */ public static function label( $settings ) { return ( $settings && '' !== $settings['label'] ) ? $settings['label'] : __( 'Event date', '3task-calendar' ); } /** * Clean the submitted settings. * * @param array $input Raw input. * @return array */ public static function sanitize( $input ) { $input = is_array( $input ) ? $input : array(); $types = array(); foreach ( array_keys( self::available_types() ) as $post_type ) { $row = isset( $input['types'][ $post_type ] ) ? (array) $input['types'][ $post_type ] : array(); if ( empty( $row['enabled'] ) ) { continue; } $source = isset( $row['source'] ) ? (string) $row['source'] : ''; if ( '__custom' === $source ) { $source = isset( $row['source_custom'] ) ? (string) $row['source_custom'] : ''; } $source = self::clean_key( $source ); $box = isset( $row['box'] ) ? sanitize_key( $row['box'] ) : 'none'; $types[ $post_type ] = array( 'enabled' => true, 'label' => isset( $row['label'] ) ? mb_substr( sanitize_text_field( $row['label'] ), 0, 60 ) : '', 'category' => isset( $row['category'] ) ? absint( $row['category'] ) : 0, 'source' => $source, 'skip' => isset( $row['skip'] ) ? self::clean_key( (string) $row['skip'] ) : '', 'box' => in_array( $box, array( 'none', 'before', 'after' ), true ) ? $box : 'none', ); } return array( 'types' => $types, 'postponed' => ! empty( $input['postponed'] ), ); } /** * Allowed characters of a meta key. * * @param string $key Meta key. * @return string */ private static function clean_key( $key ) { $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $key ); // Our own keys are not a source. return 0 === strpos( $key, '_threecal_' ) ? '' : $key; } /* --------------------------------------------------------------------- * Dates * ------------------------------------------------------------------ */ /** * Read a date in one of the usual formats. * * Accepts Y-m-d, Y-m-d H:i(:s), the ISO form with T, Ymd (ACF date * picker), d.m.Y (with time) and Unix timestamps. * * @param mixed $value Raw value. * @return array|null Array( 'Y-m-d H:i:s', has time ) or null. */ public static function parse_date( $value ) { if ( ! is_scalar( $value ) ) { return null; } $value = trim( (string) $value ); if ( '' === $value ) { return null; } // Unix timestamp; midnight counts as a date without time. if ( preg_match( '/^\d{9,10}$/', $value ) ) { $local = wp_date( 'Y-m-d H:i:s', (int) $value ); if ( ! $local ) { return null; } return self::valid_year( $local ) ? array( $local, '00:00:00' !== substr( $local, 11 ) ) : null; } $formats = array( 'Y-m-d H:i:s' => true, 'Y-m-d H:i' => true, 'Y-m-d\TH:i:s' => true, 'Y-m-d\TH:i' => true, 'Y-m-d' => false, 'Ymd' => false, 'd.m.Y H:i' => true, 'd.m.Y' => false, 'j.n.Y' => false, ); foreach ( $formats as $format => $has_time ) { $date = DateTime::createFromFormat( '!' . $format, $value ); if ( $date && $date->format( $format ) === $value ) { $out = $date->format( 'Y-m-d H:i:s' ); return self::valid_year( $out ) ? array( $out, $has_time ) : null; } } // ISO date with time zone, for example 2026-10-01T20:00:00+02:00. if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?([+-]\d{2}:?\d{2}|Z)$/', $value ) ) { $timestamp = strtotime( $value ); if ( $timestamp ) { $local = wp_date( 'Y-m-d H:i:s', $timestamp ); return self::valid_year( $local ) ? array( $local, true ) : null; } } return null; } /** * Only plausible years. * * @param string $date Y-m-d H:i:s. * @return bool */ private static function valid_year( $date ) { $year = (int) substr( $date, 0, 4 ); return $year >= 1900 && $year <= 2100; } /** * The date a post should have in the calendar. * * @param WP_Post $post Post. * @param array $settings Post type settings. * @return array|null Array with start, end and all_day, or null. */ public static function values( $post, $settings ) { $show = (string) get_post_meta( $post->ID, self::META_SHOW, true ); // A field that takes posts out of the calendar, for example "release on hold". if ( ! empty( $settings['skip'] ) ) { $skip = get_post_meta( $post->ID, $settings['skip'], true ); if ( is_scalar( $skip ) && '' !== (string) $skip && '0' !== (string) $skip ) { return null; } } if ( '' !== $settings['source'] ) { // Date from an existing field: on unless switched off in the post. if ( '0' === $show ) { return null; } $parsed = self::parse_date( get_post_meta( $post->ID, $settings['source'], true ) ); if ( ! $parsed ) { return null; } return array( 'start' => $parsed[1] ? $parsed[0] : substr( $parsed[0], 0, 10 ) . ' 00:00:00', 'end' => '', 'all_day' => ! $parsed[1], 'precision' => 'day', ); } // Own field: only when switched on in the post. if ( '1' !== $show ) { return null; } $all_day = '0' !== (string) get_post_meta( $post->ID, self::META_ALL_DAY, true ); $start = self::parse_date( get_post_meta( $post->ID, self::META_START, true ) ); if ( ! $start ) { return null; } // Only the month is known: first day of the month, all day, no end. if ( 'month' === (string) get_post_meta( $post->ID, self::META_PRECISION, true ) ) { return array( 'start' => substr( $start[0], 0, 7 ) . '-01 00:00:00', 'end' => '', 'all_day' => true, 'precision' => 'month', ); } $start_value = $all_day ? substr( $start[0], 0, 10 ) . ' 00:00:00' : $start[0]; $end = self::parse_date( get_post_meta( $post->ID, self::META_END, true ) ); $end_value = ''; if ( $end ) { $end_value = $all_day ? substr( $end[0], 0, 10 ) . ' 23:59:59' : $end[0]; if ( $end_value <= $start_value ) { $end_value = ''; } } return array( 'start' => $start_value, 'end' => $end_value, 'all_day' => $all_day, 'precision' => 'day', ); } /* --------------------------------------------------------------------- * Sync * ------------------------------------------------------------------ */ /** * Create, update or remove the calendar event of a post. * * @param int $post_id Post ID. */ public static function sync( $post_id ) { $post_id = (int) $post_id; unset( self::$pending[ $post_id ] ); $post = get_post( $post_id ); if ( ! $post || 'revision' === $post->post_type || 'auto-draft' === $post->post_status ) { return; } self::$syncing = true; $event_id = (int) get_post_meta( $post_id, self::META_EVENT, true ); $event = $event_id ? ThreeCal_Event::get( $event_id ) : null; if ( $event && (int) $event->get_setting( 'post_id' ) !== $post_id ) { $event = null; } $settings = self::type_settings( $post->post_type ); $values = $settings ? self::values( $post, $settings ) : null; if ( ! $values ) { if ( $event ) { $event->delete(); } delete_post_meta( $post_id, self::META_EVENT ); delete_post_meta( $post_id, self::META_SORT ); self::$syncing = false; return; } $is_new = ! $event; if ( $is_new ) { $event = new ThreeCal_Event(); } $live_before = ! $is_new && 'published' === $event->status; $old_day = substr( (string) $event->start_date, 0, 10 ); $new_day = substr( $values['start'], 0, 10 ); $was_month = ! $is_new && $event->is_month_only(); $now_month = isset( $values['precision'] ) && 'month' === $values['precision']; $event->title = html_entity_decode( wp_strip_all_tags( $post->post_title ), ENT_QUOTES, 'UTF-8' ); $event->description = self::excerpt( $post ); $event->start_date = $values['start']; $event->end_date = $values['end']; $event->all_day = $values['all_day']; $event->url = get_permalink( $post ); $event->featured_image = (int) get_post_thumbnail_id( $post ); $event->color = ''; $event->status = 'publish' === $post->post_status ? 'published' : 'draft'; $event->recurrence_rule = ''; $event->recurrence_end = ''; $event->parent_id = 0; // A published date that moves is shown as postponed, until it moves back. // A month that gets its day ("July 2027" becomes "July 15, 2027") did not // move, and neither does a date that turns into a month: no "postponed" then. if ( $live_before && 'published' === $event->status && $old_day && $old_day !== $new_day && ! $was_month && ! $now_month ) { $event->set_setting( 'previous_start', $old_day ); } if ( $was_month || $new_day === (string) $event->get_setting( 'previous_start', '' ) ) { $event->set_setting( 'previous_start', '' ); } $event->set_date_precision( $now_month ? 'month' : 'day' ); $event->set_setting( 'post_id', $post_id ); $event->set_setting( 'post_type', $post->post_type ); if ( $event->save() ) { $category = (int) $settings['category']; $event->set_categories( ( $category && ThreeCal_Category::get( $category ) ) ? array( $category ) : array() ); update_post_meta( $post_id, self::META_EVENT, (int) $event->id ); update_post_meta( $post_id, self::META_SORT, $event->start_date ); } self::$syncing = false; } /** * Short description from the excerpt or the start of the post. * * @param WP_Post $post Post. * @return string */ private static function excerpt( $post ) { $text = '' !== trim( $post->post_excerpt ) ? $post->post_excerpt : strip_shortcodes( $post->post_content ); $text = wp_strip_all_tags( excerpt_remove_blocks( $text ) ); return wp_trim_words( html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ), 40, '…' ); } /** * Remember a post for a sync at the end of the request. * * @param int $post_id Post ID. */ public function queue( $post_id ) { self::$pending[ (int) $post_id ] = true; } /** * Sync all remembered posts. */ public function flush() { foreach ( array_keys( self::$pending ) as $post_id ) { self::sync( $post_id ); } } /** * After a post was saved (block editor, classic editor, quick edit, REST). * * @param int $post_id Post ID. * @param WP_Post $post Post. */ public function after_insert_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( self::type_settings( $post->post_type ) || get_post_meta( $post_id, self::META_EVENT, true ) ) { self::sync( $post_id ); } } /** * Scheduled posts that go live do not always pass through the editor. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public function status_changed( $new_status, $old_status, $post ) { if ( $new_status !== $old_status && self::type_settings( $post->post_type ) ) { $this->queue( $post->ID ); } } /** * A watched field changed without the post being saved, for example by an import. * * @param int|array $meta_id Meta ID(s). * @param int $object_id Post ID. * @param string $meta_key Meta key. */ public function meta_changed( $meta_id, $object_id, $meta_key ) { if ( self::$syncing ) { return; } if ( null === self::$watch ) { self::$watch = array(); foreach ( self::enabled_types() as $settings ) { foreach ( array( self::META_SHOW, self::META_START, self::META_END, self::META_ALL_DAY, self::META_PRECISION, '_thumbnail_id', $settings['source'], isset( $settings['skip'] ) ? $settings['skip'] : '' ) as $key ) { if ( '' !== $key ) { self::$watch[ $key ] = true; } } } } // Cheap check first: most meta changes on a site have nothing to do with us. if ( ! isset( self::$watch[ $meta_key ] ) ) { return; } $settings = self::type_settings( get_post_type( $object_id ) ); if ( $settings && ( '_thumbnail_id' !== $meta_key || get_post_meta( $object_id, self::META_EVENT, true ) ) ) { $this->queue( $object_id ); } } /** * A deleted post takes its calendar event with it. * * @param int $post_id Post ID. */ public function before_delete_post( $post_id ) { $event_id = (int) get_post_meta( $post_id, self::META_EVENT, true ); if ( ! $event_id ) { return; } $event = ThreeCal_Event::get( $event_id ); if ( $event && (int) $event->get_setting( 'post_id' ) === (int) $post_id ) { $event->delete(); } unset( self::$pending[ (int) $post_id ] ); } /** * Post the date belongs to, for an event that comes from a post. * * @param ThreeCal_Event $event Event. * @return int Post ID or 0. */ public static function post_of( $event ) { return $event ? (int) $event->get_setting( 'post_id', 0 ) : 0; } /** * "Postponed, previously …" for a date that moved, empty otherwise. * * @param ThreeCal_Event $event Event. * @return string Plain text. */ public static function postponed_text( $event ) { if ( ! $event || ! self::settings()['postponed'] || $event->is_month_only() ) { return ''; } $previous = (string) $event->get_setting( 'previous_start', '' ); if ( '' === $previous || substr( (string) $event->end_date ?: (string) $event->start_date, 0, 10 ) < current_time( 'Y-m-d' ) ) { return ''; } /* translators: %s: the date before it was moved */ return sprintf( __( 'Postponed, previously %s', '3task-calendar' ), date_i18n( threecal_date_format(), strtotime( $previous ) ) ); } /* --------------------------------------------------------------------- * Editor * ------------------------------------------------------------------ */ /** * Register the post meta for the block editor and the date block. */ public function register() { foreach ( array_keys( self::enabled_types() ) as $post_type ) { // The block editor saves post meta only for types with custom fields support. add_post_type_support( $post_type, 'custom-fields' ); foreach ( array( self::META_SHOW, self::META_START, self::META_END, self::META_ALL_DAY, self::META_PRECISION ) as $key ) { register_post_meta( $post_type, $key, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => true, 'sanitize_callback' => array( __CLASS__, 'sanitize_meta' ), 'auth_callback' => array( __CLASS__, 'can_edit' ), ) ); } } $this->register_block(); } /** * Only users who may edit the post may change its date. * * @param bool $allowed Default. * @param string $meta_key Meta key. * @param int $post_id Post ID. * @return bool */ public static function can_edit( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); } /** * Clean a date meta value. * * @param mixed $value Value. * @param string $meta_key Meta key. * @return string */ public static function sanitize_meta( $value, $meta_key = '' ) { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( self::META_SHOW === $meta_key || self::META_ALL_DAY === $meta_key ) { return in_array( $value, array( '0', '1' ), true ) ? $value : ''; } if ( self::META_PRECISION === $meta_key ) { return 'month' === $value ? 'month' : ''; } $parsed = self::parse_date( $value ); if ( ! $parsed ) { return ''; } return $parsed[1] ? substr( $parsed[0], 0, 16 ) : substr( $parsed[0], 0, 10 ); } /** * Script for the panel in the block editor. */ public function enqueue_panel() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || empty( $screen->post_type ) ) { return; } $settings = self::type_settings( $screen->post_type ); if ( ! $settings ) { return; } wp_enqueue_script( 'threecal-post-dates-panel', THREECAL_PLUGIN_URL . 'admin/js/post-dates-panel.js', array( 'wp-plugins', 'wp-edit-post', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n' ), threecal_asset_version( 'admin/js/post-dates-panel.js' ), true ); wp_set_script_translations( 'threecal-post-dates-panel', '3task-calendar', THREECAL_PLUGIN_DIR . 'languages' ); $post = get_post(); $mapped = ''; $category = $settings['category'] ? ThreeCal_Category::get( (int) $settings['category'] ) : null; if ( $post && '' !== $settings['source'] ) { $parsed = self::parse_date( get_post_meta( $post->ID, $settings['source'], true ) ); if ( $parsed ) { $mapped = date_i18n( threecal_date_format(), strtotime( $parsed[0] ) ); if ( $parsed[1] ) { $mapped .= ' · ' . date_i18n( threecal_time_format(), strtotime( $parsed[0] ) ); } } } $event = ( $post && get_post_meta( $post->ID, self::META_EVENT, true ) ) ? ThreeCal_Event::get( (int) get_post_meta( $post->ID, self::META_EVENT, true ) ) : null; wp_localize_script( 'threecal-post-dates-panel', 'threecalPostDates', array( 'postType' => $screen->post_type, 'label' => self::label( $settings ), 'source' => $settings['source'], 'mapped' => $mapped, 'category' => $category ? $category->name : '', 'postponed' => $event ? self::postponed_text( $event ) : '', ) ); } /** * Box for the classic editor (hidden in the block editor, which has the panel). * * @param string $post_type Post type. * @param WP_Post $post Post. */ public function add_meta_box( $post_type, $post ) { $settings = self::type_settings( $post_type ); if ( ! $settings ) { return; } add_meta_box( 'threecal-post-date', self::label( $settings ), array( $this, 'render_meta_box' ), $post_type, 'side', 'default', array( '__back_compat_meta_box' => true ) ); } /** * Fields of the classic editor box. * * @param WP_Post $post Post. */ public function render_meta_box( $post ) { $settings = self::type_settings( $post->post_type ); $show = (string) get_post_meta( $post->ID, self::META_SHOW, true ); wp_nonce_field( 'threecal_post_date', 'threecal_post_date_nonce' ); if ( '' !== $settings['source'] ) { $parsed = self::parse_date( get_post_meta( $post->ID, $settings['source'], true ) ); echo '
';
if ( $parsed ) {
echo esc_html( date_i18n( threecal_date_format(), strtotime( $parsed[0] ) ) );
} else {
esc_html_e( 'No date yet.', '3task-calendar' );
}
echo '
';
/* translators: %s: name of the custom field */
echo esc_html( sprintf( __( 'Taken from the field %s.', '3task-calendar' ), $settings['source'] ) );
echo '
' . esc_html__( 'You do not have permission to change the calendar settings.', '3task-calendar' ) . '
' . esc_html__( 'Settings saved!', '3task-calendar' ); if ( $resync ) { echo ' ' . esc_html__( 'The posts are being checked now, please keep this page open for a moment.', '3task-calendar' ); } echo '