[ 'label' => 'Yoast SEO', 'seo' => true ], 'aioseo' => [ 'label' => 'All in One SEO', 'seo' => true ], 'rankmath' => [ 'label' => 'Rank Math', 'seo' => true ], 'seopress' => [ 'label' => 'SEOPress', 'seo' => true ], 'cf7' => [ 'label' => 'Contact Form 7', 'seo' => false ], 'wpforms' => [ 'label' => 'WPForms', 'seo' => false ], 'redirection' => [ 'label' => 'Redirection', 'seo' => false ], ]; /** * Register the AJAX hooks and the post-apply admin notice. * * @return void */ public function register(): void { add_action( 'wp_ajax_' . self::TOGGLE_HIDDEN_ACTION, [ $this, 'handle_toggle_hidden' ] ); add_action( 'wp_ajax_' . self::GET_POPUP_ACTION, [ $this, 'handle_get_popup' ] ); add_action( 'wp_ajax_' . self::APPLY_ACTION, [ $this, 'handle_apply' ] ); add_action( 'admin_notices', [ $this, 'maybe_render_apply_notice' ] ); } // ----------------------------------------------------------------- // Rendering // ----------------------------------------------------------------- /** * Render the Quick Start card (or, when hidden, nothing -- the caller * renders the restore link separately via render_restore_link()). * * @return void */ public function render_card(): void { if ( ! current_user_can( Module_Registry::DEFAULT_ACTIVATION_CAPABILITY ) ) { return; } $rows = $this->build_rows(); $this->maybe_auto_hide( $rows ); if ( $this->is_hidden() ) { return; } $done_count = 0; $total_count = count( $rows ); foreach ( $rows as $row ) { if ( 'open' !== $row['status'] ) { ++$done_count; } } $percent = $total_count > 0 ? round( ( $done_count / $total_count ) * 100 ) : 100; ?>

render_row( $row ); ?>
is_hidden() ) { return; } ?>
status_text( $row['status'] ) ); ?>
*/ private function build_rows(): array { return [ $this->migration_row(), $this->apply_recommended_row(), $this->secure_site_row(), $this->smtp_row(), $this->seo_row(), ]; } /** * Build a simple "enable this one module" row. * * @param string $module_id Canonical module ID. * @param string $label Row label. * @param bool $done Whether the row is complete. * * @return array */ private function simple_module_row( string $module_id, string $label, bool $done ): array { return [ 'id' => $module_id, 'label' => $label, 'subline' => '', 'status' => $done ? 'done' : 'open', 'action_label' => $done ? '' : __( 'Open →', 'admin-optimizer' ), 'action_url' => Module_Registry::get_module_url( $module_id ), ]; } /** * Row 3: secure the site. Done when the custom login URL is enabled and * Security Monitor (when on) reports no critical issues. Always links * onward: to Security Monitor's Site Health tab, or to the login module * while that is still off. * * @return array */ private function secure_site_row(): array { $login_done = Module_Registry::is_enabled( 'custom_login_url' ); $critical = $this->security_monitor_critical_count(); $done = $login_done && 0 === $critical; if ( ! $login_done ) { $subline = __( 'Set a custom login URL.', 'admin-optimizer' ); $url = Module_Registry::get_module_url( 'custom_login_url' ); } else { /* translators: %d: number of critical Security Monitor issues. */ $subline = sprintf( _n( 'Security Monitor found %d critical issue.', 'Security Monitor found %d critical issues.', $critical, 'admin-optimizer' ), $critical ); $url = $this->security_monitor_url(); } if ( $done ) { $subline = ''; $url = $this->security_monitor_url(); if ( '' === $url ) { $url = Module_Registry::get_module_url( 'custom_login_url' ); } } return [ 'id' => 'secure_site', 'label' => __( 'Secure your site', 'admin-optimizer' ), 'done_label' => __( 'Site secured', 'admin-optimizer' ), 'subline' => $subline, 'status' => $done ? 'done' : 'open', 'action_label' => $done ? __( 'Review again', 'admin-optimizer' ) : __( 'Open →', 'admin-optimizer' ), 'action_url' => $url, ]; } /** * Critical issue count from Security Monitor's cached Site Health results * (checks only run when that cache is stale). Zero when the module is off. * * @return int */ private function security_monitor_critical_count(): int { if ( ! Module_Registry::is_enabled( 'security_monitor' ) || ! class_exists( Security_Monitor_Settings::class ) ) { return 0; } $results = Health_Engine::get_results( Security_Monitor_Settings::SITE_HEALTH_TRANSIENT, Security_Monitor_Settings::SITE_HEALTH_TTL, [ Security_Monitor_Settings::class, 'run_checks_and_snapshot' ] ); $counts = Health_Engine::count_statuses( $results['items'], [ Site_Health_Checks::GROUP_INFO ] ); return (int) $counts['critical']; } /** * Security Monitor's Site Health tab URL, or '' when the module is off. * * @return string */ private function security_monitor_url(): string { if ( ! Module_Registry::is_enabled( 'security_monitor' ) || ! class_exists( Security_Monitor::class ) ) { return ''; } return Security_Monitor::get_module_admin_url( [ 'tab' => 'site-health' ] ); } /** * Row 3: SMTP mail, done when enabled and configured beyond defaults. * * @return array */ private function smtp_row(): array { $done = Module_Registry::is_enabled( 'smtp_mail' ) && SMTP_Email::is_configured(); return [ 'id' => 'smtp_mail', 'label' => __( 'Set up SMTP mail', 'admin-optimizer' ), 'done_label' => __( 'SMTP mail set up', 'admin-optimizer' ), 'subline' => '', 'status' => $done ? 'done' : 'open', 'action_label' => $done ? '' : __( 'Open →', 'admin-optimizer' ), 'action_url' => Module_Registry::get_module_url( 'smtp_mail' ), ]; } /** * Row 5: SEO basics, done when the XML Sitemap module is enabled. The * action opens the SEO Section. * * @return array */ private function seo_row(): array { $done = Module_Registry::is_enabled( 'xml_sitemap' ); return [ 'id' => 'xml_sitemap', 'label' => __( 'Check your SEO basics', 'admin-optimizer' ), 'done_label' => __( 'SEO basics in place', 'admin-optimizer' ), 'subline' => '', 'status' => $done ? 'done' : 'open', 'action_label' => $done ? '' : __( 'Open →', 'admin-optimizer' ), 'action_url' => Module_Registry::get_module_url( 'xml_sitemap' ), ]; } /** * Row 1: migration from a detected source plugin. * * @return array */ private function migration_row(): array { $detected = $this->detected_source_plugins(); $migrator_url = $this->migrator_url(); $scan_url = $this->scan_again_url(); if ( empty( $detected ) ) { return [ 'id' => 'migration', 'label' => __( 'Migrate from another plugin', 'admin-optimizer' ), 'subline' => __( 'Nothing to migrate', 'admin-optimizer' ), 'status' => 'not_applicable', 'action_label' => __( 'Scan again', 'admin-optimizer' ), 'action_url' => $scan_url, ]; } $names = wp_list_pluck( $detected, 'label' ); $seo_sources = array_filter( $detected, static fn( $plugin ) => ! empty( $plugin['seo'] ) ); $label = $this->format_migration_label( $names ); $subline = __( 'Detected on this site', 'admin-optimizer' ); if ( count( $seo_sources ) >= 2 ) { $subline .= '. ' . __( 'Migrate one SEO plugin at a time', 'admin-optimizer' ); } /** * Lets the companion Migration Tool plugin (or anything else) report * real per-source completion instead of the ao_migration_log * approximation below, should the companion's log shape ever change. * * @param bool|null $done Null signals "use the default approximation". * @param array $detected Detected source plugins (slug/label/seo). */ $done = apply_filters( 'adminoptimizer_quick_start_migration_done', null, $detected ); if ( null === $done ) { $done = $this->migration_log_covers( $detected ); } return [ 'id' => 'migration', 'label' => $label, 'done_label' => __( 'Migration complete', 'admin-optimizer' ), 'subline' => $subline, 'status' => $done ? 'done' : 'open', 'action_label' => $done ? '' : __( 'Migrate →', 'admin-optimizer' ), 'action_url' => $migrator_url, ]; } /** * Format the migration row label for one, a few, or many detected * sources. * * @param array $names Detected plugin display names. * * @return string */ private function format_migration_label( array $names ): string { $count = count( $names ); if ( 1 === $count ) { /* translators: %s: source plugin name, or a comma/and-separated list of plugin names, e.g. "Yoast SEO". */ return sprintf( __( 'Migrate from %s', 'admin-optimizer' ), $names[0] ); } if ( $count <= 3 ) { return sprintf( /* translators: %s: source plugin name, or a comma/and-separated list of plugin names, e.g. "Yoast SEO". */ __( 'Migrate from %s', 'admin-optimizer' ), wp_sprintf_l( '%l', $names ) ); } /* translators: %d: number of detected source plugins. */ return sprintf( __( 'Migrate from %d plugins', 'admin-optimizer' ), $count ); } /** * Detect migratable source plugins. When the companion Migration Tool is * active, this asks its own importer classes' is_available() directly -- * the same check it uses to decide which discovery cards to show -- so * Free never second-guesses it. When the companion is inactive, it falls * back to re-deriving the same presence signal locally per source (see * FALLBACK_DETECTORS / migration_source_detected_fallback()). * * @return array */ private function detected_source_plugins(): array { $companion_active = class_exists( '\Yipresser\AdminOptimizer\AO_Migrator' ); $detected = []; foreach ( self::MIGRATION_SOURCES as $slug => $meta ) { $available = $companion_active ? $this->migration_source_detected_via_companion( $slug ) : $this->migration_source_detected_fallback( $slug ); if ( $available ) { $detected[] = array_merge( [ 'slug' => $slug ], $meta ); } } return $detected; } /** * Ask the companion Migration Tool's own importer class whether its * source plugin is present, rather than re-deriving the signal * ourselves. * * @param string $slug Importer slug (see MIGRATION_SOURCES). * * @return bool */ private function migration_source_detected_via_companion( string $slug ): bool { $classes = [ 'yoast' => '\Yipresser\AdminOptimizer\Yoast_Importer', 'aioseo' => '\Yipresser\AdminOptimizer\AIOSEO_Importer', 'rankmath' => '\Yipresser\AdminOptimizer\RankMath_Importer', 'seopress' => '\Yipresser\AdminOptimizer\SEOPress_Importer', 'cf7' => '\Yipresser\AdminOptimizer\CF7_Importer', 'wpforms' => '\Yipresser\AdminOptimizer\WPForms_Importer', 'redirection' => '\Yipresser\AdminOptimizer\Redirection_Importer', ]; $class = $classes[ $slug ] ?? null; return null !== $class && class_exists( $class ) && call_user_func( [ $class, 'is_available' ] ); } /** * Fallback detection used only when the companion plugin is inactive: * the same constant/class/option presence probe each importer's own * is_available() already performs against the *source* plugin (not the * companion), copied verbatim from migration-tool/includes/importers. * * @param string $slug Importer slug (see MIGRATION_SOURCES). * * @return bool */ private function migration_source_detected_fallback( string $slug ): bool { switch ( $slug ) { case 'yoast': return false !== get_option( 'wpseo' ) || class_exists( 'WPSEO_Options' ); case 'aioseo': return defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\Plugin\AIOSEO' ) || false !== get_option( 'aioseo_options' ) || false !== get_option( 'aioseo_options_dynamic' ); case 'rankmath': return defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) || class_exists( 'RankMath\RankMath' ) || false !== get_option( 'rank-math-options-general' ) || false !== get_option( 'rank-math-options-titles' ); case 'seopress': return defined( 'SEOPRESS_VERSION' ) || class_exists( 'SeoPress\Core\Kernel' ) || function_exists( 'seopress_init' ) || false !== get_option( 'seopress_titles_option_name' ) || false !== get_option( 'seopress_social_option_name' ) || false !== get_option( 'seopress_robot_option_name' ); case 'cf7': return defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7' ); case 'wpforms': return defined( 'WPFORMS_VERSION' ) || defined( 'WPFORMS_PRO_VERSION' ) || class_exists( '\WPForms_Form_Handler' ) || function_exists( 'wpforms' ); case 'redirection': // The only probe here that costs a DB round trip, so keep it // scoped to the Overview screen (the only place this whole // class is ever rendered) rather than trusting call-site // discipline alone. if ( ! $this->is_overview_request() ) { return false; } global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'redirection_items' ) // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare ); default: return false; } } /** * Whether `ao_migration_log` has an entry for every currently detected * source, matched by each importer's exact plugin name. * * The log entry's only identifying field is `plugin` (no slug -- * confirmed by reading every importer's log_migration() call site in * migration-tool/includes/importers/*.php, e.g. class-yoast-importer.php * get_plugin_name(): `return __( 'Yoast SEO', '...' );`). That string * goes through the companion plugin's own __() call, but every importer * passes a literal brand name with nothing to translate in practice, so * matching the exact literal here (MIGRATION_SOURCES' `label`, which is * copied verbatim from each get_plugin_name()) is as stable as this log * shape allows without a slug to key on. * * `log_migration()` is only ever called from each importer's completion * path (module-importer.php's batch-complete branch, and each * importer's own finish_run()/ajax_start() completion branch) -- never * from an error/abort path -- so an entry here always means that source's * migration run actually finished, not merely started or failed midway. * * There is deliberately no "log has any entry" fallback: with the exact * per-source match above, a non-match is a real "not yet migrated" * signal, not an ambiguous one. Falling back to "something was migrated * once" would mark this row done for a newly detected source (e.g. CF7 * found after a long-gone, previously migrated SEO plugin) that was * never actually migrated. * * @param array $detected Detected source plugins (slug/label/seo). * * @return bool */ private function migration_log_covers( array $detected ): bool { if ( empty( $detected ) ) { return false; } $log = get_option( 'ao_migration_log', [] ); if ( ! is_array( $log ) || empty( $log ) ) { return false; } $logged_names = array_map( static function ( $entry ) { return is_array( $entry ) ? strtolower( trim( (string) ( $entry['plugin'] ?? '' ) ) ) : ''; }, $log ); foreach ( $detected as $source ) { if ( ! in_array( strtolower( $source['label'] ), $logged_names, true ) ) { return false; } } return true; } /** * Row 2: apply recommended settings, done when every applicable * recommended ID is enabled. * * @return array */ private function apply_recommended_row(): array { $applicable = $this->applicable_recommended_ids(); $declined = $this->declined_ids(); $outstanding = array_filter( $applicable, static function ( $id ) use ( $declined ) { return ! in_array( $id, $declined, true ) && ! Module_Registry::is_enabled( $id ); } ); $done = empty( $outstanding ); return [ 'id' => 'recommended_settings', 'label' => __( 'Apply recommended optimizations', 'admin-optimizer' ), 'done_label' => __( 'Recommended optimizations applied', 'admin-optimizer' ), 'subline' => __( 'Enable a set of safe performance, security and reliability optimizations.', 'admin-optimizer' ), 'status' => $done ? 'done' : 'open', 'action_label' => $done ? __( 'Review again', 'admin-optimizer' ) : __( 'Review & Apply →', 'admin-optimizer' ), 'action_url' => '#', 'action_key' => 'review-recommended', ]; } /** * Recommended IDs available in the installed edition. * * @return array */ private function applicable_recommended_ids(): array { return array_values( array_filter( self::RECOMMENDED_IDS, static function ( $id ) { return Module_Registry::has_module( $id ) && Module_Registry::is_toggleable( $id ); } ) ); } /** * Optional IDs available in the installed edition. * * @return array */ private function applicable_optional_ids(): array { return array_values( array_filter( self::OPTIONAL_IDS, static function ( $id ) { return Module_Registry::has_module( $id ) && Module_Registry::is_toggleable( $id ); } ) ); } /** * Site-level list of recommended IDs previously declined. * * @return array */ private function declined_ids(): array { return array_map( 'sanitize_key', (array) get_option( self::DECLINED_OPTION, [] ) ); } // ----------------------------------------------------------------- // Per-user hidden/auto-hide state // ----------------------------------------------------------------- /** * Whether the card is currently hidden for the current user. * * @return bool */ private function is_hidden(): bool { return ! empty( $this->get_user_state()['hidden'] ); } /** * Read the current user's Quick Start UI-chrome state. * * @return array{hidden:bool,auto_hidden_once:bool} */ private function get_user_state(): array { $raw = get_user_option( self::USER_OPTION ); $raw = is_array( $raw ) ? $raw : []; return [ 'hidden' => ! empty( $raw['hidden'] ), 'auto_hidden_once' => ! empty( $raw['auto_hidden_once'] ), ]; } /** * Persist the current user's Quick Start UI-chrome state. * * @param array $state {hidden:bool,auto_hidden_once:bool}. * * @return void */ private function save_user_state( array $state ): void { update_user_option( get_current_user_id(), self::USER_OPTION, [ 'hidden' => ! empty( $state['hidden'] ), 'auto_hidden_once' => ! empty( $state['auto_hidden_once'] ), ] ); } /** * Auto-hide the card once, the first time every row renders done/not * applicable. A later regression never re-triggers it; the restore link * is the only way back. * * @param array $rows Rows built by build_rows(). * * @return void */ private function maybe_auto_hide( array $rows ): void { $state = $this->get_user_state(); if ( $state['hidden'] || $state['auto_hidden_once'] ) { return; } foreach ( $rows as $row ) { if ( 'open' === $row['status'] ) { return; } } $state['hidden'] = true; $state['auto_hidden_once'] = true; $this->save_user_state( $state ); } // ----------------------------------------------------------------- // URLs // ----------------------------------------------------------------- /** * The Overview screen's own URL for "Scan again". Carries a cache-busting * timestamp so a page-caching layer in front of wp-admin (reverse proxy, * caching plugin misconfigured to also cache admin requests) can't serve * a stale render instead of re-running detection; detection itself is * already computed live on every render, nothing here is actually cached * by this plugin. * * @return string */ private function scan_again_url(): string { return add_query_arg( [ 'page' => ADMINOPTIMIZER_MODULES_MENU_SLUG, '_qs_rescan_at' => time(), ], admin_url( 'admin.php' ) ); } /** * Whether the current request is the Overview screen itself, used to * keep the one DB-querying detection probe (Redirection) from running * anywhere else this class might someday be reached from. * * @return bool */ private function is_overview_request(): bool { if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, not a state change. return false; } return ADMINOPTIMIZER_MODULES_MENU_SLUG === sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, not a state change. } /** * The Overview screen's Migration tab URL. * * @return string */ private function migrator_url(): string { return add_query_arg( [ 'page' => ADMINOPTIMIZER_MODULES_MENU_SLUG, 'module' => 'migrator', ], admin_url( 'admin.php' ) ); } // ----------------------------------------------------------------- // AJAX: hide/restore // ----------------------------------------------------------------- /** * Toggle the per-user hidden flag. Manual dismiss/undo never touches * auto_hidden_once. * * @return void */ public function handle_toggle_hidden(): void { if ( ! current_user_can( Module_Registry::DEFAULT_ACTIVATION_CAPABILITY ) ) { wp_send_json_error( [ 'code' => 'forbidden' ], 403 ); } check_ajax_referer( self::NONCE_ACTION, self::NONCE_FIELD ); $hidden = ! empty( $_POST['hidden'] ) && '0' !== $_POST['hidden']; $state = $this->get_user_state(); $state['hidden'] = $hidden; $this->save_user_state( $state ); wp_send_json_success( [ 'hidden' => $hidden ] ); } // ----------------------------------------------------------------- // AJAX: review-recommended popup // ----------------------------------------------------------------- /** * Build the popup's item list on demand. Labels/descriptions are pulled * from Module_Registry at request time; sublines specific to this popup * are hardcoded copy, not a second catalogue. * * @return void */ public function handle_get_popup(): void { if ( ! current_user_can( Module_Registry::DEFAULT_ACTIVATION_CAPABILITY ) ) { wp_send_json_error( [ 'code' => 'forbidden' ], 403 ); } check_ajax_referer( self::NONCE_ACTION, self::NONCE_FIELD ); wp_send_json_success( [ 'recommended' => $this->popup_items( $this->applicable_recommended_ids() ), 'optional' => $this->popup_items( $this->applicable_optional_ids() ), ] ); } /** * Build popup item payloads for a list of canonical IDs. * * @param array $ids Canonical Module_Registry IDs. * * @return array */ private function popup_items( array $ids ): array { $items = []; foreach ( $ids as $id ) { $module = Module_Registry::get_module( $id ); if ( null === $module ) { continue; } $items[] = [ 'id' => $id, 'label' => $module['name'], 'subline' => $this->popup_subline( $id ), 'already_on' => Module_Registry::is_enabled( $id ), ]; } return $items; } /** * Dynamic/static subline copy for one popup item. * * @param string $id Canonical ID. * * @return string */ private function popup_subline( string $id ): string { if ( 'publish_missed_schedule' === $id ) { $missed = $this->missed_schedule_count(); if ( $missed > 0 ) { return sprintf( /* translators: %d: number of posts marked Missed Schedule. */ _n( '%d post is marked Missed Schedule and will publish when you apply.', '%d posts are marked Missed Schedule and will publish when you apply.', $missed, 'admin-optimizer' ), $missed ); } return __( 'Publishes scheduled posts that miss their time.', 'admin-optimizer' ); } $sublines = [ 'disable_jquery_migrate' => __( 'Can break older plugins and themes.', 'admin-optimizer' ), 'disable_xmlrpc' => __( 'Also used by Jetpack and the mobile app.', 'admin-optimizer' ), 'disable_404_redirect' => __( 'Stops WordPress guessing the URL for a missing page.', 'admin-optimizer' ), ]; return $sublines[ $id ] ?? ''; } /** * Count posts stuck as 'future' with a post_date_gmt already in the * past. A single prepared COUNT query, computed only when the popup is * requested. * * @return int */ private function missed_schedule_count(): int { global $wpdb; $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s AND post_date_gmt < %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 'future', current_time( 'mysql', true ) ) ); return (int) $count; } // ----------------------------------------------------------------- // AJAX: apply // ----------------------------------------------------------------- /** * Apply the selected recommended/optional settings. Re-resolves every ID * server-side; writes only through Module_Registry::set_enabled(). * * @return void */ public function handle_apply(): void { if ( ! current_user_can( Module_Registry::DEFAULT_ACTIVATION_CAPABILITY ) ) { wp_send_json_error( [ 'code' => 'forbidden' ], 403 ); } check_ajax_referer( self::NONCE_ACTION, self::NONCE_FIELD ); $submitted = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? array_map( 'sanitize_key', wp_unslash( $_POST['ids'] ) ) : []; $preset = array_merge( self::RECOMMENDED_IDS, self::OPTIONAL_IDS ); $selected = array_values( array_filter( array_unique( $submitted ), static function ( $id ) use ( $preset ) { return in_array( $id, $preset, true ) && Module_Registry::has_module( $id ) && Module_Registry::is_toggleable( $id ); } ) ); if ( empty( $selected ) ) { wp_send_json_error( [ 'code' => 'nothing_selected' ], 400 ); } $applied = []; foreach ( $selected as $id ) { $result = Module_Registry::set_enabled( $id, true ); if ( is_wp_error( $result ) ) { $this->error( 'Quick Start failed to apply a recommended setting.', [ 'id' => $id, 'error' => $result->get_error_code() ], 'quick_start_apply_failed' ); continue; } $applied[] = $id; } $this->record_declined( $selected ); if ( empty( $applied ) ) { wp_send_json_error( [ 'code' => 'apply_failed' ], 500 ); } $this->info( 'Quick Start applied recommended settings.', [ 'ids' => $applied ], 'quick_start_applied' ); $this->queue_apply_notice( $applied ); wp_send_json_success( [ 'applied' => $applied ] ); } /** * Record the recommended IDs that were applicable but not among the * selected set as declined, so they stop counting toward completion. * * @param array $selected The IDs the apply request actually selected. * * @return void */ private function record_declined( array $selected ): void { $declined_now = array_diff( $this->applicable_recommended_ids(), $selected ); if ( empty( $declined_now ) ) { return; } $declined = array_unique( array_merge( $this->declined_ids(), array_values( $declined_now ) ) ); update_option( self::DECLINED_OPTION, array_values( $declined ) ); } /** * Queue the one-shot admin notice shown on the next render. * * This intentionally does not go through the shared admin-notice/toast * coordinator service in includes/notification.php: both of its request * methods reject any request made within 14 days of plugin activation, * which is exactly when a new install is using Quick Start. A per-user * transient read once by a plain `admin_notices` hook is the minimal * mechanism that survives the AJAX apply -> page reload round trip * without being silently dropped for the installs this feature targets. * * @param array $applied Canonical IDs that were successfully applied. * * @return void */ private function queue_apply_notice( array $applied ): void { set_transient( self::NOTICE_TRANSIENT_PREFIX . get_current_user_id(), $applied, MINUTE_IN_SECONDS ); } /** * Render (once) and clear the queued post-apply admin notice. * * @return void */ public function maybe_render_apply_notice(): void { $key = self::NOTICE_TRANSIENT_PREFIX . get_current_user_id(); $applied = get_transient( $key ); if ( empty( $applied ) || ! is_array( $applied ) ) { return; } delete_transient( $key ); $links = []; foreach ( $applied as $id ) { $module = Module_Registry::get_module( $id ); if ( null === $module ) { continue; } $links[] = sprintf( '%s', esc_url( Module_Registry::get_module_url( $id ) ), esc_html( $module['name'] ) ); } if ( empty( $links ) ) { return; } ?>