Setup API Key';
array_unshift( $links, $setup_link );
return $links;
}
// ── Menu Registration ─────────────────────────────────────────────────
public function register_menus(): void {
add_menu_page(
__( 'AICOM', 'aicom' ),
__( 'AICOM', 'aicom' ),
self::CAPABILITY,
self::MENU_SLUG,
[ $this, 'page_dashboard' ],
'dashicons-rest-api',
80
);
add_submenu_page( self::MENU_SLUG, __( 'Dashboard', 'aicom' ), __( 'Dashboard', 'aicom' ), self::CAPABILITY, self::MENU_SLUG, [ $this, 'page_dashboard' ] );
add_submenu_page( self::MENU_SLUG, __( 'API Keys', 'aicom' ), __( 'API Keys', 'aicom' ), self::CAPABILITY, 'aicom-api-keys', [ $this, 'page_api_keys' ] );
add_submenu_page( self::MENU_SLUG, __( 'Audit Logs', 'aicom' ), __( 'Audit Logs', 'aicom' ), self::CAPABILITY, 'aicom-audit-logs', [ $this, 'page_audit_logs' ] );
add_submenu_page( self::MENU_SLUG, __( 'Safety', 'aicom' ), __( 'Safety', 'aicom' ), self::CAPABILITY, 'aicom-safety', [ $this, 'page_safety' ] );
add_submenu_page( self::MENU_SLUG, __( 'Modules', 'aicom' ), __( 'Modules', 'aicom' ), self::CAPABILITY, 'aicom-modules', [ $this, 'page_modules' ] );
add_submenu_page( self::MENU_SLUG, __( 'Backups', 'aicom' ), __( 'Backups', 'aicom' ), self::CAPABILITY, 'aicom-backups', [ $this, 'page_backups' ] );
}
// ── Asset Enqueuing ───────────────────────────────────────────────────
public function enqueue_assets( string $hook ): void {
$plugin_url = AICOM_URL;
$version = AICOM_VERSION;
$on_aicom = strpos( $hook, 'aicom' ) !== false;
// CSS enqueued on every admin page (admin bar styles needed everywhere)
wp_enqueue_style(
'aicom-admin',
$plugin_url . 'assets/admin.css',
[],
$version
);
// JS only needed on AICOM pages OR when admin bar is showing (toolbar toggle)
wp_enqueue_script(
'aicom-admin',
$plugin_url . 'assets/admin.js',
[ 'jquery' ],
$version,
true
);
wp_localize_script( 'aicom-admin', 'AICOM_MCP', [
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
] );
}
// ── Page Renderers ────────────────────────────────────────────────────
public function page_dashboard(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/dashboard.php';
}
public function page_api_keys(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/api-keys.php';
}
public function page_audit_logs(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/audit-logs.php';
}
public function page_safety(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/safety.php';
}
public function page_modules(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/modules.php';
}
public function page_backups(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/backups.php';
}
// ── Admin notices (key created / rotated) ─────────────────────────────
public function display_key_notice(): void {
$screen = get_current_screen();
if ( ! $screen || strpos( $screen->id ?? '', 'aicom' ) === false ) {
return;
}
$created = isset( $_GET['created'] ) ? absint( $_GET['created'] ) : 0;
$rotated = isset( $_GET['rotated'] ) ? absint( $_GET['rotated'] ) : 0;
$new_key_id = $created ?: $rotated;
if ( ! $new_key_id ) {
return;
}
$plain_key = get_transient( 'aicom_new_key_' . $new_key_id );
if ( ! $plain_key ) {
return;
}
delete_transient( 'aicom_new_key_' . $new_key_id );
$mcp_url = get_site_url() . '/wp-json/aicom/v1/mcp';
$fallback_url = get_site_url() . '/?aicom=1';
/* translators: 1: MCP endpoint URL, 2: fallback URL, 3: API key */
$agent_text = sprintf(
__( "Connect to my WordPress site via MCP:\n\nEndpoint: %1\$s\nFallback: %2\$s\nAuthorization: Bearer %3\$s\n\nUse the endpoint and the Authorization header for every request. If the connection is refused or returns 401, retry the request with real browser headers (User-Agent, Accept, Accept-Language).", 'aicom' ),
$mcp_url, $fallback_url, $plain_key
);
$label = $created
? __( 'Key created', 'aicom' )
: __( 'Key rotated', 'aicom' );
?>
handle_set_lock(
! empty( $_POST['soft_lock'] ),
! empty( $_POST['hard_lock'] )
);
break;
case 'create_key':
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) );
$this->handle_create_key(
sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) ),
$scopes,
! empty( $_POST['dry_run_only'] ),
sanitize_textarea_field( wp_unslash( $_POST['ip_allowlist'] ?? '' ) ),
sanitize_text_field( wp_unslash( $_POST['expires_at'] ?? '' ) )
);
break;
case 'rotate_key':
$this->handle_rotate_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'revoke_key':
$this->handle_revoke_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'suspend_key':
$this->handle_suspend_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'unsuspend_key':
$this->handle_unsuspend_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'edit_key':
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) );
$this->handle_edit_key(
absint( wp_unslash( $_POST['key_id'] ?? 0 ) ),
$scopes,
! empty( $_POST['dry_run_only'] ),
sanitize_textarea_field( wp_unslash( $_POST['ip_allowlist'] ?? '' ) ),
sanitize_text_field( wp_unslash( $_POST['expires_at'] ?? '' ) ),
! empty( $_POST['rotate_secret'] )
);
break;
case 'archive_key':
AICOM_Auth::archive_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&archived=' . absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) ) );
exit;
case 'unarchive_key':
AICOM_Auth::unarchive_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&unarchived=' . absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) ) );
exit;
case 'delete_backup':
$this->handle_delete_backup( absint( wp_unslash( $_POST['backup_id'] ?? 0 ) ) );
break;
case 'restore_session':
$this->handle_restore_session( absint( wp_unslash( $_POST['session_id'] ?? 0 ) ) );
break;
case 'save_backup_settings':
update_option( 'aicom_backup_max_age_days', absint( wp_unslash( $_POST['max_age_days'] ?? 0 ) ) );
update_option( 'aicom_backup_max_size_mb', absint( wp_unslash( $_POST['max_size_mb'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-backups&settings_saved=1' ) );
exit;
default:
wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '&error=unknown_action' ) );
exit;
}
}
// ── Action Handlers ───────────────────────────────────────────────────
private function handle_set_lock( bool $soft_lock, bool $hard_lock ): void {
AICOM_Lock_Manager::set_soft_lock( $soft_lock );
AICOM_Lock_Manager::set_hard_lock( $hard_lock );
AICOM_Audit_Logger::log( [
'remote_ip' => AICOM_Tool_Router::remote_ip(),
'tool_name' => 'admin.set_lock',
'module' => 'admin',
'status' => 'success',
'http_status' => 200,
'api_key_label' => wp_get_current_user()->user_login,
'result_summary_json' => wp_json_encode( [
'soft_lock' => $soft_lock,
'hard_lock' => $hard_lock,
] ),
] );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-safety&updated=lock' ) );
exit;
}
private function handle_create_key( string $label, array $scopes, bool $dry_run_only, string $ip_allowlist_raw, string $expires_raw = '' ): void {
if ( ! $label ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=missing_label' ) );
exit;
}
$restrictions = [];
if ( $dry_run_only ) {
$restrictions['dry_run_only'] = true;
}
if ( $ip_allowlist_raw ) {
$restrictions['ip_allowlist'] = array_filter( array_map( 'trim', explode( "\n", $ip_allowlist_raw ) ) );
}
$expires_at = $expires_raw ? gmdate( 'Y-m-d 23:59:59', strtotime( $expires_raw ) ) : null;
$result = AICOM_Auth::create_key( $label, $scopes, $restrictions, $expires_at );
// Store the plain key in a short-lived transient for one-time display
set_transient( 'aicom_new_key_' . $result['id'], $result['plain_key'], 60 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&created=' . $result['id'] ) );
exit;
}
private function handle_rotate_key( int $key_id ): void {
$new_key = AICOM_Auth::rotate_key( $key_id );
if ( $new_key ) {
set_transient( 'aicom_new_key_' . $key_id, $new_key, 60 );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&rotated=' . $key_id ) );
exit;
}
private function handle_revoke_key( int $key_id ): void {
AICOM_Auth::revoke_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&revoked=' . $key_id ) );
exit;
}
private function handle_suspend_key( int $key_id ): void {
AICOM_Auth::suspend_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&suspended=' . $key_id ) );
exit;
}
private function handle_unsuspend_key( int $key_id ): void {
AICOM_Auth::unsuspend_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&unsuspended=' . $key_id ) );
exit;
}
private function handle_edit_key( int $id, array $scopes, bool $dry_run_only, string $ip_raw, string $expires_raw, bool $rotate ): void {
if ( ! $id ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=invalid_key' ) );
exit;
}
$restrictions = [];
if ( $dry_run_only ) {
$restrictions['dry_run_only'] = true;
}
if ( $ip_raw ) {
$restrictions['ip_allowlist'] = array_filter( array_map( 'trim', explode( "\n", $ip_raw ) ) );
}
$expires_at = $expires_raw ? gmdate( 'Y-m-d 23:59:59', strtotime( $expires_raw ) ) : null;
$diff = AICOM_Auth::update_key( $id, $scopes, $restrictions, $expires_at );
$rotated = false;
if ( $rotate ) {
$new_key = AICOM_Auth::rotate_key( $id );
if ( $new_key ) {
set_transient( 'aicom_new_key_' . $id, $new_key, 60 );
$rotated = true;
}
}
set_transient( 'aicom_edit_result_' . $id, wp_json_encode( [
'added' => $diff['added'],
'removed' => $diff['removed'],
'rotated' => $rotated,
] ), 30 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&edited=' . $id . ( $rotated ? '&rotated=' . $id : '' ) ) );
exit;
}
private function handle_delete_backup( int $id ): void {
global $wpdb;
if ( $id ) {
$wpdb->delete( $wpdb->prefix . 'aicom_backups', [ 'id' => $id ] );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-backups&deleted=' . $id ) );
exit;
}
private function handle_restore_session( int $session_id ): void {
if ( ! $session_id ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-audit-logs&tab=sessions&restore_error=1' ) );
exit;
}
$restored = self::do_restore_session( $session_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-audit-logs&tab=sessions&restored=' . $restored ) );
exit;
}
/**
* Core restore logic — replays all backups for a session in reverse order.
* Returns the number of objects restored.
* Public so tests can call it directly without going through the POST handler.
*/
public static function do_restore_session( int $session_id ): int {
global $wpdb;
$backups = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}aicom_backups WHERE session_id = %d ORDER BY created_at DESC",
$session_id
),
ARRAY_A
);
$restored = 0;
foreach ( $backups as $backup ) {
$payload = json_decode( $backup['payload_json'], true );
if ( ! $payload ) {
continue;
}
if ( $backup['target_type'] === 'post' && ! empty( $payload['post']['ID'] ) ) {
$post_data = $payload['post'];
$original_id = (int) $post_data['ID'];
unset( $post_data['post_modified'], $post_data['post_modified_gmt'] );
// Check at DB level — get_post() uses object cache which may be stale.
$exists = (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT 1 FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", $original_id
) );
if ( $exists ) {
$post_data['ID'] = $original_id;
wp_update_post( $post_data );
} else {
// Re-insert with the original ID via import_id (wp_insert_post INSERT branch).
unset( $post_data['ID'] );
$post_data['import_id'] = $original_id;
wp_insert_post( $post_data );
$post_data['ID'] = $original_id; // restore for meta/terms below
}
foreach ( $payload['meta'] ?? [] as $key => $values ) {
delete_post_meta( $original_id, $key );
foreach ( (array) $values as $val ) {
add_post_meta( $original_id, $key, maybe_unserialize( $val ) );
}
}
foreach ( $payload['terms'] ?? [] as $tax => $term_ids ) {
wp_set_post_terms( $original_id, $term_ids, $tax );
}
clean_post_cache( $original_id );
$restored++;
} elseif ( $backup['target_type'] === 'term' && ! empty( $payload['term']['term_id'] ) ) {
$t = $payload['term'];
wp_update_term( (int) $t['term_id'], $t['taxonomy'] ?? '', [
'name' => $t['name'] ?? '',
'slug' => $t['slug'] ?? '',
'description' => $t['description'] ?? '',
'parent' => $t['parent'] ?? 0,
] );
foreach ( $payload['meta'] ?? [] as $key => $values ) {
delete_term_meta( (int) $t['term_id'], $key );
foreach ( (array) $values as $val ) {
add_term_meta( (int) $t['term_id'], $key, maybe_unserialize( $val ) );
}
}
$restored++;
}
}
return $restored;
}
/**
* Delete old backups per configured age/size caps. Called by daily cron.
*/
public static function run_backup_cleanup(): void {
global $wpdb;
$max_age = (int) get_option( 'aicom_backup_max_age_days', 0 );
$max_mb = (int) get_option( 'aicom_backup_max_size_mb', 0 );
if ( $max_age > 0 ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}aicom_backups WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)",
current_time( 'mysql', true ),
$max_age
)
);
}
if ( $max_mb > 0 ) {
$max_bytes = $max_mb * 1024 * 1024;
for ( $i = 0; $i < 10000; $i++ ) { // hard limit to avoid infinite loop
$total = (int) $wpdb->get_var( "SELECT SUM(LENGTH(payload_json)) FROM {$wpdb->prefix}aicom_backups" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( $total <= $max_bytes ) {
break;
}
$oldest = (int) $wpdb->get_var( "SELECT id FROM {$wpdb->prefix}aicom_backups ORDER BY created_at ASC LIMIT 1" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( ! $oldest ) {
break;
}
$wpdb->delete( $wpdb->prefix . 'aicom_backups', [ 'id' => $oldest ] );
}
}
}
// ── Admin Bar ─────────────────────────────────────────────────────────
public static function register_admin_bar( WP_Admin_Bar $bar ): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
global $wpdb;
$keys = $wpdb->get_results(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
"SELECT id, label, status FROM {$wpdb->prefix}aicom_api_keys
WHERE status IN ('active','suspended')
ORDER BY created_at DESC LIMIT 20",
ARRAY_A
);
$manage_url = admin_url( 'admin.php?page=aicom-api-keys' );
$active_count = count( array_filter( $keys, fn( $k ) => $k['status'] === 'active' ) );
$count_html = $active_count > 0
? ' ' . $active_count . ''
: '';
$lock_status = AICOM_Lock_Manager::get_effective_lock();
$tb_class = 'aicom-tb-parent';
if ( $lock_status === 'hard_locked' ) {
$tb_class .= ' aicom-tb-hardlocked';
} elseif ( $lock_status === 'soft_locked' ) {
$tb_class .= ' aicom-tb-softlocked';
}
$bar->add_node( [
'id' => 'aicom-toolbar',
'title' => ''
. 'AICOM Keys'
. $count_html,
'href' => $manage_url,
'meta' => [ 'class' => $tb_class ],
] );
// ── Lock controls (first child) ────────────────────────────────────
$lock_nonce = wp_create_nonce( 'aicom_toolbar_lock' );
$bar->add_node( [
'id' => 'aicom-toolbar-lock',
'parent' => 'aicom-toolbar',
'title' => ''
. ''
. '',
'href' => false,
'meta' => [ 'class' => 'aicom-tb-lock-row' ],
] );
foreach ( $keys as $key ) {
$is_active = $key['status'] === 'active';
$dot = $is_active ? '🟢' : '🟡';
$action = $is_active ? 'suspend_key' : 'unsuspend_key';
$btn_label = $is_active ? 'Suspend' : 'Unsuspend';
$btn_class = $is_active ? 'aicom-tb-btn-suspend' : 'aicom-tb-btn-unsuspend';
$bar->add_node( [
'id' => 'aicom-key-' . (int) $key['id'],
'parent' => 'aicom-toolbar',
'title' => '' . $dot . ''
. '' . esc_html( $key['label'] ) . ''
. '',
'href' => false,
'meta' => [ 'class' => 'aicom-tb-key' ],
] );
}
$bar->add_node( [
'id' => 'aicom-toolbar-manage',
'parent' => 'aicom-toolbar',
'title' => 'Manage API Keys',
'href' => $manage_url,
'meta' => [ 'class' => 'aicom-tb-manage' ],
] );
}
public static function ajax_save_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
if ( ! $name ) {
wp_send_json_error( 'missing_name', 400 );
}
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_values( array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) ) );
if ( empty( $scopes ) ) {
wp_send_json_error( 'no_scopes', 400 );
}
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'aicom_presets',
[
'name' => $name,
'scopes_json' => wp_json_encode( $scopes ),
'created_at' => current_time( 'mysql' ),
'created_by_user_id' => get_current_user_id(),
]
);
wp_send_json_success( [
'id' => (int) $wpdb->insert_id,
'name' => $name,
'scopes' => $scopes,
] );
}
public static function ajax_delete_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$id = absint( $_POST['preset_id'] ?? 0 );
if ( ! $id ) {
wp_send_json_error( 'invalid_id', 400 );
}
global $wpdb;
$deleted = $wpdb->delete( $wpdb->prefix . 'aicom_presets', [ 'id' => $id ] );
if ( ! $deleted ) {
wp_send_json_error( 'not_found', 404 );
}
wp_send_json_success( [ 'deleted' => $id ] );
}
public static function ajax_toolbar_toggle(): void {
$key_id = absint( $_POST['key_id'] ?? 0 );
$action = sanitize_key( wp_unslash( $_POST['aicom_action'] ?? '' ) );
if ( ! $key_id || ! in_array( $action, [ 'suspend_key', 'unsuspend_key' ], true ) ) {
wp_send_json_error( 'invalid_params', 400 );
}
if ( ! check_ajax_referer( 'aicom_toolbar_' . $key_id, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$ok = $action === 'suspend_key'
? AICOM_Auth::suspend_key( $key_id )
: AICOM_Auth::unsuspend_key( $key_id );
if ( ! $ok ) {
wp_send_json_error( 'no_change', 409 );
}
$new_status = $action === 'suspend_key' ? 'suspended' : 'active';
$new_dot = $new_status === 'active' ? '🟢' : '🟡';
$new_action = $new_status === 'active' ? 'suspend_key' : 'unsuspend_key';
$new_btn_label = $new_status === 'active' ? 'Suspend' : 'Unsuspend';
$new_btn_class = $new_status === 'active' ? 'aicom-tb-btn-suspend' : 'aicom-tb-btn-unsuspend';
$new_nonce = wp_create_nonce( 'aicom_toolbar_' . $key_id );
wp_send_json_success( [
'new_status' => $new_status,
'new_dot' => $new_dot,
'new_action' => $new_action,
'new_btn_label' => $new_btn_label,
'new_btn_class' => $new_btn_class,
'new_nonce' => $new_nonce,
] );
}
public static function ajax_toolbar_lock(): void {
if ( ! check_ajax_referer( 'aicom_toolbar_lock', 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$mode = sanitize_key( wp_unslash( $_POST['lock_mode'] ?? '' ) );
if ( ! in_array( $mode, [ 'unlock', 'soft', 'hard' ], true ) ) {
wp_send_json_error( 'invalid_mode', 400 );
}
AICOM_Lock_Manager::set_hard_lock( $mode === 'hard' );
AICOM_Lock_Manager::set_soft_lock( $mode === 'soft' );
$new_status = AICOM_Lock_Manager::get_effective_lock();
wp_send_json_success( [
'lock_status' => $new_status,
'new_nonce' => wp_create_nonce( 'aicom_toolbar_lock' ),
] );
}
// ── Private Helpers ───────────────────────────────────────────────────
private function require_cap(): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'aicom' ) );
}
}
}