$grouped Grouped abilities from AbilitiesRegistry. * * @return array Filtered grouped abilities. * @since 1.0.0 */ abstract protected function filter_abilities( array $grouped ): array; /** * Register WordPress hooks. * * @return void * @since 1.0.0 */ public function register_hooks(): void { add_action( 'admin_menu', [ $this, 'add_menu_pages' ] ); add_action( 'admin_init', [ $this, 'register_settings' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); } /** * Add menu pages. * * @return void * @since 1.0.0 */ public function add_menu_pages(): void { add_submenu_page( 'albert', $this->get_page_title(), $this->get_menu_title(), 'manage_options', $this->get_page_slug(), [ $this, 'render_page' ] ); } /** * Register plugin settings. * * @return void * @since 1.0.0 */ public function register_settings(): void { register_setting( self::OPTION_GROUP, self::OPTION_NAME, [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_settings' ], 'default' => [], ] ); } /** * Render the page. * * @return void * @since 1.0.0 */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'albert-ai-butler' ) ); } // Require WP 6.9+ Abilities API. if ( ! function_exists( 'wp_get_abilities' ) ) { ?>

filter_abilities( $grouped ); // Filter out empty categories. $grouped = array_filter( $grouped, function ( $data ) { return ! empty( $data['abilities'] ); } ); // Sort by predefined order. $grouped = AbilitiesRegistry::sort_grouped_categories( $grouped ); $disabled_abilities = self::get_disabled_abilities(); ?>

render_mobile_nav( $grouped, $disabled_abilities ); ?>
render_sidebar( $grouped, $disabled_abilities ); ?>
render_abilities_content( $grouped, $disabled_abilities ); ?>
$grouped Grouped abilities data. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_sidebar( array $grouped, array $disabled_abilities ): void { if ( empty( $grouped ) ) { return; } ?> $grouped Grouped abilities data. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_mobile_nav( array $grouped, array $disabled_abilities ): void { if ( empty( $grouped ) ) { return; } ?> 'dashicons-admin-site', 'user' => 'dashicons-admin-users', 'content' => 'dashicons-admin-post', 'taxonomy' => 'dashicons-category', 'comments' => 'dashicons-admin-comments', 'commerce' => 'dashicons-cart', 'woo-products' => 'dashicons-products', 'woo-orders' => 'dashicons-clipboard', 'woo-customers' => 'dashicons-groups', 'seo' => 'dashicons-search', 'fields' => 'dashicons-editor-table', 'forms' => 'dashicons-feedback', 'lms' => 'dashicons-welcome-learn-more', 'maintenance' => 'dashicons-admin-tools', ]; /** * Filters the category icon mapping. * * @since 1.0.0 * * @param array $icons Map of category slug to dashicon class. */ $icons = apply_filters( 'albert/abilities_icons', $icons ); return $icons[ $category ] ?? 'dashicons-admin-generic'; } /** * Render abilities content. * * @param array $grouped Grouped abilities data. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_abilities_content( array $grouped, array $disabled_abilities ): void { ?>

render_collapse_preload_script(); ?>
$data ) { ?> render_category_section( $slug, $data, $disabled_abilities ); ?>

$category_data Category data with 'category' and 'abilities'. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_category_section( string $slug, array $category_data, array $disabled_abilities ): void { $category = $category_data['category']; $abilities = $category_data['abilities']; $card_id = 'category-' . sanitize_key( $slug ); $items_id = 'category-items-' . sanitize_key( $slug ); $toggle_id = 'toggle-all-' . sanitize_key( $slug ); $icon = $this->get_category_icon( $slug ); $label = is_object( $category ) && method_exists( $category, 'get_label' ) ? $category->get_label() : ( $category['label'] ?? ucfirst( $slug ) ); $description = is_object( $category ) && method_exists( $category, 'get_description' ) ? $category->get_description() : ( $category['description'] ?? '' ); // Get the predefined content type groupings. $content_types = $this->get_content_types_for_category( $slug, $abilities ); if ( empty( $content_types ) ) { return; } // Single content type: promote Read/Write toggles to card header, // render abilities directly in the card body (no intermediate row). if ( 1 === count( $content_types ) ) { $this->render_single_type_section( $slug, $card_id, $items_id, $icon, $label, $description, reset( $content_types ), $disabled_abilities ); return; } ?>

$type_data ) { ?> render_content_type_row( $slug, $type_key, $type_data, $disabled_abilities ); ?>
$type_data The single content type data. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_single_type_section( string $slug, string $card_id, string $items_id, string $icon, string $label, string $description, array $type_data, array $disabled_abilities ): void { $read_abilities = $type_data['read_abilities']; $write_abilities = $type_data['write_abilities']; $read_id = $card_id . '-read'; $write_id = $card_id . '-write'; $has_read = ! empty( $read_abilities ); $has_write = ! empty( $write_abilities ); $read_ability_names = $has_read ? array_values( array_map( 'strval', array_keys( $read_abilities ) ) ) : []; $write_ability_names = $has_write ? array_values( array_map( 'strval', array_keys( $write_abilities ) ) ) : []; $read_enabled = $has_read && $this->are_all_abilities_enabled( $read_ability_names, $disabled_abilities ); $write_enabled = $has_write && $this->are_all_abilities_enabled( $write_ability_names, $disabled_abilities ); ?>

render_ability_item( $ability, $disabled_abilities, 'read', $read_id ); } if ( $has_both_modes ) { ?>
render_ability_item( $ability, $disabled_abilities, 'write', $write_id ); } ?>
$abilities Abilities in this category. * * @return array> Content types with their abilities. * @since 1.0.0 */ private function get_content_types_for_category( string $category_slug, array $abilities ): array { // Get predefined groupings from AbilitiesRegistry. $groups = AbilitiesRegistry::get_ability_groups(); // Build a lookup of ability name to ability object. $ability_lookup = []; foreach ( $abilities as $ability ) { $name = is_object( $ability ) && method_exists( $ability, 'get_name' ) ? $ability->get_name() : ''; if ( $name ) { $ability_lookup[ $name ] = $ability; } } $content_types = []; // Check each group for matching abilities. foreach ( $groups as $group ) { if ( ! isset( $group['types'] ) ) { continue; } foreach ( $group['types'] as $type_key => $type_data ) { $type_label = $type_data['label'] ?? ucfirst( $type_key ); $read_abilities = []; $write_abilities = []; // Collect read abilities (store full objects for details). if ( isset( $type_data['read']['abilities'] ) ) { foreach ( $type_data['read']['abilities'] as $ability_name ) { if ( isset( $ability_lookup[ $ability_name ] ) ) { $read_abilities[ $ability_name ] = $ability_lookup[ $ability_name ]; } } } // Collect write abilities (store full objects for details). if ( isset( $type_data['write']['abilities'] ) ) { foreach ( $type_data['write']['abilities'] as $ability_name ) { if ( isset( $ability_lookup[ $ability_name ] ) ) { $write_abilities[ $ability_name ] = $ability_lookup[ $ability_name ]; } } } // Only add if we found at least one ability. if ( ! empty( $read_abilities ) || ! empty( $write_abilities ) ) { $content_types[ $type_key ] = [ 'label' => $type_label, 'read_abilities' => $read_abilities, 'write_abilities' => $write_abilities, 'category' => $category_slug, ]; } } } // Handle any abilities not in predefined groups (core, third-party). $grouped_abilities = []; foreach ( $content_types as $type_data ) { $grouped_abilities = array_merge( $grouped_abilities, array_keys( $type_data['read_abilities'] ), array_keys( $type_data['write_abilities'] ) ); } $ungrouped = array_diff( array_keys( $ability_lookup ), $grouped_abilities ); foreach ( $ungrouped as $ability_name ) { $ability = $ability_lookup[ $ability_name ]; $label = method_exists( $ability, 'get_label' ) ? $ability->get_label() : $ability_name; // Create a unique key from the ability name. $type_key = sanitize_key( str_replace( '/', '-', $ability_name ) ); // Determine if read or write. if ( $this->is_write_ability( $ability_name ) ) { $content_types[ $type_key ] = [ 'label' => $label, 'read_abilities' => [], 'write_abilities' => [ $ability_name => $ability ], 'category' => $category_slug, ]; } else { $content_types[ $type_key ] = [ 'label' => $label, 'read_abilities' => [ $ability_name => $ability ], 'write_abilities' => [], 'category' => $category_slug, ]; } } return $content_types; } /** * Get tooltip text for a source type badge. * * @param string $source_type Source type identifier. * * @return string Tooltip text. * @since 1.0.0 */ private function get_source_tooltip( string $source_type ): string { $tooltips = [ 'core' => __( 'Built into WordPress core', 'albert-ai-butler' ), 'albert' => __( 'Provided by the Albert plugin', 'albert-ai-butler' ), 'third-party' => __( 'Provided by a third-party plugin', 'albert-ai-butler' ), ]; return $tooltips[ $source_type ] ?? ''; } /** * Determine the primary source for a content type based on its abilities. * * @param array $read_abilities Read abilities. * @param array $write_abilities Write abilities. * * @return array{type: string, label: string} Source info. * @since 1.0.0 */ private function get_content_type_source( array $read_abilities, array $write_abilities ): array { $all_abilities = array_merge( array_keys( $read_abilities ), array_keys( $write_abilities ) ); if ( empty( $all_abilities ) ) { return [ 'type' => 'unknown', 'label' => '', ]; } // Use the first ability to determine source. return AbilitiesRegistry::get_ability_source( $all_abilities[0] ); } /** * Render a content type row with Read and Write toggles. * * @param string $category_slug Parent category slug. * @param string $type_key Content type key (e.g., 'posts', 'pages'). * @param array $type_data Content type data. * @param array $disabled_abilities Currently disabled ability slugs. * * @return void * @since 1.0.0 */ private function render_content_type_row( string $category_slug, string $type_key, array $type_data, array $disabled_abilities ): void { $label = $type_data['label']; $read_abilities = $type_data['read_abilities']; $write_abilities = $type_data['write_abilities']; $row_id = 'type-' . sanitize_key( $category_slug . '-' . $type_key ); $details_id = $row_id . '-details'; $read_id = $row_id . '-read'; $write_id = $row_id . '-write'; $has_read = ! empty( $read_abilities ); $has_write = ! empty( $write_abilities ); $read_ability_names = $has_read ? array_values( array_map( 'strval', array_keys( $read_abilities ) ) ) : []; $write_ability_names = $has_write ? array_values( array_map( 'strval', array_keys( $write_abilities ) ) ) : []; $read_enabled = $has_read && $this->are_all_abilities_enabled( $read_ability_names, $disabled_abilities ); $write_enabled = $has_write && $this->are_all_abilities_enabled( $write_ability_names, $disabled_abilities ); // Get source badge info. $source = $this->get_content_type_source( $read_abilities, $write_abilities ); // Determine if this row has sub-items (more than 1 ability total). $total_abilities = count( $read_abilities ) + count( $write_abilities ); $has_sub_items = $total_abilities > 1; ?>
$disabled_abilities Currently disabled ability slugs. * @param string $mode Mode ('read' or 'write'). * @param string $group_checkbox_id ID of the parent group checkbox. * * @return void * @since 1.0.0 */ private function render_ability_item( object $ability, array $disabled_abilities, string $mode, string $group_checkbox_id ): void { $name = method_exists( $ability, 'get_name' ) ? $ability->get_name() : ''; $label = method_exists( $ability, 'get_label' ) ? $ability->get_label() : $name; $description = method_exists( $ability, 'get_description' ) ? $ability->get_description() : ''; $category = method_exists( $ability, 'get_category' ) ? $ability->get_category() : ''; $is_enabled = ! in_array( $name, $disabled_abilities, true ); $field_id = 'ability-' . sanitize_key( str_replace( '/', '-', $name ) ); $source = AbilitiesRegistry::get_ability_source( $name ); ?>

$abilities List of ability names. * @param array $disabled_abilities Currently disabled ability slugs. * * @return bool True if all abilities are enabled. * @since 1.0.0 */ private function are_all_abilities_enabled( array $abilities, array $disabled_abilities ): bool { foreach ( $abilities as $ability_name ) { if ( in_array( $ability_name, $disabled_abilities, true ) ) { return false; } } return true; } /** * Determine if an ability is a write (mutating) ability based on its slug. * * @param string $ability_name Ability name/slug. * * @return bool True if this is a write ability. * @since 1.0.0 */ private function is_write_ability( string $ability_name ): bool { // Extract the action part after the namespace prefix. $parts = explode( '/', $ability_name, 2 ); $action = $parts[1] ?? $parts[0]; $write_prefixes = [ 'create-', 'update-', 'delete-', 'upload-', 'set-' ]; foreach ( $write_prefixes as $prefix ) { if ( str_starts_with( $action, $prefix ) ) { return true; } } return false; } /** * Sanitize settings. * * Computes the disabled abilities list from what was presented on the page * versus what was checked (enabled). Abilities not on the page are preserved * in their current state. * * @param mixed $input Raw input (hidden trigger field value, not used directly). * * @return array Sanitized array of disabled ability slugs. * @since 1.0.0 */ public function sanitize_settings( $input ): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by settings API; sanitized below with array_map. $presented_raw = isset( $_POST['albert_presented_abilities'] ) ? wp_unslash( $_POST['albert_presented_abilities'] ) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by settings API; sanitized below with array_map. $enabled_raw = isset( $_POST['albert_enabled_on_page'] ) ? wp_unslash( $_POST['albert_enabled_on_page'] ) : []; $presented = array_map( 'sanitize_text_field', (array) $presented_raw ); $enabled = array_map( 'sanitize_text_field', (array) $enabled_raw ); // Validate slugs. $presented = array_filter( $presented, [ $this, 'is_valid_ability_slug' ] ); $enabled = array_filter( $enabled, [ $this, 'is_valid_ability_slug' ] ); // Abilities on this page that were unchecked = newly disabled. $newly_disabled = array_diff( $presented, $enabled ); // Get existing disabled list — sanitize and validate stored values. $existing_disabled = get_option( self::OPTION_NAME, [] ); if ( ! is_array( $existing_disabled ) ) { $existing_disabled = []; } $existing_disabled = array_map( 'sanitize_text_field', $existing_disabled ); $existing_disabled = array_filter( $existing_disabled, [ $this, 'is_valid_ability_slug' ] ); // Remove from disabled list anything that was on this page and is now enabled. $existing_disabled = array_diff( $existing_disabled, $enabled ); // Merge with newly disabled. $disabled = array_values( array_unique( array_merge( $existing_disabled, $newly_disabled ) ) ); // Mark that abilities have been saved at least once. update_option( 'albert_abilities_saved', true ); return $disabled; } /** * Get currently disabled abilities. * * @return array Array of disabled ability slugs. * @since 1.0.0 */ public static function get_disabled_abilities(): array { $disabled = get_option( 'albert_disabled_abilities', [] ); // On fresh install, use default disabled list (Albert write abilities). if ( empty( $disabled ) && ! get_option( 'albert_abilities_saved' ) ) { return AbilitiesRegistry::get_default_disabled_abilities(); } return (array) $disabled; } /** * Check if ability slug is valid. * * Accepts any slug in the format namespace/ability-name. * * @param string $ability_slug Ability slug. * * @return bool * @since 1.0.0 */ private function is_valid_ability_slug( string $ability_slug ): bool { return (bool) preg_match( '/^[a-z0-9_-]+\/[a-z0-9_-]+$/', $ability_slug ); } /** * Enqueue admin assets. * * @param string $hook Current admin page hook. * * @return void * @since 1.0.0 */ public function enqueue_assets( string $hook ): void { if ( 'albert_page_' . $this->get_page_slug() !== $hook ) { return; } wp_enqueue_style( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/css/admin-settings.css', [], ALBERT_VERSION ); wp_enqueue_script( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/js/admin-settings.js', [], ALBERT_VERSION, true ); wp_register_script( 'albert-collapse-preload', ALBERT_PLUGIN_URL . 'assets/js/admin-collapse-preload.js', [], ALBERT_VERSION, false ); } }