with a real admin URL, and the open one carries * aria-current="page" printed by the server. That is the admin shell contract * in ECOSYSTEM.md: navigation that a blocked, stripped or simply slow script * cannot break. Nothing here listens for a click. * * THE COUNTS * * An item may carry a number on its right. It is the same number the * workspace's own landing card shows, recorded by that landing when it renders * (see remember()) rather than recomputed here -- the rail is on thirteen * screens and must not run three workspaces' queries on each one. It counts * outstanding work and goes to nothing when there is none, which is the * measure/state distinction in CARD-LANDING-DECISIONS.md ยง3: a number that * only ever goes up is a score, and a score on a navigation item is noise. * * @package AI_O_Optimiser */ if ( ! defined( 'ABSPATH' ) ) { exit; } class AI_O_Optimiser_Admin_Rail { const HANDLE = 'ai-o-optimiser-admin-rail'; /** * Where the workspaces leave their outstanding-work counts for the rail. * * Autoloaded and tiny: one integer and one already-translated label per * workspace, at most three entries. Reading it is the whole cost of showing * a count on a screen that has nothing to do with the workspace it counts. */ const COUNTS_OPTION = 'ai_o_optimiser_rail_counts'; /** Register the rail on AI-O admin screens. */ public static function boot() { add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) ); add_filter( 'admin_body_class', array( __CLASS__, 'body_class' ) ); /* * Priority 0, ahead of the shell at 1, so the rail is the first child of * #wpbody-content. It is floated, and a float only clears the content * beside it if it comes first in source order. */ add_action( 'admin_notices', array( __CLASS__, 'render' ), 0 ); } /** * Every AI-O admin page, and the rail item each one belongs to. * * Hidden detail screens map to the item that owns them, so the rail never * goes blank on a page that is plainly part of a section: the logs and the * safety check are Health & checks, the metadata workflow is Metadata. * * @return array */ public static function page_map() { return 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-local-discovery' => 'local', 'ai-o-optimiser-guidance' => 'guidance', 'ai-o-optimiser-settings' => 'settings', /* * Notifications is printed in the Health & checks row alongside * System Check and Environment & storage, so it belongs to that rail * item. It mapped to Settings, which meant one row had two different * parents depending on which of its screens was open -- the rail * highlighted Configure > Settings while the row around it was * Health's. */ 'ai-o-optimiser-notifications' => 'health', 'ai-o-optimiser-system-check' => 'health', 'ai-o-optimiser-environment-check' => 'health', 'ai-o-optimiser-logs' => 'health', 'ai-o-optimiser-deep-review-prompt' => 'health', ); } /** * The rail, in order. * * Every item names the page it opens, and that page must appear in * page_map() or the rail would send a reader somewhere it cannot then * highlight. tests/admin-rail.php holds the two together. * * Icons are dashicons the admin shell already loads. A second icon system for * the same screens is ruled out by the shell contract in AGENTS.md, which is * also why there is no inline SVG here -- and every name below is one the * plugin already used somewhere else before this rail existed, so none of * them can be a name Dashicons does not have. A missing dashicon does not * fail: it renders as blank space, which is the kind of defect that ships. * * @return array */ public static function groups() { $sensitive = defined( 'AI_O_Optimiser_SENSITIVE_CAPABILITY' ) ? AI_O_Optimiser_SENSITIVE_CAPABILITY : 'manage_options'; $dashboard = class_exists( 'AI_O_Optimiser_Admin' ) ? AI_O_Optimiser_Admin::dashboard_capability() : $sensitive; return array( array( 'title' => __( 'Measure', 'ai-o-optimiser' ), 'items' => array( 'dashboard' => array( 'label' => __( 'Visibility Dash', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-dashboard', 'icon' => 'dashicons-chart-line', 'capability' => $dashboard, ), ), ), array( 'title' => __( 'Fix', 'ai-o-optimiser' ), 'items' => array( 'metadata' => array( 'label' => __( 'Metadata', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-optimise', 'icon' => 'dashicons-edit', 'capability' => $sensitive, ), 'schema' => array( 'label' => __( 'Schema', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-schema-review', 'icon' => 'dashicons-list-view', 'capability' => $sensitive, ), /* * "NAP" on the rail and in the WordPress * submenu. The submenu label is the one the directory review * and every existing bookmark know; the rail is new copy and * says what the section is for. The URL is the same and does * not change -- see tests/primary-navigation.php. */ 'local' => array( 'label' => __( 'NAP', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-local-discovery', 'icon' => 'dashicons-location', 'capability' => $sensitive, ), ), ), array( 'title' => __( 'Publish', 'ai-o-optimiser' ), 'items' => array( 'guidance' => array( 'label' => __( 'AI Guidance Files', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-guidance', 'icon' => 'dashicons-media-document', 'capability' => $sensitive, ), ), ), array( 'title' => __( 'Configure', 'ai-o-optimiser' ), 'items' => array( 'settings' => array( 'label' => __( 'Settings', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-settings', 'icon' => 'dashicons-admin-generic', 'capability' => $sensitive, ), 'health' => array( 'label' => __( 'Health & checks', 'ai-o-optimiser' ), 'page' => 'ai-o-optimiser-system-check', 'icon' => 'dashicons-shield', 'capability' => $sensitive, ), ), ), ); } /** The item the current request belongs to, or '' when it is not an AI-O screen. */ public static function active_item() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin routing only; nothing is written here. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $map = self::page_map(); return isset( $map[ $page ] ) ? $map[ $page ] : ''; } /** * Whether this site is running only the Free/core build. * * AI_O_Optimiser_Pro_Features exists only in the carved build -- it is what * the three removed workspaces are re-pointed at. Its presence used to be * the whole answer; now the carved build is also the core the Pro add-on * installs beside, so the add-on having registered any of the three * workspaces with core's registry is what makes a site Pro. */ public static function is_free_edition() { if ( ! class_exists( 'AI_O_Optimiser_Pro_Features' ) ) { return false; } if ( ! class_exists( 'AI_O_Optimiser_Extensions' ) ) { return true; } foreach ( array( 'metadata', 'schema', 'local' ) as $key ) { if ( AI_O_Optimiser_Extensions::is_available( $key ) ) { return false; } } return true; } /** * Record a workspace's outstanding-work count for the rail. * * Called by the workspace landings, which have already done the counting. * Writes only on a change, so the ordinary case of opening a workspace whose * state has not moved costs one option read and no write at all. * * @param string $key Rail item key: metadata, schema or local. * @param int $count Outstanding items. Zero clears the badge. * @param string $label Already-translated phrase saying what the number is, * for screen readers and the item's title attribute. */ public static function remember( $key, $count, $label = '' ) { $key = sanitize_key( (string) $key ); $count = max( 0, (int) $count ); if ( '' === $key ) { return; } $stored = self::stored_counts(); $now = array( 'count' => $count, 'label' => (string) $label ); if ( isset( $stored[ $key ] ) && $stored[ $key ] === $now ) { return; } $stored[ $key ] = $now; update_option( self::COUNTS_OPTION, $stored, true ); } /** The recorded counts, normalised. */ private static function stored_counts() { $stored = get_option( self::COUNTS_OPTION, array() ); if ( ! is_array( $stored ) ) { return array(); } $clean = array(); foreach ( $stored as $key => $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['count'] ) ) { continue; } $clean[ sanitize_key( (string) $key ) ] = array( 'count' => max( 0, (int) $entry['count'] ), 'label' => isset( $entry['label'] ) ? (string) $entry['label'] : '', ); } return $clean; } /** * Bring the open workspace's own count up to date before the rail prints it. * * Only the workspace whose screen this is. Each of these reads its own state * -- one option for Schema, one grouped query for Metadata, one sources read * for Business details -- and running all three on all thirteen screens is * the cost this design exists to avoid. The other two show what they last * recorded, which is what they were when the reader last had them open. * * The effect is that the number beside the section you are working in is * live: fix nine schema conflicts, reload, and the rail says twenty rather * than twenty-nine without you having to go back to the landing for it. * * @param string $active Rail item key for the current screen. */ private static function refresh( $active ) { if ( self::is_free_edition() ) { return; } if ( 'schema' === $active && class_exists( 'AI_O_Optimiser_Schema_Review', false ) ) { AI_O_Optimiser_Schema_Review::record_rail_count(); return; } if ( 'local' === $active && class_exists( 'AI_O_Optimiser_Local_Discovery', false ) ) { AI_O_Optimiser_Local_Discovery::record_rail_count(); return; } if ( 'metadata' === $active && class_exists( 'AI_O_Optimiser_Metadata_Workflow', false ) ) { AI_O_Optimiser_Metadata_Workflow::instance()->record_rail_count(); } } /** * The counts the rail may show on this request. * * Free shows none. The three counting workspaces are not in that build, so * anything left in the option is from a paid install this site used to run, * and a number nothing can update is worse than no number. */ public static function counts() { return self::is_free_edition() ? array() : self::stored_counts(); } /** Mark the body so the layout rules apply only where the rail actually printed. */ public static function body_class( $classes ) { if ( '' === self::active_item() ) { return $classes; } return trim( $classes . ' ai-o-optimiser-has-rail' ); } /** Load the rail stylesheet on AI-O screens only. */ public static function enqueue() { if ( '' === self::active_item() ) { return; } $path = AI_O_Optimiser_PLUGIN_DIR . 'assets/admin-rail.css'; $ver = file_exists( $path ) ? AI_O_Optimiser_VERSION . '.' . filemtime( $path ) : AI_O_Optimiser_VERSION; wp_enqueue_style( self::HANDLE, AI_O_Optimiser_PLUGIN_URL . 'assets/admin-rail.css', array(), $ver ); wp_enqueue_style( 'dashicons' ); } /** Print the rail. */ public static function render() { $active = self::active_item(); if ( '' === $active ) { return; } self::refresh( $active ); $counts = self::counts(); /* * One word, and the one the product is sold under. 1.37.0 printed "Free * edition" / "Paid edition" as grey caption text under the name, and the * first person to install both could not tell which was which: the paid * build was called "AI-O Optimiser" on the Plugins screen -- the same as * the WordPress.org submission build -- and the only other tell was a tab * several clicks into Settings that the carved build does not have. The * paid header says Pro as of 1.37.1 and this says the same word, as a * badge rather than a caption. */ $free = self::is_free_edition(); $edition = $free ? __( 'Free', 'ai-o-optimiser' ) : __( 'Pro', 'ai-o-optimiser' ); ?>