$id, 'ability' => $ability, 'input' => $input, 'ops' => $ops, 'summary' => $summary, 'user' => get_current_user_id(), 'created' => time(), 'expires' => time() + self::TTL, ]; set_transient( 'rooster_change_' . $id, $record, self::TTL ); return [ 'change_id' => $id, 'preview_url' => add_query_arg( self::QUERY, $id, home_url( '/' ) ), 'summary' => $summary, 'expires_at' => gmdate( 'c', $record['expires'] ), ]; } public static function get_draft( string $id ): ?array { $id = preg_replace( '/[^a-zA-Z0-9]/', '', $id ); $record = '' === $id ? false : get_transient( 'rooster_change_' . $id ); return is_array( $record ) ? $record : null; } public static function apply( string $change_id ) { $draft = self::get_draft( $change_id ); if ( ! $draft ) { return new \WP_Error( 'rooster_no_draft', __( 'Unknown or expired change id.', 'ai-eshop-optimizer' ), [ 'status' => 404 ] ); } $previous = []; foreach ( $draft['ops'] as $op ) { $previous[] = self::execute( $op ); } $token = self::log( $draft, $previous ); delete_transient( 'rooster_change_' . $draft['id'] ); do_action( 'rooster/settings/changed' ); do_action( 'rooster/agent/applied', $draft, $token ); return [ 'undo_token' => $token, 'applied' => count( $draft['ops'] ), 'summary' => (string) $draft['summary'] ]; } public static function execute( array $op ): array { $args = (array) ( $op['args'] ?? [] ); switch ( $op['type'] ?? '' ) { case 'settings': $group = (string) $args['group']; $before = \Rooster\Core\Setup::group( $group ); $merged = empty( $args['replace'] ) ? array_merge( $before, (array) $args['values'] ) : (array) $args['values']; \Rooster\Core\Setup::save( $group, $merged ); return [ 'type' => 'settings', 'args' => [ 'group' => $group, 'values' => $before, 'replace' => true ] ]; case 'option': $before = get_option( (string) $args['name'] ); update_option( (string) $args['name'], $args['value'] ); return [ 'type' => 'option', 'args' => [ 'name' => (string) $args['name'], 'value' => $before ] ]; case 'pins': $registry = \Rooster\Templates\Slots::instance(); $before = $registry->pins(); $registry->set_pins( (array) $args['pins'] ); return [ 'type' => 'pins', 'args' => [ 'pins' => $before ] ]; case 'global_styles': $post_id = (int) \WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); $post = $post_id ? get_post( $post_id ) : null; $before = $post ? (string) $post->post_content : ''; $current = json_decode( $before, true ); $current = is_array( $current ) ? $current : [ 'version' => \WP_Theme_JSON::LATEST_SCHEMA, 'isGlobalStylesUserThemeJSON' => true ]; $merged = self::styles( $current, $args ); $merged['isGlobalStylesUserThemeJSON'] = true; $merged['version'] = \WP_Theme_JSON::LATEST_SCHEMA; if ( $post_id ) { wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( (string) wp_json_encode( $merged ) ) ] ); } wp_cache_delete( 'global-styles-' . get_stylesheet(), 'theme_json' ); \WP_Theme_JSON_Resolver::clean_cached_data(); return [ 'type' => 'global_styles_raw', 'args' => [ 'content' => $before ] ]; case 'global_styles_raw': $post_id = (int) \WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); if ( $post_id ) { wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( (string) $args['content'] ) ] ); } \WP_Theme_JSON_Resolver::clean_cached_data(); return [ 'type' => 'noop', 'args' => [] ]; case 'post': $id = (int) ( $args['id'] ?? 0 ); $revision = (int) ( $args['revision'] ?? 0 ); if ( $id <= 0 || $revision <= 0 || $id !== (int) wp_is_post_revision( $revision ) ) { return [ 'type' => 'noop', 'args' => [] ]; } $before = self::snapshot( $id ); wp_restore_post_revision( $revision ); return [ 'type' => 'post', 'args' => [ 'id' => $id, 'revision' => $before ] ]; } return [ 'type' => 'noop', 'args' => [] ]; } public static function snapshot( int $post_id ): int { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || ! wp_revisions_enabled( $post ) ) { return 0; } $made = wp_save_post_revision( $post_id ); if ( is_int( $made ) && $made > 0 ) { return $made; } foreach ( wp_get_post_revisions( $post_id, [ 'posts_per_page' => 5 ] ) as $revision ) { if ( ! wp_is_post_autosave( $revision ) ) { return (int) $revision->ID; } } return 0; } public static function record( string $type, array $args, string $summary = '', string $ability = 'rooster/record' ): string { if ( ! in_array( $type, [ 'settings', 'option', 'pins', 'global_styles', 'global_styles_raw', 'post' ], true ) ) { return ''; } $draft = [ 'id' => 'rx' . wp_generate_password( 14, false, false ), 'ability' => '' !== $ability ? $ability : 'rooster/record', 'input' => [ 'type' => $type, 'args' => $args ], 'summary' => '' !== $summary ? $summary : ( 'post' === $type ? sprintf( __( 'Post #%d changed', 'ai-eshop-optimizer' ), (int) ( $args['id'] ?? 0 ) ) : $type ), 'user' => get_current_user_id(), ]; $token = self::log( $draft, [ [ 'type' => $type, 'args' => $args ] ] ); do_action( 'rooster/agent/recorded', $draft, $token ); return $token; } public static function styles( array $current, array $args ): array { foreach ( self::PRESETS as $path ) { $value = self::get_path( $current, $path ); if ( ! is_array( $value ) || ! $value ) { continue; } $keyed = array_filter( $value, static fn ( $k ) => is_string( $k ), ARRAY_FILTER_USE_KEY ); $bare = array_values( array_diff_key( $value, $keyed ) ); if ( $bare ) { $keyed['custom'] = array_merge( (array) ( $keyed['custom'] ?? [] ), $bare ); self::set_path( $current, $path, $keyed ); } } foreach ( (array) ( $args['replace'] ?? [] ) as $path ) { self::set_path( $current, (string) $path, null ); } return self::merge( $current, (array) ( $args['data'] ?? [] ) ); } public static function variation( array $data ): array { unset( $data['$schema'], $data['version'], $data['title'], $data['slug'] ); $paths = []; foreach ( self::PRESETS as $path ) { $value = self::get_path( $data, $path ); if ( is_array( $value ) && array_is_list( $value ) ) { self::set_path( $data, $path, [ 'theme' => $value ] ); $paths[] = $path; } } return [ $data, $paths ]; } private static function get_path( array $data, string $path ) { foreach ( explode( '.', $path ) as $key ) { if ( ! is_array( $data ) || ! array_key_exists( $key, $data ) ) { return null; } $data = $data[ $key ]; } return $data; } private static function set_path( array &$data, string $path, $value ): void { $keys = explode( '.', $path ); $last = array_pop( $keys ); $node = &$data; foreach ( $keys as $key ) { if ( ! isset( $node[ $key ] ) || ! is_array( $node[ $key ] ) ) { if ( null === $value ) { return; } $node[ $key ] = []; } $node = &$node[ $key ]; } if ( null === $value ) { unset( $node[ $last ] ); } else { $node[ $last ] = $value; } } public static function merge( array $base, array $over ): array { foreach ( $over as $key => $value ) { if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) ) { if ( array_is_list( $value ) && array_is_list( $base[ $key ] ) && isset( $value[0]['slug'] ) ) { $by_slug = []; foreach ( $base[ $key ] as $item ) { if ( isset( $item['slug'] ) ) { $by_slug[ $item['slug'] ] = $item; } } foreach ( $value as $item ) { if ( isset( $item['slug'] ) ) { $by_slug[ $item['slug'] ] = array_merge( $by_slug[ $item['slug'] ] ?? [], $item ); } } $base[ $key ] = array_values( $by_slug ); } elseif ( ! array_is_list( $value ) ) { $base[ $key ] = self::merge( $base[ $key ], $value ); } else { $base[ $key ] = $value; } } else { $base[ $key ] = $value; } } return $base; } public static function table(): string { global $wpdb; return $wpdb->prefix . 'rooster_agent_log'; } public static function install_table(): void { global $wpdb; if ( (string) get_option( 'rooster_agent_log_version' ) === '1' ) { return; } if ( class_exists( 'AIEO_Schema_Lock' ) && ! \AIEO_Schema_Lock::acquire( 'rooster_agent_log_version' ) ) { return; } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = function_exists( 'aieo_get_charset_collate' ) ? aieo_get_charset_collate() : $wpdb->get_charset_collate(); $table = self::table(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, ability varchar(80) NOT NULL, change_id varchar(40) NOT NULL, input_hash char(32) NOT NULL, summary text NULL, undo_token char(40) NOT NULL, undo_payload longtext NULL, applied_at datetime NULL, undone_at datetime NULL, PRIMARY KEY (id), KEY undo_token (undo_token), KEY change_id (change_id) ) {$charset};" ); update_option( 'rooster_agent_log_version', '1', false ); } private static function log( array $draft, array $previous ): string { global $wpdb; self::install_table(); $token = wp_generate_password( 40, false, false ); $wpdb->insert( self::table(), [ 'created_at' => current_time( 'mysql', true ), 'user_id' => (int) $draft['user'], 'ability' => (string) $draft['ability'], 'change_id' => (string) $draft['id'], 'input_hash' => md5( wp_json_encode( $draft['input'] ) ), 'summary' => (string) $draft['summary'], 'undo_token' => $token, 'undo_payload' => wp_json_encode( $previous ), 'applied_at' => current_time( 'mysql', true ), ], [ '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $token; } public static function undo( string $token ) { global $wpdb; $token = preg_replace( '/[^a-zA-Z0-9]/', '', $token ); self::install_table(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE undo_token = %s AND undone_at IS NULL', self::table(), $token ), ARRAY_A ); if ( ! $row ) { return new \WP_Error( 'rooster_no_undo', __( 'Unknown or already undone token.', 'ai-eshop-optimizer' ), [ 'status' => 404 ] ); } $ops = json_decode( (string) $row['undo_payload'], true ); $ops = is_array( $ops ) ? $ops : []; foreach ( array_reverse( $ops ) as $op ) { self::execute( (array) $op ); } $wpdb->update( self::table(), [ 'undone_at' => current_time( 'mysql', true ) ], [ 'id' => (int) $row['id'] ], [ '%s' ], [ '%d' ] ); do_action( 'rooster/settings/changed' ); return [ 'restored' => count( $ops ) ]; } public static function recent( int $limit = 20, bool $with_tokens = false ): array { global $wpdb; self::install_table(); $columns = 'id, created_at, user_id, ability, change_id, summary, applied_at, undone_at' . ( $with_tokens ? ', undo_token' : '' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$columns} FROM %i ORDER BY id DESC LIMIT %d", self::table(), $limit ), ARRAY_A ); return is_array( $rows ) ? $rows : []; } }