' . '' . '' . '' . '' /* * Three dots, matching assets/ai-o-icon.png, which the in-page * navigation uses. The sidebar mark had two, so the same logo drew * differently depending which part of the plugin you looked at. * * Monochrome is deliberate and stays: a full-colour logo in the * admin menu fights every colour scheme WordPress ships. * * The placement is measured, not eyeballed. At 20x20 the ring * already reaches x=19.9, so a third dot beside it collides -- the * first attempt overlapped it by 0.2 units and would have looked * fused at real size. Up and to the left clears by 1.09 units, the * widest gap available without redrawing the composition. Re-measure * anything moved here; at this size the difference between separated * and touching is about one unit. */ . '' . '' . '' . '' . '' . ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data URI for a menu icon, not obfuscation. } public function register_menu() { add_menu_page( __( 'AI-O Optimiser', 'ai-o-optimiser' ), __( 'AI-O Optimiser', 'ai-o-optimiser' ), self::dashboard_capability(), 'ai-o-optimiser-dashboard', array( $this, 'render_dashboard' ), self::menu_icon(), 80 ); add_submenu_page( 'ai-o-optimiser-dashboard', __( 'Dashboard', 'ai-o-optimiser' ), __( 'Dashboard', 'ai-o-optimiser' ), self::dashboard_capability(), 'ai-o-optimiser-dashboard', array( $this, 'render_dashboard' ), 1 ); add_submenu_page( 'ai-o-optimiser-dashboard', __( 'Metadata', 'ai-o-optimiser' ), __( 'Metadata', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-optimise', array( $this, 'render_optimise' ), 10 ); add_submenu_page( 'ai-o-optimiser-dashboard', __( 'Settings', 'ai-o-optimiser' ), __( 'Settings', 'ai-o-optimiser' ), self::SENSITIVE_CAPABILITY, 'ai-o-optimiser-settings', array( $this, 'render_settings' ), 60 ); add_submenu_page( null, __( 'Notifications', 'ai-o-optimiser' ), __( 'Notifications', 'ai-o-optimiser' ), self::SENSITIVE_CAPABILITY, 'ai-o-optimiser-notifications', array( $this, 'render_notifications' ) ); // Logs, AI Guidance Files, System Check, Safety Check and the AI Review // Prompt are configure-once / check-occasionally screens, not daily // workflow screens. They stay fully reachable at their existing URLs // (no broken links/emails/bookmarks) but no longer clutter the main // sidebar — they're grouped as tabs alongside Settings instead, via a // null parent slug (WordPress's documented way to register a page // without a visible menu entry). add_submenu_page( null, __( 'Logs', 'ai-o-optimiser' ), __( 'Logs', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-logs', array( $this, 'render_logs' ) ); /* * The Metadata Workflow slug is registered by the add-on when it is * present (its own real screen, or its own locked state). When it is * not, eleven links elsewhere in this admin still point at the slug -- * dashboard priority actions, the API-keys button on Settings, * background-job rows -- so core registers it, hidden, to answer them * with the absent-state presentation rather than "Sorry, you are not * allowed to access this page". */ if ( ! class_exists( 'AI_O_Optimiser_Extensions' ) || ! AI_O_Optimiser_Extensions::is_available( 'metadata' ) ) { add_submenu_page( null, __( 'Metadata', 'ai-o-optimiser' ), __( 'Metadata', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-metadata-workflow', array( $this, 'render_optimise' ) ); } add_submenu_page( null, __( 'AI Guidance Files', 'ai-o-optimiser' ), __( 'AI Guidance Files', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-guidance', array( 'AI_O_Optimiser_LLMS_Admin', 'render_admin_page' ) ); add_submenu_page( null, __( 'Environmental Safety Check', 'ai-o-optimiser' ), __( 'Safety Check', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-environment-check', array( 'AI_O_Optimiser_Environment_Check', 'render_admin_page' ) ); add_submenu_page( null, __( 'System Check', 'ai-o-optimiser' ), __( 'System Check', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-system-check', array( 'AI_O_Optimiser_System_Check', 'render_admin_page' ) ); add_submenu_page( null, __( 'AI-O Optimiser Review Prompt', 'ai-o-optimiser' ), __( 'AI Review Prompt', 'ai-o-optimiser' ), self::CAPABILITY, 'ai-o-optimiser-deep-review-prompt', array( $this, 'render_deep_review_prompt' ) ); } /** * Force the exact intended sidebar order regardless of registration * timing across plugins. add_submenu_page()'s numeric $position argument * did not reliably produce the intended order once a second plugin * (the legacy Metadata module) was registering into the same top-level menu — so * this runs last, after every plugin's own admin_menu callback, and * rebuilds the submenu array directly by slug instead of trusting * position values to sort correctly across plugin boundaries. */ public function reorder_menu() { global $submenu; if ( empty( $submenu['ai-o-optimiser-dashboard'] ) || ! is_array( $submenu['ai-o-optimiser-dashboard'] ) ) { return; } $desired_order = array( 'ai-o-optimiser-dashboard', // What is happening. 'ai-o-optimiser-optimise', // Metadata workflow. 'ai-o-optimiser-schema-review', // Structured-data workflow. 'ai-o-optimiser-settings', // Administrator-only configuration and diagnostics. ); $by_slug = array(); foreach ( $submenu['ai-o-optimiser-dashboard'] as $item ) { $by_slug[ $item[2] ] = $item; } $ordered = array(); foreach ( $desired_order as $slug ) { if ( isset( $by_slug[ $slug ] ) ) { $ordered[] = $by_slug[ $slug ]; unset( $by_slug[ $slug ] ); } } // Workflow/detail pages are registered with a null parent by their owning // modules. Do not register them under ai-o-optimiser-dashboard and then remove them // here: WordPress uses the parent relationship when validating direct admin // URLs, and stripping those submenu rows makes valid detail URLs fail with // a permissions error. $submenu['ai-o-optimiser-dashboard'] = $ordered; } /** * Persistent in-window navigation for every AI-O Optimiser screen. * * Dashboard is intentionally the only area available to non-admin viewers. * Operational, technical and configuration pages require the dedicated * administrator capability and carry an explicit advanced-change warning. */ public function render_global_nav() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only screen state (filters, tab selection, pagination, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $groups = array( 'ai-o-optimiser-dashboard' => 'dashboard', 'ai-o-optimiser-optimise' => 'metadata', 'ai-o-optimiser-metadata-workflow' => 'metadata', 'ai-o-optimiser-content-opportunities' => 'metadata', 'ai-o-optimiser-schema-review' => 'schema', 'ai-o-optimiser-system-check' => 'settings', 'ai-o-optimiser-guidance' => 'settings', 'ai-o-optimiser-environment-check' => 'settings', 'ai-o-optimiser-logs' => 'settings', 'ai-o-optimiser-deep-review-prompt' => 'settings', 'ai-o-optimiser-settings' => 'settings', 'ai-o-optimiser-notifications' => 'settings', ); if ( ! isset( $groups[ $page ] ) ) { return; } $active = $groups[ $page ]; $is_admin = current_user_can( self::CAPABILITY ); $items = array( 'dashboard' => array( 'label' => __( 'Dashboard', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-dashboard' ) ), ); if ( $is_admin ) { $items['metadata'] = array( 'label' => __( 'Metadata', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-optimise' ) ); $items['schema'] = array( 'label' => __( 'Schema', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-schema-review' ) ); $items['settings'] = array( 'label' => __( 'Settings', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-settings' ) ); } ?>
prefix . 'ai_o_optimiser_metadata_queue'; $counts = array( 'queued' => 0, 'processing' => 0, 'suggested' => 0, 'applied' => 0, 'skipped' => 0, 'error' => 0 ); if ( ! AI_O_Optimiser_Persistence::table_exists( $table ) ) { return $counts; } $rows = AI_O_Optimiser_Persistence::cached_results( 'metadata_queue_status_counts', $wpdb->prepare( 'SELECT status, COUNT(*) AS total FROM %i GROUP BY status', $table ), OBJECT, 30 ); foreach ( $rows as $row ) { if ( isset( $counts[ $row->status ] ) ) { $counts[ $row->status ] = (int) $row->total; } } return $counts; } /** * Build the compact cross-module notification list for Settings → Notifications. * * @return array */ private function needs_attention_items() { $items = array(); $counts = self::metadata_queue_counts(); $api = get_option( 'ai_o_optimiser_metadata_api_test', array() ); $api = is_array( $api ) ? $api : array(); if ( ! empty( $counts['error'] ) ) { $items[] = array( 'label' => sprintf( /* translators: Placeholder values are replaced with contextual values shown in this message. */ _n( '%s metadata item failed', '%s metadata items failed', $counts['error'], 'ai-o-optimiser' ), number_format_i18n( $counts['error'] ) ), 'note' => __( 'Fix the AI connection once, then re-add the failed batch to the queue.', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-metadata-workflow&tab=review&status=error' ), 'action'=> __( 'Review failures', 'ai-o-optimiser' ), 'level' => 'warning', ); } if ( ! empty( $counts['queued'] ) && isset( $api['status'] ) && ! in_array( $api['status'], array( 'ok', 'not_tested' ), true ) ) { $items[] = array( 'label' => __( 'Metadata generation cannot use the current AI provider', 'ai-o-optimiser' ), 'note' => isset( $api['message'] ) ? wp_strip_all_tags( $api['message'] ) : __( 'Reconnect or add a direct provider key.', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-metadata-workflow&tab=settings#ai-o-optimiser-metadata-api-keys' ), 'action'=> __( 'Fix AI connection', 'ai-o-optimiser' ), 'level' => 'fail', ); } if ( '1' === (string) get_option( 'ai_o_optimiser_metadata_queue_paused', '0' ) && ! empty( $counts['queued'] ) ) { $items[] = array( 'label' => __( 'Metadata generation is paused', 'ai-o-optimiser' ), 'note' => sprintf( /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( '%s queued items are waiting.', 'ai-o-optimiser' ), number_format_i18n( $counts['queued'] ) ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-metadata-workflow' ), 'action'=> __( 'Open queue controls', 'ai-o-optimiser' ), 'level' => 'warning', ); } $health = class_exists( 'AI_O_Optimiser_System_Check' ) ? AI_O_Optimiser_System_Check::health_report() : array(); if ( ! empty( $health['overall'] ) && in_array( $health['overall'], array( 'warning', 'fail' ), true ) ) { $items[] = array( 'label' => __( 'Nightly technical health needs review', 'ai-o-optimiser' ), 'note' => __( 'A crawlability, robots, guidance-file or key-page check reported a warning.', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-system-check' ), 'action'=> __( 'Review technical health', 'ai-o-optimiser' ), 'level' => $health['overall'], ); } $storage = AI_O_Optimiser_DB::storage_report(); if ( ! empty( $storage['total_bytes'] ) && (int) $storage['total_bytes'] > 75 * MB_IN_BYTES ) { $items[] = array( 'label' => __( 'AI-O Optimiser storage is above 75 MB', 'ai-o-optimiser' ), 'note' => __( 'The v1.17 compact aggregate migration will shrink long-term visibility storage after backfill completes.', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-settings#ai-o-optimiser-storage-guardrails' ), 'action'=> __( 'Review storage', 'ai-o-optimiser' ), 'level' => 'warning', ); } return array_slice( $items, 0, 5 ); } /** Render the cross-module notification panel. */ private function render_needs_attention() { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $items = $this->needs_attention_items(); ?>

'0' ) ); $paused = '1' === (string) get_option( 'ai_o_optimiser_metadata_queue_paused', '0' ); $next_metadata = wp_next_scheduled( 'ai_o_optimiser_metadata_auto_generate' ); $next_health = wp_next_scheduled( AI_O_Optimiser_NIGHTLY_HEALTH_HOOK ); $aggregate = AI_O_Optimiser_DB::aggregate_state(); $aggregate_total = max( 0, (int) ( $aggregate['max_id'] ?? 0 ) ); $aggregate_done = min( $aggregate_total, max( 0, (int) ( $aggregate['last_id'] ?? 0 ) ) ); ?>
·

Notifications. * * @param array $counts Queue status counts from metadata_queue_counts(). * @return void */ public static function render_metadata_hub( array $counts ) { $metadata_generated = (int) $counts['suggested'] + (int) $counts['error']; $metadata_active_total = (int) $counts['queued'] + (int) $counts['processing'] + $metadata_generated; $metadata_progress = $metadata_active_total > 0 ? min( 100, (int) round( ( $metadata_generated / $metadata_active_total ) * 100 ) ) : 0; ?>

0 ) : ?>

__( 'AI brands', 'ai-o-optimiser' ), 'ai-o-optimiser-visibility-overview' => __( 'Overview', 'ai-o-optimiser' ), 'ai-o-optimiser-ai-league' => __( 'League table', 'ai-o-optimiser' ), 'ai-o-optimiser-traffic-impact' => __( 'Traffic impact', 'ai-o-optimiser' ), 'ai-o-optimiser-page-detail' => __( 'Pages', 'ai-o-optimiser' ), ); echo ''; } /** * Print the dashboard row into the shell, on the dashboard only. * * @param string $active Primary navigation group. * @param string $page Current admin page slug. */ public static function render_secondary_nav( $active, $page ) { if ( 'dashboard' !== $active || 'ai-o-optimiser-dashboard' !== $page ) { return; } self::render_dashboard_tab_nav(); } public static function render_settings_tabs( $active_slug ) { // The diagnostic screens live behind the Health section of Settings. // Until 1.27.1 they shared one flat strip with a "Settings" link, which // read as ten peers once the section tabs were added beside them. Logs // and the AI review prompt remain secondary tools rather than navigation // items; they simply show Health as the active section. // // AI Guidance Files was the fourth of them and is not here as of 1.37.1. // The rail carries it under Publish, so listing it here printed the same // label, marked open, in two navigations about 150px apart -- which is // the thing the rail was built to stop rather than to cause. What is left // is the screens that belong to the rail's Health & checks item: that is // secondary navigation inside one rail item, not a second copy of one. // See AI_O_Optimiser_Admin_Rail::groups(). $tabs = array(); if ( current_user_can( self::SENSITIVE_CAPABILITY ) ) { $tabs['notifications'] = array( 'label' => __( 'Notifications', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-notifications' ) ); } $tabs['system-check'] = array( 'label' => __( 'System Check', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-system-check' ) ); $tabs['safety-check'] = array( 'label' => __( 'Environment & storage', 'ai-o-optimiser' ), 'url' => admin_url( 'admin.php?page=ai-o-optimiser-environment-check' ) ); AI_O_Optimiser_Settings_Navigation::render( $active_slug, $tabs ); } /** * Load the plugin's small admin stylesheet, only on our own screens. * * @param string $hook Current admin page hook. */ public function enqueue_assets( $hook ) { if ( strpos( $hook, 'ai-o-optimiser-' ) === false ) { return; } $css_path = AI_O_Optimiser_PLUGIN_DIR . 'assets/admin.css'; $css_ver = file_exists( $css_path ) ? AI_O_Optimiser_VERSION . '.' . filemtime( $css_path ) : AI_O_Optimiser_VERSION; wp_enqueue_style( 'ai-o-optimiser-admin', AI_O_Optimiser_PLUGIN_URL . 'assets/admin.css', array(), $css_ver ); $consistency_path = AI_O_Optimiser_PLUGIN_DIR . 'assets/admin-modules.css'; $consistency_ver = file_exists( $consistency_path ) ? AI_O_Optimiser_VERSION . '.' . filemtime( $consistency_path ) : AI_O_Optimiser_VERSION; wp_enqueue_style( 'ai-o-optimiser-admin-modules', AI_O_Optimiser_PLUGIN_URL . 'assets/admin-modules.css', array( 'ai-o-optimiser-admin' ), $consistency_ver ); $guard_path = AI_O_Optimiser_PLUGIN_DIR . 'assets/admin-notice-guard.js'; $guard_ver = file_exists( $guard_path ) ? AI_O_Optimiser_VERSION . '.' . filemtime( $guard_path ) : AI_O_Optimiser_VERSION; wp_enqueue_script( 'ai-o-optimiser-admin-notice-guard', AI_O_Optimiser_PLUGIN_URL . 'assets/admin-notice-guard.js', array(), $guard_ver, true ); // Hover and keyboard focus on the signal "?" explainers are pure CSS. This // adds only tap-to-toggle and Escape-to-dismiss, so the definitions still // work with the script blocked. $hints_path = AI_O_Optimiser_PLUGIN_DIR . 'assets/admin-signal-hints.js'; $hints_ver = file_exists( $hints_path ) ? AI_O_Optimiser_VERSION . '.' . filemtime( $hints_path ) : AI_O_Optimiser_VERSION; wp_enqueue_script( 'ai-o-optimiser-admin-signal-hints', AI_O_Optimiser_PLUGIN_URL . 'assets/admin-signal-hints.js', array(), $hints_ver, true ); /* * A dependency-free handle that exists only to carry inline scripts. * * Several screens need a few lines of behaviour that depend on a * translated string or on which page is being rendered, so they cannot * be a static .js file. They used to be printed as raw