load_dependencies(); $this->set_locale(); $this->init_hooks(); } /** * Load dependencies */ private function load_dependencies() { // Core classes require_once THREECAL_PLUGIN_DIR . 'includes/class-activator.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-deactivator.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-event.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-location.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-category.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-shortcode.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-calendar-renderer.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-ics.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-icons.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-themes.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-post-dates.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-importer.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-prefill.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-feed.php'; require_once THREECAL_PLUGIN_DIR . 'includes/class-submit.php'; // WP-CLI: wp threecal import if (defined('WP_CLI') && WP_CLI) { require_once THREECAL_PLUGIN_DIR . 'includes/class-cli.php'; WP_CLI::add_command('threecal', 'ThreeCal_CLI'); } // Admin classes if (is_admin()) { require_once THREECAL_PLUGIN_DIR . 'admin/class-admin.php'; } // Public classes require_once THREECAL_PLUGIN_DIR . 'public/class-public.php'; } /** * Set locale * * Note: WordPress 4.6+ automatically loads translations from the languages directory. * Manual load_plugin_textdomain() is discouraged by WordPress.org Plugin Guidelines. */ private function set_locale() { // Language packs from translate.wordpress.org load automatically. // The bundled German files are only a fallback when no language pack exists. add_action('init', array($this, 'load_bundled_translations'), 0); } /** * Load the bundled German translation when no language pack is installed. */ public function load_bundled_translations() { $locale = determine_locale(); if (0 !== strpos($locale, 'de_')) { return; } $pack = WP_LANG_DIR . '/plugins/3task-calendar-' . $locale . '.mo'; if (file_exists($pack)) { return; } $bundled = THREECAL_PLUGIN_DIR . 'languages/3task-calendar-' . $locale . '.mo'; if (!file_exists($bundled)) { // de_AT, de_CH and others fall back to de_DE (formal variants to de_DE_formal). $fallback = (false !== strpos($locale, '_formal')) ? 'de_DE_formal' : 'de_DE'; $bundled = THREECAL_PLUGIN_DIR . 'languages/3task-calendar-' . $fallback . '.mo'; } if (file_exists($bundled)) { load_textdomain('3task-calendar', $bundled); } } /** * Initialize hooks */ private function init_hooks() { // Admin if (is_admin()) { $this->admin = new ThreeCal_Admin('3task-calendar', THREECAL_VERSION); add_action('admin_enqueue_scripts', array($this->admin, 'enqueue_styles')); add_action('admin_enqueue_scripts', array($this->admin, 'enqueue_scripts')); add_action('admin_menu', array($this->admin, 'add_menu_pages')); add_action('admin_init', array($this->admin, 'register_settings')); add_action('admin_init', array($this, 'maybe_add_capabilities')); } // Database upgrades after plugin updates (activation hook does not run on update). add_action('init', array($this, 'maybe_upgrade_database'), 5); // Public $this->public = new ThreeCal_Public('3task-calendar', THREECAL_VERSION); add_action('wp_enqueue_scripts', array($this->public, 'enqueue_styles')); add_action('wp_enqueue_scripts', array($this->public, 'enqueue_scripts')); // Shortcodes new ThreeCal_Shortcode(); // iCalendar download and subscription feed new ThreeCal_ICS(); new ThreeCal_Post_Dates(); // Register block add_action('init', array($this, 'register_block')); add_action('enqueue_block_editor_assets', array($this, 'block_editor_data')); // Pages saved with the old block name (before 1.3.0) render with the new one. add_filter('the_content', array(__CLASS__, 'migrate_block_name'), 8); // REST API add_action('rest_api_init', array($this, 'register_rest_routes')); // Schema.org SEO add_action('wp_head', array($this, 'output_schema')); // AJAX handlers add_action('wp_ajax_threecal_get_events', array($this, 'ajax_get_events')); add_action('wp_ajax_nopriv_threecal_get_events', array($this, 'ajax_get_events')); add_action('wp_ajax_threecal_get_event_details', array($this, 'ajax_get_event_details')); ThreeCal_Prefill::init(); ThreeCal_Feed::init(); ThreeCal_Submit::init(); add_action('wp_ajax_nopriv_threecal_get_event_details', array($this, 'ajax_get_event_details')); } /** * Register Gutenberg block */ public function register_block() { if (!function_exists('register_block_type')) { return; } wp_register_script( 'threecal-block-editor', THREECAL_PLUGIN_URL . 'blocks/calendar-block/index.js', array('wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-server-side-render'), threecal_asset_version('blocks/calendar-block/index.js'), true ); if (function_exists('wp_set_script_translations')) { wp_set_script_translations('threecal-block-editor', '3task-calendar', THREECAL_PLUGIN_DIR . 'languages'); } // The calendar stylesheet also styles the live preview in the editor. if (!wp_style_is('threecal-public', 'registered')) { wp_register_style('threecal-public', THREECAL_PLUGIN_URL . 'public/css/threecal.css', array(), threecal_asset_version('public/css/threecal.css')); } wp_register_style( 'threecal-block-editor', THREECAL_PLUGIN_URL . 'blocks/calendar-block/editor.css', array(), threecal_asset_version('blocks/calendar-block/editor.css') ); register_block_type('threecal/calendar', array( 'api_version' => 3, 'editor_script' => 'threecal-block-editor', 'editor_style' => array('threecal-block-editor', 'threecal-public'), 'render_callback' => array($this, 'render_block'), 'attributes' => array( 'view' => array( 'type' => 'string', 'default' => 'month' ), 'category' => array( 'type' => 'number', 'default' => 0 ), 'theme' => array( 'type' => 'string', 'default' => '' ), 'showSearch' => array( 'type' => 'boolean', 'default' => true ), 'align' => array( 'type' => 'string', 'default' => '' ), 'className' => array( 'type' => 'string', 'default' => '' ) ), 'supports' => array( 'align' => array('wide', 'full'), 'html' => false ) )); } /** * Rename the old block "3task-calendar/calendar" to "threecal/calendar". * * Block names must start with a letter, so WordPress never parsed the old * name and the block rendered nothing. Runs before do_blocks(). * * @param string $content Post content. * @return string */ public static function migrate_block_name($content) { if (!is_string($content) || false === strpos($content, 'wp:3task-calendar/calendar')) { return $content; } return str_replace( array(''), array(''), $content ); } /** * Designs and categories for the block settings (editor only). */ public function block_editor_data() { // Designs and categories for the block settings. $block_themes = array(); foreach (ThreeCal_Themes::all() as $key => $theme) { $block_themes[] = array('value' => $key, 'label' => $theme['label']); } $block_categories = array(); foreach (ThreeCal_Category::get_all() as $cat) { $block_categories[] = array('value' => (int) $cat->id, 'label' => $cat->name); } $block_data = 'window.threecalBlockData = ' . wp_json_encode(array('themes' => $block_themes, 'categories' => $block_categories)) . ';'; wp_add_inline_script('threecal-block-editor', $block_data, 'before'); wp_add_inline_script('threecal-post-date-block', $block_data, 'before'); } /** * Render block callback */ public function render_block($attributes) { $view = isset($attributes['view']) ? $attributes['view'] : 'month'; $category = isset($attributes['category']) ? absint($attributes['category']) : 0; $theme = isset($attributes['theme']) ? ThreeCal_Themes::normalize($attributes['theme']) : ''; // Poster view: upcoming dates as tiles with their images. $shortcode = 'poster' === $view ? '[threecal_events view="poster" columns="4" limit="12"' : '[threecal view="' . esc_attr($view) . '"'; if ($category > 0) { $shortcode .= ' category="' . $category . '"'; } if (isset($attributes['showSearch']) && !$attributes['showSearch']) { $shortcode .= ' show_search="false"'; } $shortcode .= $theme ? ' theme="' . esc_attr($theme) . '"]' : ']'; $classes = array('wp-block-threecal-calendar'); if (!empty($attributes['align']) && in_array($attributes['align'], array('wide', 'full'), true)) { $classes[] = 'align' . $attributes['align']; } if (!empty($attributes['className'])) { $classes[] = $attributes['className']; } return '
' . do_shortcode($shortcode) . '
'; } /** * Output Schema.org markup for events * * Runs on singular pages that contain the calendar shortcode or block and * respects the category of the first calendar on the page. */ public function output_schema() { if (!is_singular()) { return; } $settings = get_option('threecal_settings', array()); if (isset($settings['enable_schema']) && !$settings['enable_schema']) { return; } $post = get_post(); if (!$post) { return; } $category = $this->find_calendar_category($post->post_content); if (null === $category) { return; } // Five places for the next events that really get markup. Events without // a place, with only the month, in a category excluded from the markup or // emptied by the filter are skipped and leave their place to the next one. $page_url = get_permalink($post); $printed = 0; foreach (ThreeCal_Event::get_upcoming(50, $category) as $event) { $schema = $this->build_event_schema($event, $page_url, $post); if (!$schema) { continue; } echo '' . "\n"; if (++$printed >= 5) { break; } } } /** * Schema check (since 1.7.0): which of the next events get event markup, which of the * five places they take and why an event gets none. * * @param int $limit How many upcoming events to check. * @param int $category Category ID, 0 for all. * @return array Rows: id, title, start, ok, place (1 to 5 or 0), reasons (keys, see schema_reason_labels()). */ public function schema_report($limit = 20, $category = 0) { $settings = get_option('threecal_settings', array()); $disabled = isset($settings['enable_schema']) && !$settings['enable_schema']; $no_schema = ThreeCal_Category::no_schema_ids(); $rows = array(); $printed = 0; foreach (ThreeCal_Event::get_upcoming(max(1, (int) $limit), (int) $category) as $event) { $reasons = array(); if ($disabled) { $reasons[] = 'disabled'; } else { if ($event->is_month_only()) { $reasons[] = 'month_only'; } if (!$event->location_id || !ThreeCal_Location::get($event->location_id)) { $reasons[] = 'no_location'; } if ($no_schema) { foreach (ThreeCal_Event::get_categories($event->id) as $cat) { if (in_array((int) $cat->id, $no_schema, true)) { $reasons[] = 'category_excluded'; break; } } } if (!$reasons && !$this->build_event_schema($event, home_url('/'))) { $reasons[] = 'filtered'; } } $place = 0; if (!$reasons) { if ($printed < 5) { $place = ++$printed; } else { $reasons[] = 'no_place'; } } $rows[] = array( 'id' => (int) $event->id, 'title' => $event->title, 'start' => $event->is_month_only() ? $event->month_label() : date_i18n(threecal_date_format(), strtotime($event->start_date)), 'ok' => 0 !== $place, 'place' => $place, 'reasons' => $reasons, ); } return $rows; } /** * Labels of the reasons in schema_report(). Since 1.7.0 * * @return array key => label */ public static function schema_reason_labels() { return array( 'disabled' => __('Event schema is switched off in the settings', '3task-calendar'), 'month_only' => __('Only the month is known; Google needs a day', '3task-calendar'), 'no_location' => __('No location; Google needs a real place', '3task-calendar'), 'category_excluded' => __('Category is marked "Not an event for search engines"', '3task-calendar'), 'filtered' => __('Removed by the filter threecal_event_schema', '3task-calendar'), 'no_place' => __('Fine, but the five places are taken by earlier events', '3task-calendar'), ); } /** * Published pages and posts that show a calendar (shortcode or block). Since 1.7.0 * * @param int $limit Maximum number. * @return WP_Post[] */ public function calendar_pages($limit = 5) { global $wpdb; $like_a = '%' . $wpdb->esc_like('[threecal') . '%'; $like_b = '%' . $wpdb->esc_like('wp:threecal/calendar') . '%'; $like_c = '%' . $wpdb->esc_like('wp:3task-calendar/calendar') . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin-only check, small result. $ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('page', 'post') AND (post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s) ORDER BY post_type DESC, post_title ASC LIMIT %d", $like_a, $like_b, $like_c, max(1, (int) $limit) )); return array_filter(array_map('get_post', array_map('intval', $ids))); } /** * Does this post or page show a calendar (shortcode or block)? Since 1.7.0 * * @param WP_Post $post Post. * @return bool */ public function page_has_calendar($post) { return $post && null !== $this->find_calendar_category((string) $post->post_content); } /** * Text block for the site's llms.txt: tells AI search engines where the events are. Since 1.7.0 * * @return string */ public function llms_snippet() { $lines = array( '## ' . __('Events', '3task-calendar'), '- [' . __('Upcoming events as JSON', '3task-calendar') . '](' . ThreeCal_Feed::url() . '): ' . __('all upcoming events with date and time zone, place, organizer, categories and link. Filters: ?category=slug, ?from=YYYY-MM-DD, ?to=YYYY-MM-DD, ?day=YYYY-MM-DD, ?search=word, ?limit=200.', '3task-calendar'), ); foreach ($this->calendar_pages(3) as $page) { $lines[] = '- [' . get_the_title($page) . '](' . get_permalink($page) . '): ' . __('calendar page with the upcoming events', '3task-calendar'); } return implode("\n", $lines) . "\n"; } /** * Find the category of the first calendar on a page. * * @param string $content Post content. * @return int|null Category ID (0 = all) or null when the page has no calendar. */ private function find_calendar_category($content) { if (function_exists('has_block') && (has_block('threecal/calendar', $content) || false !== strpos($content, '