define_admin_hooks(); $this->define_public_hooks(); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'webcal', \WebCalendar\WP\CLI\WebCalCommand::class ); // Registered separately rather than as more methods on WebCalCommand, // so the record-level operations nest under `wp webcal event` instead // of flattening into `wp webcal event-create` and friends. \WP_CLI::add_command( 'webcal event', \WebCalendar\WP\CLI\WebCalEventCommand::class ); // An invokable class, so this stays a single command rather than // becoming a group with one subcommand under it. \WP_CLI::add_command( 'webcal doctor', \WebCalendar\WP\CLI\WebCalDoctorCommand::class ); } } /** * Register all of the hooks related to the admin area functionality * of the plugin. */ private function define_admin_hooks() : void { $settings = new \WebCalendar\WP\Admin\Settings(); $settings->init(); $holidays = new \WebCalendar\WP\Admin\Holidays(); $holidays->init(); if ( class_exists( \WebCalendar\WP\Admin\RemoteCalendars::class ) ) { $remote_cals = new \WebCalendar\WP\Admin\RemoteCalendars(); $remote_cals->init(); } $import = new \WebCalendar\WP\Admin\Import(); $import->init(); $shared_cals = new \WebCalendar\WP\Admin\SharedCalendars(); $shared_cals->init(); $layers = new \WebCalendar\WP\Admin\Layers(); $layers->init(); $site_health = new \WebCalendar\WP\Admin\SiteHealth(); $site_health->init(); $profile_prefs = new \WebCalendar\WP\Admin\ProfilePreferences(); $profile_prefs->init(); if ( \webcal_feature_available( 'mcp' ) ) { $ai_page = new \WebCalendar\WP\Admin\AiPage(); $ai_page->init(); } add_action( 'rest_api_init', [$this, 'register_rest_routes'] ); // WP REST API JSON-encodes all response bodies by default. For raw // file-download routes (CSV/ICS exports) we must intercept and echo // the body directly so the downloaded file is not wrapped in JSON. add_filter( 'rest_pre_serve_request', [$this, 'serve_raw_file_response'], 10, 3 ); } /** * Bypass JSON encoding for file-download REST routes. * * @param bool $served Whether the request has already been served. * @param \WP_HTTP_Response $result The response object. * @param \WP_REST_Request $request The request object. * @return bool True if served (prevents default JSON output), false otherwise. */ public function serve_raw_file_response( bool $served, \WP_HTTP_Response $result, \WP_REST_Request $request ) : bool { if ( !self::is_raw_file_route( $request->get_route() ) ) { return $served; } foreach ( $result->get_headers() as $name => $value ) { header( sprintf( '%s: %s', $name, $value ) ); } $data = $result->get_data(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw file download (CSV/ICS), escaping would corrupt binary content echo ( is_string( $data ) ? $data : '' ); return true; } /** * Whether a REST route answers with a file rather than JSON. */ public static function is_raw_file_route( string $route ) : bool { $raw_routes = [ '/webcal/v1/events/export/csv', '/webcal/v1/events/export', '/webcal/v1/events/[0-9]+/export', '/webcal/v1/events/[0-9]+/attendees/export/csv', '/webcal/v1/feed' ]; foreach ( $raw_routes as $pattern ) { if ( preg_match( '#^' . $pattern . '$#', $route ) ) { return true; } } return false; } /** * Register REST API routes. */ public function register_rest_routes() : void { $controllers = [ new \WebCalendar\WP\REST\EventsController(), new \WebCalendar\WP\REST\UsersController(), new \WebCalendar\WP\REST\CategoriesController(), new \WebCalendar\WP\REST\HolidaysController(), new \WebCalendar\WP\REST\FeedController(), new \WebCalendar\WP\REST\SharedCalendarsController(), new \WebCalendar\WP\REST\LayersController(), new \WebCalendar\WP\REST\VenuesController(), new \WebCalendar\WP\REST\OrganizersController(), new \WebCalendar\WP\REST\ExtraFieldsController() ]; // Pro-only controllers — registered only when their class files ship. if ( class_exists( \WebCalendar\WP\REST\RemoteCalendarsController::class ) ) { $controllers[] = new \WebCalendar\WP\REST\RemoteCalendarsController(); } if ( class_exists( \WebCalendar\WP\REST\SubmissionsController::class ) ) { $controllers[] = new \WebCalendar\WP\REST\SubmissionsController(); } if ( class_exists( \WebCalendar\WP\REST\RsvpController::class ) ) { $controllers[] = new \WebCalendar\WP\REST\RsvpController(); } foreach ( $controllers as $controller ) { $controller->register_routes(); } // MCP Streamable HTTP endpoint (bundled MCP server). The gate stays for // symmetry with the rest of the wiring, but FeatureMap puts 'mcp' at the // free tier, so it passes everywhere — WordPress.org edition included. if ( \webcal_feature_available( 'mcp' ) ) { $raw_opt = get_option( 'webcal_enable_mcp', 'yes' ); $enabled = is_scalar( $raw_opt ) && (string) $raw_opt === 'yes'; if ( apply_filters( 'webcalendar_mcp_enabled', $enabled ) ) { $abilities = new \WebCalendar\WP\Abilities(); $mcp_server = new \WebCalendar\WP\MCP\McpServer($abilities); $mcp_endpoint = new \WebCalendar\WP\MCP\McpEndpoint($mcp_server); $mcp_endpoint->register(); } } } /** * Register all of the hooks related to the public-facing functionality * of the plugin. */ private function define_public_hooks() : void { $container = Container::get_instance(); $shortcode = new \WebCalendar\WP\Shortcode(); $shortcode->register(); // Hands a deleted user's calendar rows on, so a reused login does // not inherit them. Runs wherever users are deleted, admin or not. ( new \WebCalendar\WP\UserLifecycle() )->register(); // Removes a legacy dump left staged by an abandoned or failed // migration. Cron runs outside wp-admin, so this cannot wait for it. /** @psalm-suppress HookNotFound -- Plugin-owned cron hook, scheduled by LegacyMigrationTab::handle_upload(). */ add_action( 'webcal_legacy_migration_cleanup', [\WebCalendar\WP\Admin\LegacyMigrationTab::class, 'purge_stale_staged_files'], 10, 0 ); if ( class_exists( \WebCalendar\WP\SubmitEventShortcode::class ) ) { $submit_shortcode = new \WebCalendar\WP\SubmitEventShortcode(); $submit_shortcode->register(); } // RSVP. Every class here is stripped from the free build, so each // entry point is class_exists-guarded rather than feature-gated alone: // on wordpress.org these files do not exist at all. if ( class_exists( \WebCalendar\WP\RsvpShortcode::class ) ) { $rsvp_shortcode = new \WebCalendar\WP\RsvpShortcode(); $rsvp_shortcode->register(); } if ( class_exists( \WebCalendar\WP\Rsvp\RsvpPage::class ) ) { $rsvp_page = new \WebCalendar\WP\Rsvp\RsvpPage(); $rsvp_page->init(); } // RSVP is the only place the plugin collects personal data from people // who are not WordPress users, so it is the only place that needs to // answer an export or erasure request on its own. if ( class_exists( \WebCalendar\WP\Rsvp\RsvpPrivacy::class ) ) { $rsvp_privacy = new \WebCalendar\WP\Rsvp\RsvpPrivacy(); $rsvp_privacy->init(); } // Unconfirmed RSVPs expire rather than accumulate. They hold no // capacity, so this is housekeeping on personal data rather than on // seats: an abandoned request leaves a name and an email address, and // nothing should keep those once the link has expired. if ( class_exists( \WebCalendar\WP\Rsvp\PendingRsvpRepository::class ) ) { /** @psalm-suppress HookNotFound -- Plugin-owned cron hook, scheduled just below. */ add_action( 'webcal_purge_pending_rsvps', [$this, 'purge_pending_rsvps'], 10, 0 ); if ( false === wp_next_scheduled( 'webcal_purge_pending_rsvps' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'webcal_purge_pending_rsvps' ); } } elseif ( wp_next_scheduled( 'webcal_purge_pending_rsvps' ) !== false ) { wp_clear_scheduled_hook( 'webcal_purge_pending_rsvps' ); } $single_event = new \WebCalendar\WP\SingleEventPage(); $single_event->init(); $event_search = new \WebCalendar\WP\EventSearchIntegration(); $event_search->init(); /** @var \WebCalendar\WP\ReminderProcessor */ $reminder_processor = $container->get( 'reminder_processor' ); $reminder_processor->register(); /** @var \WebCalendar\WP\EmailQueue */ $email_queue = $container->get( 'email_queue' ); $email_queue->register(); // Remote calendar sync cron — the service class is stripped from the // free build, so only schedule when it ships. Clear any event left // behind by a previous premium install to avoid a class-not-found // fatal in the cron request. if ( class_exists( \WebCalendar\WP\RemoteCalendarService::class ) ) { add_action( 'webcal_sync_remote_calendars', [$this, 'sync_remote_calendars'] ); if ( !wp_next_scheduled( 'webcal_sync_remote_calendars' ) ) { /** @var string $interval */ $interval = apply_filters( 'webcal_sync_interval', 'hourly' ); wp_schedule_event( time(), $interval, 'webcal_sync_remote_calendars' ); } } elseif ( wp_next_scheduled( 'webcal_sync_remote_calendars' ) !== false ) { wp_clear_scheduled_hook( 'webcal_sync_remote_calendars' ); } // TEC side-by-side sync cron (Epic 8, free feature). register() // only reads one non-autoloaded option; the reader/importer are // constructed lazily inside a firing cycle. $tec_sync = new \WebCalendar\WP\Migration\Tec\TecSyncScheduler(); /** @psalm-suppress HookNotFound -- Plugin-owned cron hook, scheduled by TecSyncScheduler::reschedule(). */ add_action( \WebCalendar\WP\Migration\Tec\TecSyncScheduler::HOOK, [$tec_sync, 'run_cycle'], 10, 0 ); $tec_sync->register(); add_action( 'wp_enqueue_scripts', [$this, 'register_assets'] ); add_action( 'init', [$this, 'register_blocks'], 10, 0 ); add_action( 'init', [$this, 'register_sitemap_provider'], 10, 0 ); add_action( 'init', [$this, 'maybe_run_deferred_seeding'], 10, 0 ); add_action( 'widgets_init', [$this, 'register_widgets'], 10, 0 ); // Abilities API (WP 6.9+) — optional: registers tools as WordPress // abilities. Free tier and up, on the same gate as the MCP endpoint above. if ( \webcal_feature_available( 'mcp' ) && function_exists( 'wp_register_ability' ) ) { $raw_opt = get_option( 'webcal_enable_mcp', 'yes' ); $enabled = is_scalar( $raw_opt ) && (string) $raw_opt === 'yes'; if ( apply_filters( 'webcalendar_mcp_enabled', $enabled ) ) { $abilities = new \WebCalendar\WP\Abilities(); $abilities->init(); } } // Configurable rate limit from Settings. /** @psalm-suppress HookNotFound -- Applied in Controller::check_rate_limit(). */ add_filter( 'webcal_rate_limit', [self::class, 'configured_rate_limit'], 10, 2 ); /** @psalm-suppress HookNotFound -- Applied in Controller::check_rate_limit(). */ add_filter( 'webcal_rate_limit_period', [self::class, 'configured_rate_limit_period'], 10, 2 ); // Query Monitor integration — expose PDO queries in QM panel \WebCalendar\WP\Integrations\QueryMonitor\QueryMonitorCollector::register(); // Elementor integration — register widgets when Elementor is active add_action( 'plugins_loaded', function () : void { if ( did_action( 'elementor/loaded' ) ) { ( new \WebCalendar\WP\Integrations\Elementor\ElementorIntegration() )->init(); } }, 20, 0 ); // Divi Builder integration — register modules when Divi is active add_action( 'plugins_loaded', function () : void { if ( defined( 'ET_CORE_VERSION' ) ) { ( new \WebCalendar\WP\Integrations\Divi\DiviIntegration() )->init(); } }, 20, 0 ); } /** * Register Gutenberg blocks. */ public function register_blocks() : void { register_block_type( WEBCALENDAR_WP_PATH . 'blocks/calendar' ); register_block_type( WEBCALENDAR_WP_PATH . 'blocks/upcoming-events' ); } /** * Register the event sitemap provider for single event pages. */ public function register_sitemap_provider() : void { if ( !function_exists( 'wp_register_sitemap_provider' ) ) { return; } wp_register_sitemap_provider( 'webcalendar', new EventSitemapProvider() ); } /** * Register sidebar widgets. */ public function register_widgets() : void { register_widget( MiniCalendarWidget::class ); } /** * Run deferred data seeding if activation could not complete it. * * When DB_HOST cannot be reached during register_activation_hook (e.g. * socket-only MySQL with an unparseable DB_HOST), Activator sets a * webcal_pending_seed transient. We retry on the next admin request, * by which time the connection may be reachable. */ public function maybe_run_deferred_seeding() : void { if ( !get_transient( 'webcal_pending_seed' ) ) { return; } if ( !is_admin() ) { return; } try { Activator::seed_default_categories(); Activator::seed_holiday_calendar(); Activator::auto_subscribe_holidays(); delete_transient( 'webcal_pending_seed' ); } catch ( \Throwable $e ) { // Connection still failing; leave the transient in place to retry later. if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic only fires when WP_DEBUG_LOG is enabled. error_log( 'WebCalendar deferred seed retry failed: ' . $e->getMessage() ); } } } /** * Register frontend assets. */ public function register_assets() : void { $asset_file = WEBCALENDAR_WP_PATH . 'build/frontend.asset.php'; if ( !file_exists( $asset_file ) ) { // Fallback to index.asset.php if frontend isn't built separately yet $asset_file = WEBCALENDAR_WP_PATH . 'build/index.asset.php'; } if ( !file_exists( $asset_file ) ) { return; } $assets = (require $asset_file); wp_register_script( 'webcal-frontend', WEBCALENDAR_WP_URL . 'build/frontend.js', $assets['dependencies'], $assets['version'], true ); // Load current user's calendar preferences $user_default_access = 'P'; if ( is_user_logged_in() ) { $user_repo = Container::get_instance()->get_user_repository(); $prefs = $user_repo->getPreferences( wp_get_current_user()->user_login ); foreach ( $prefs as $pref ) { if ( $pref->key() === 'default_access' ) { $user_default_access = $pref->value(); break; } } } // Build layer data for frontend color matching $layers_data = []; if ( is_user_logged_in() ) { $layer_service = Container::get_instance()->get_layer_service(); $layers = $layer_service->getLayersForUser( wp_get_current_user()->user_login ); foreach ( $layers as $layer ) { $layer_name = $layer->layerUser(); $wp_layer_user = get_user_by( 'login', $layer->layerUser() ); if ( $wp_layer_user ) { $layer_name = $wp_layer_user->display_name; } else { $resource_repo = Container::get_instance()->get_resource_repository(); $resource = $resource_repo->findByLogin( $layer->layerUser() ); if ( $resource !== null ) { $layer_name = $resource->name(); } } $layers_data[] = [ 'layerUser' => $layer->layerUser(), 'color' => $layer->color(), 'showDuplicates' => $layer->showDuplicates(), 'name' => $layer_name, ]; } } // Global holidays are shown on every calendar (EventsController adds the // '__holidays__' creator to the feed). Expose a matching, always-available // "Holidays" overlay so any viewer — even one who never subscribed to the // layer — can toggle it. Skip if the viewer already has it as a real layer. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed one-row existence probe on a webcal_* table; no WP API covers these tables. $has_holidays = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}webcal_entry WHERE cal_create_by = %s LIMIT 1", '__holidays__' ) ); if ( $has_holidays && !in_array( '__holidays__', array_column( $layers_data, 'layerUser' ), true ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- single-row color lookup on a webcal_* table; no WP API covers these tables. $holiday_color = $wpdb->get_var( $wpdb->prepare( "SELECT cal_color FROM {$wpdb->prefix}webcal_user_layers WHERE cal_layeruser = %s LIMIT 1", '__holidays__' ) ); $layers_data[] = [ 'layerUser' => '__holidays__', 'color' => ( $holiday_color ? $holiday_color : '#dc3232' ), 'showDuplicates' => false, 'name' => __( 'Holidays', 'agenticdaisy-calendar' ), ]; } $start_of_week_raw = filter_var( get_option( 'start_of_week', 0 ), FILTER_VALIDATE_INT ); $start_of_week = ( $start_of_week_raw === false ? 0 : $start_of_week_raw ); $stored_offset = get_option( 'gmt_offset', 0 ); $gmt_offset = ( is_numeric( $stored_offset ) ? (float) $stored_offset : 0.0 ); $api_settings = [ 'root' => esc_url_raw( rest_url() ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'timeFormat' => webcal_time_format(), 'dateFormat' => webcal_date_format(), 'startOfWeek' => $start_of_week, 'gmtOffset' => $gmt_offset, 'mapProvider' => get_option( 'webcal_map_provider', 'osm' ), 'googleMapsApiKey' => get_option( 'webcal_google_maps_api_key', '' ), 'defaultAccess' => $user_default_access, 'layers' => $layers_data, 'canEdit' => is_user_logged_in() && current_user_can( 'webcal_can_edit' ), 'canSubmit' => is_user_logged_in() && current_user_can( 'webcal_can_submit' ), 'adminEventsUrl' => admin_url( 'admin.php?page=webcalendar' ), 'features' => webcal_feature_flags(), ]; // Localize for shortcode handle and block view script handles. // Blocks auto-generate handles from block.json viewScript; the // shortcode path enqueues webcal-frontend directly. $handles = [ 'webcal-frontend', 'webcalendar-main-view-script', 'webcalendar-upcoming-events-view-script', 'webcalendar-submit-event-view-script' ]; foreach ( $handles as $handle ) { wp_localize_script( $handle, 'webcalApiSettings', $api_settings ); } // Without this the __() calls in the bundles are never translated, // however complete the catalogue is: WordPress only loads the JSON // translations for a handle that has been told which domain and which // directory to look in. The strings were in the .pot either way, so a // translator's work on them simply had no effect. // // The block editor scripts are listed as well as the view scripts: // the inspector controls are translatable too, and they live in the // editor bundle. Setting translations on a handle that is not // registered in the current request is a no-op. $translated = array_merge( $handles, ['webcalendar-main-editor-script', 'webcalendar-upcoming-events-editor-script', 'webcalendar-submit-event-editor-script'] ); foreach ( $translated as $handle ) { wp_set_script_translations( $handle, 'agenticdaisy-calendar', WEBCALENDAR_WP_PATH . 'languages' ); } wp_register_style( 'webcal-frontend', WEBCALENDAR_WP_URL . 'build/frontend.css', [], $assets['version'] ); wp_register_script( 'webcal-mini-calendar', WEBCALENDAR_WP_URL . 'src/mini-calendar.js', [], $assets['version'], true ); // Premium frontend bundle: the front-end submit form (frontend_submission). // Stripped from the free build via @fs_premium_only, so it is only // registered when present. Depends on webcal-frontend for React + // webcalApiSettings + apiFetch middleware. $submit_asset = WEBCALENDAR_WP_PATH . 'build/premium-submit-event.asset.php'; if ( file_exists( $submit_asset ) && file_exists( WEBCALENDAR_WP_PATH . 'build/premium-submit-event.js' ) ) { /** @psalm-suppress MissingFile -- build artifact resolved at runtime; guarded by file_exists(). */ $submit = (require $submit_asset); wp_register_script( 'webcal-premium-submit-event', WEBCALENDAR_WP_URL . 'build/premium-submit-event.js', array_merge( $submit['dependencies'], ['webcal-frontend'] ), $submit['version'], true ); wp_localize_script( 'webcal-premium-submit-event', 'webcalApiSettings', $api_settings ); wp_set_script_translations( 'webcal-premium-submit-event', 'agenticdaisy-calendar', WEBCALENDAR_WP_PATH . 'languages' ); if ( file_exists( WEBCALENDAR_WP_PATH . 'build/premium-submit-event.css' ) ) { wp_register_style( 'webcal-premium-submit-event', WEBCALENDAR_WP_URL . 'build/premium-submit-event.css', ['webcal-frontend'], $submit['version'] ); } } // Premium frontend bundle: the public RSVP form (event_rsvp). Same // shape as the submit form above -- stripped from the free build, so // registered only when the built file is present. $rsvp_asset = WEBCALENDAR_WP_PATH . 'build/premium-rsvp.asset.php'; if ( file_exists( $rsvp_asset ) && file_exists( WEBCALENDAR_WP_PATH . 'build/premium-rsvp.js' ) ) { /** @psalm-suppress MissingFile -- build artifact resolved at runtime; guarded by file_exists(). */ $rsvp = (require $rsvp_asset); wp_register_script( 'webcal-premium-rsvp', WEBCALENDAR_WP_URL . 'build/premium-rsvp.js', array_merge( $rsvp['dependencies'], ['webcal-frontend'] ), $rsvp['version'], true ); wp_localize_script( 'webcal-premium-rsvp', 'webcalApiSettings', $api_settings ); wp_set_script_translations( 'webcal-premium-rsvp', 'agenticdaisy-calendar', WEBCALENDAR_WP_PATH . 'languages' ); if ( file_exists( WEBCALENDAR_WP_PATH . 'build/premium-rsvp.css' ) ) { wp_register_style( 'webcal-premium-rsvp', WEBCALENDAR_WP_URL . 'build/premium-rsvp.css', ['webcal-frontend'], $rsvp['version'] ); } } } /** * Sync remote calendars via cron. */ public function sync_remote_calendars() : void { if ( !class_exists( \WebCalendar\WP\RemoteCalendarService::class ) ) { return; } $container = Container::get_instance(); try { /** @var \WebCalendar\WP\RemoteCalendarService */ $service = $container->get( 'remote_calendar_service' ); $service->sync_all(); update_option( 'webcal_last_sync_time', (string) time(), false ); } catch ( \Throwable $e ) { /** @var \WebCalendarDeps\Psr\Log\LoggerInterface */ $logger = $container->get( 'logger' ); $logger->error( 'Cron Error: ' . $e->getMessage() ); } } /** * Delete unconfirmed RSVPs whose confirmation window has closed. * * These never held a seat, so nothing is released here. What is released * is the guest's name and email address, which there is no reason to keep * once the link they were collected for can no longer be used. */ public function purge_pending_rsvps() : void { if ( !class_exists( \WebCalendar\WP\Rsvp\PendingRsvpRepository::class ) ) { return; } $container = Container::get_instance(); try { /** @var \WebCalendar\WP\Rsvp\PendingRsvpRepository */ $pending = $container->get( 'pending_rsvp_repository' ); $pending->purge_expired(); } catch ( \Throwable $e ) { /** @var \WebCalendarDeps\Psr\Log\LoggerInterface */ $logger = $container->get( 'logger' ); $logger->error( 'Cron Error: ' . $e->getMessage() ); } } /** * Run the plugin. */ public function run() : void { $database = new \WebCalendar\WP\Database\Database(); $database->init(); add_action( 'admin_notices', [$database, 'render_schema_notice'], 10, 0 ); } /** * The Settings page's request limit, for the shared bucket only. * * A route with its own bucket (RSVP: 10 a minute) keeps its allowance; * the setting used to replace it with the site-wide value. A named * bucket can still be changed on purpose, through its own option * webcal_rate_limit_ (the test site raises them all). * * @param int $default Limit the route asked for. * @param string $bucket Rate-limit bucket. */ public static function configured_rate_limit( int $default, string $bucket = 'default' ) : int { $raw = get_option( ( 'default' === $bucket ? 'webcal_rate_limit' : 'webcal_rate_limit_' . $bucket ) ); return ( is_scalar( $raw ) && (int) $raw > 0 ? (int) $raw : $default ); } /** * The Settings page's rate-limit window, for the shared bucket only. * * @param int $default Period the route asked for. * @param string $bucket Rate-limit bucket. */ public static function configured_rate_limit_period( int $default, string $bucket = 'default' ) : int { $raw = get_option( 'webcal_rate_limit_period' ); return ( 'default' === $bucket && is_scalar( $raw ) && (int) $raw > 0 ? (int) $raw : $default ); } }