__( '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' ) ) { ?>
' . 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