[ '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;
?>
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;
}
?>