__( 'Insufficient permissions.', 'albert-ai-butler' ) ], 403 ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_text_field() below. $ability_id = sanitize_text_field( wp_unslash( (string) ( $_POST['ability_id'] ?? '' ) ) ); // Accept the explicit string forms the JS sends ("1" / "true"); anything // else is treated as disable. Doesn't rely on PHP truthy coercion so // "false" or "0" reach us as the user expects. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared to literals below. $enabled_raw = isset( $_POST['enabled'] ) ? wp_unslash( (string) $_POST['enabled'] ) : ''; $enabled = $enabled_raw === '1' || $enabled_raw === 'true'; if ( ! $this->is_valid_ability_slug( $ability_id ) ) { wp_send_json_error( [ 'message' => __( 'Invalid ability id.', 'albert-ai-butler' ) ], 400 ); } $disabled = self::get_disabled_abilities(); // Re-sanitize stored values defensively before mutating. $disabled = array_filter( array_map( 'sanitize_text_field', $disabled ), [ $this, 'is_valid_ability_slug' ] ); if ( $enabled ) { $disabled = array_values( array_diff( $disabled, [ $ability_id ] ) ); } elseif ( ! in_array( $ability_id, $disabled, true ) ) { $disabled[] = $ability_id; } update_option( self::DISABLED_ABILITIES_OPTION, $disabled ); update_option( 'albert_abilities_saved', true ); wp_send_json_success( [ 'ability_id' => $ability_id, 'enabled' => $enabled, ] ); } /** * AJAX handler that persists the view-mode preference. * * Called by the view toggle in the toolbar; returns success silently. * The capability check matches the abilities page itself. * * @return void * @since 1.1.0 */ public function ajax_save_view_mode(): void { check_ajax_referer( 'albert_view_mode', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'albert-ai-butler' ) ], 403 ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_key() on the next line. $mode = self::normalize_view_mode( sanitize_key( wp_unslash( (string) ( $_POST['mode'] ?? '' ) ) ) ); update_option( self::VIEW_MODE_OPTION, $mode, false ); wp_send_json_success( [ 'mode' => $mode ] ); } /** * Get the persisted view-mode preference, defaulting to "list". * * @return string Either `list` or `paginated`. * @since 1.1.0 */ public static function get_view_mode(): string { return self::normalize_view_mode( get_option( self::VIEW_MODE_OPTION, 'list' ) ); } /** * Normalize an arbitrary string to a valid view mode. * * Anything that isn't `paginated` collapses to `list` so an unexpected * value (corrupted option, bad AJAX payload) never breaks rendering. * * @param string $mode Raw mode string. * * @return string Either `list` or `paginated`. * @since 1.1.0 */ private static function normalize_view_mode( string $mode ): string { return $mode === 'paginated' ? 'paginated' : 'list'; } /** * Register the submenu page under Albert. * * @return void * @since 1.1.0 */ public function add_menu_page(): void { add_submenu_page( 'albert', __( 'Abilities', 'albert-ai-butler' ), __( 'Abilities', 'albert-ai-butler' ), 'manage_options', self::PAGE_SLUG, [ $this, 'render_page' ] ); } /** * Render the admin page. * * @return void * @since 1.1.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' ) ); } // WP 6.9+ Abilities API required. if ( ! function_exists( 'wp_get_abilities' ) ) { ?>

! in_array( $row['id'], $disabled_abilities, true ) ) ); // Pre-fetch log data for all abilities in one query to avoid N+1. $ability_ids = array_column( $abilities, 'id' ); $logging_repo = new LoggingRepository(); $ability_log_map = $logging_repo->latest_bulk( $ability_ids ); ?>

render_toolbar( $categories, $suppliers, $annotations, $enabled_count, $total_count, $view_mode ); ?>
$row ) { ?> = self::ROWS_PER_PAGE ); ?> render_ability_row( $row, $disabled_abilities, $ability_log_map, $pre_hidden ); ?>
$categories Category slug => label. * @param array $suppliers Supplier slug => label. * @param array $annotations Annotation slug => label. * @param int $enabled_count Number of currently-enabled abilities. * @param int $total_count Total ability count. * @param string $view_mode Current view mode (`list` or `paginated`). * * @return void * @since 1.1.0 */ private function render_toolbar( array $categories, array $suppliers, array $annotations, int $enabled_count, int $total_count, string $view_mode ): void { $is_paginated = $view_mode === 'paginated'; ?>

data-template-all="" data-total="" data-enabled-count="" >

$row Normalized ability row data. * @param array $disabled_abilities List of disabled ability ids. * @param array $ability_log_map Map of ability_name => latest log row. * @param bool $pre_hidden Whether to pre-render the row hidden * (used for paginated view to avoid a * flash of all rows before JS runs). * * @return void * @since 1.1.0 */ private function render_ability_row( array $row, array $disabled_abilities, array $ability_log_map, bool $pre_hidden = false ): void { $id = $row['id']; $label = $row['label']; $description = $row['description']; $category_slug = $row['category_slug']; $category_lbl = $row['category_label']; $supplier_slug = $row['supplier_slug']; $supplier_lbl = $row['supplier_label']; $annotations = $row['annotations']; $annotation_slug = $row['annotation_slug']; $chips = AnnotationPresenter::chips_for( $annotations, $id ); $is_destruct = AnnotationPresenter::is_destructive( $annotations, $id ); $is_enabled = ! in_array( $id, $disabled_abilities, true ); $dom_id = 'albert-ability-' . sanitize_html_class( str_replace( '/', '-', $id ) ); $details_id = $dom_id . '-details'; $toggle_id = $dom_id . '-toggle'; $search_haystack = strtolower( $label . ' ' . $description . ' ' . $id ); ?>
hidden >

$ability_log_map Map of ability_name => latest log row. * * @return void * @since 1.1.0 */ private function render_log_line( string $ability_id, array $ability_log_map ): void { if ( ! isset( $ability_log_map[ $ability_id ] ) ) { ?> user_id ); $display_name = $user ? $user->display_name : __( 'Unknown user', 'albert-ai-butler' ); $time_diff = human_time_diff( (int) $log->created_ts, time() ); $is_error = isset( $log->status ) && $log->status === 'error'; if ( $is_error ) { $error_code = isset( $log->error_code ) && $log->error_code !== '' ? $log->error_code : null; ?> ' . esc_html( $display_name ) . '', '' . esc_html( $error_code ) . '' ); } else { printf( /* translators: 1: human time diff, 2: user display name. */ esc_html__( '%1$s ago by %2$s', 'albert-ai-butler' ), esc_html( $time_diff ), '' . esc_html( $display_name ) . '' ); } ?> ' . esc_html( $display_name ) . '' ); ?> > * @since 1.1.0 */ private static function collect_abilities(): array { $all = wp_get_abilities(); $categories = wp_get_ability_categories(); $rows = []; foreach ( $all as $ability ) { $id = $ability->get_name(); $meta = (array) $ability->get_meta(); $source = AbilitiesRegistry::get_ability_source( $id ); $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : []; $chips = AnnotationPresenter::chips_for( $annotations, $id ); $annotation_slug = ! empty( $chips ) ? $chips[0]['key'] : ''; $rows[] = [ 'id' => $id, 'label' => $ability->get_label(), 'description' => $ability->get_description(), 'category_slug' => $ability->get_category(), 'category_label' => self::resolve_category_label( $ability->get_category(), $categories ), 'supplier_slug' => $source['slug'], 'supplier_label' => $source['label'], 'annotations' => $annotations, 'annotation_slug' => $annotation_slug, 'annotation_label' => self::resolve_annotation_label( $annotation_slug ), ]; } usort( $rows, static function ( array $a, array $b ): int { $cat = strcasecmp( $a['category_label'], $b['category_label'] ); if ( $cat !== 0 ) { return $cat; } return strcasecmp( $a['label'], $b['label'] ); } ); return $rows; } /** * Build a deduplicated, sorted map of filter dropdown options. * * Used for both the category and supplier filter dropdowns. Extracts * unique slug → label pairs from the collected rows, skipping empty * slugs, and sorts alphabetically by label. * * @param array> $abilities Collected rows. * @param string $slug_key Row key for the slug value. * @param string $label_key Row key for the label value. * * @return array * @since 1.1.0 */ private static function collect_filter_options( array $abilities, string $slug_key, string $label_key ): array { $options = []; foreach ( $abilities as $row ) { $slug = $row[ $slug_key ]; if ( $slug !== '' && ! isset( $options[ $slug ] ) ) { $options[ $slug ] = $row[ $label_key ]; } } asort( $options, SORT_NATURAL | SORT_FLAG_CASE ); return $options; } /** * Resolve a category slug to its human label. * * @param string $slug Category slug. * @param array $categories Map from wp_get_ability_categories(). * * @return string * @since 1.1.0 */ private static function resolve_category_label( string $slug, array $categories ): string { if ( $slug === '' ) { return ''; } if ( isset( $categories[ $slug ] ) ) { $category = $categories[ $slug ]; if ( is_object( $category ) && method_exists( $category, 'get_label' ) ) { return (string) $category->get_label(); } if ( is_array( $category ) && isset( $category['label'] ) ) { return (string) $category['label']; } } return ucfirst( str_replace( [ '-', '_' ], ' ', $slug ) ); } /** * Resolve an annotation slug to its human label. * * @param string $slug Annotation slug (read, write, delete). * * @return string * @since 1.1.0 */ private static function resolve_annotation_label( string $slug ): string { $labels = [ 'read' => __( 'Read', 'albert-ai-butler' ), 'write' => __( 'Write', 'albert-ai-butler' ), 'delete' => __( 'Delete', 'albert-ai-butler' ), ]; return $labels[ $slug ] ?? ucfirst( $slug ); } /** * Get currently disabled abilities. * * On fresh install returns the default blocklist (Albert write abilities). * * @return array * @since 1.1.0 */ public static function get_disabled_abilities(): array { $disabled = get_option( self::DISABLED_ABILITIES_OPTION, [] ); if ( empty( $disabled ) && ! get_option( 'albert_abilities_saved' ) ) { return AbilitiesRegistry::get_default_disabled_abilities(); } return (array) $disabled; } /** * Validate an ability slug. * * @param string $ability_slug Slug to validate. * * @return bool * @since 1.1.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 for this page only. * * @param string $hook Current admin page hook. * * @return void * @since 1.1.0 */ public function enqueue_assets( string $hook ): void { if ( 'albert_page_' . self::PAGE_SLUG !== $hook ) { return; } wp_enqueue_style( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/css/admin-settings.css', [], ALBERT_VERSION ); wp_enqueue_script( 'albert-admin-utils', ALBERT_PLUGIN_URL . 'assets/js/albert-admin-utils.js', [], ALBERT_VERSION, true ); wp_enqueue_script( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/js/admin-settings.js', [ 'albert-admin-utils' ], ALBERT_VERSION, true ); wp_localize_script( 'albert-admin', 'albertAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'toggleAbilityNonce' => wp_create_nonce( 'albert_toggle_ability' ), 'viewModeNonce' => wp_create_nonce( 'albert_view_mode' ), 'i18n' => [ 'copied' => __( 'Copied!', 'albert-ai-butler' ), 'copyFailed' => __( 'Copy failed', 'albert-ai-butler' ), /* translators: 1: visible count, 2: total count, 3: enabled count. */ 'statsTemplate' => __( 'Showing %1$s of %2$s · %3$s enabled', 'albert-ai-butler' ), /* translators: 1: current page number, 2: total page count. */ 'pageTemplate' => __( 'Page %1$s of %2$s', 'albert-ai-butler' ), 'destructiveConfirm' => __( 'This ability can permanently delete data. Are you sure you want to enable it?', 'albert-ai-butler' ), 'noMatches' => __( 'No abilities match your filters.', 'albert-ai-butler' ), 'saveError' => __( 'Could not save your change. Please try again.', 'albert-ai-butler' ), 'sessionExpired' => __( 'Your session has expired. Reload the page and try again.', 'albert-ai-butler' ), ], ] ); } }