get_info_array(); // Add debug mode status to the output. $debug_mode = Settings::get( 'enable_debug_support' ) ? 'Enabled' : 'Disabled'; $rows[] = array( 'Key' => 'Debug Mode', 'Value' => $debug_mode, ); WP_CLI\Utils\format_items( 'table', $rows, array( 'Key', 'Value' ) ); } /** * Pull batch request updates from the middleware for any incomplete batch requests. * * Uses the same underlying logic as the scheduled cron task. */ public function pull_all() { /** * ## EXAMPLES * wp parallax pull-all */ if ( method_exists( BatchRequest::class, 'pull_all' ) ) { BatchRequest::pull_all(); WP_CLI::success( 'Pulled status for all incomplete Parallax batch requests.' ); } else { // Graceful fallback: call Middleware on each open batch request if BatchRequest::pull_all is unavailable. $batch_requests = BatchRequest::query( array( 'status__not_in' => array( 'completed', 'failed' ), 'select' => array( 'id', 'batch_request_id', 'status' ), 'maybe_unserialize' => false, ) ); if ( ! $batch_requests ) { WP_CLI::log( 'No incomplete batch requests.' ); return; } $updated = 0; foreach ( $batch_requests as $row ) { if ( method_exists( MiddlewareRequest::class, 'get_batch_request_status' ) ) { $status = MiddlewareRequest::get_batch_request_status( $row['batch_request_id'] ); if ( is_array( $status ) && isset( $status['status'] ) ) { BatchRequest::update_status( (int) $row['id'], sanitize_text_field( $status['status'] ) ); ++$updated; } } } WP_CLI::success( sprintf( 'Updated %d batch request(s).', $updated ) ); } } /** * Toggle Parallax debug mode on or off. * * Enables or disables debug support for AI Content by Parallax. */ public function toggle_debug() { /* * ## EXAMPLES * wp parallax toggle-debug */ $current = Settings::get( 'enable_debug_support' ); $new_value = $current ? '' : '1'; update_option( Settings::OPTION_KEY, array_merge( get_option( Settings::OPTION_KEY, array() ), array( 'enable_debug_support' => $new_value ) ) ); WP_CLI::success( 'Debug mode is now ' . ( $new_value ? 'enabled' : 'disabled' ) . '.' ); } /** * Pull the latest status for a specific Parallax batch request. * * Accepts either the internal row ID or the Parallax batch request identifier. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. **/ public function pull( $args, $assoc_args ) { /* * ## OPTIONS * * : The numeric row ID or Parallax batch request identifier (batch_request_id column). * * [--show-ids] * : Display batch request identifiers and any ID lists in metadata. * * ## EXAMPLES * wp parallax pull 42 * wp parallax pull batch_12345 */ if ( empty( $args ) ) { WP_CLI::error( 'Batch Request identifier is required.' ); } $show_ids = \WP_CLI\Utils\get_flag_value( $assoc_args, 'show-ids', false ); if ( ! method_exists( BatchRequest::class, 'pull_single' ) ) { WP_CLI::error( 'Pulling a single batch request is not supported in this version.' ); } $identifier = $args[0]; $result = BatchRequest::pull_single( $identifier ); if ( ! is_array( $result ) || isset( $result['error'] ) ) { $error = $result['error'] ?? 'unknown_error'; if ( 'not_found' === $error ) { WP_CLI::error( sprintf( 'No batch request found matching "%s".', $identifier ) ); } if ( 'decode_failed' === $error ) { WP_CLI::error( sprintf( 'Failed to decode middleware response for batch request "%s".', $identifier ) ); } if ( 'api_error' === $error ) { $status = isset( $result['status'] ) ? wp_json_encode( $result['status'] ) : '(unknown)'; WP_CLI::error( sprintf( 'Middleware returned an error for batch request "%s": %s', $identifier, $status ) ); } WP_CLI::error( 'Failed to pull batch request status.' ); } $batch_request = $result['batch_request']; $this->batch_request_details( array( (string) $batch_request['id'] ), $assoc_args ); $status_label = isset( $batch_request['status'] ) ? $batch_request['status'] : '(unknown)'; $batch_request_identifier = $show_ids ? $batch_request['batch_request_id'] : '(hidden)'; WP_CLI::success( sprintf( 'Pulled latest status for batch request %s (%s).', $batch_request_identifier, $status_label ) ); } /** * Import optimized items for a specific Parallax batch request. * * Accepts either the internal row ID or the Parallax batch request identifier. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function import( $args, $assoc_args ) { /* * ## OPTIONS * * : The numeric row ID or Parallax batch request identifier (batch_request_id column). * * [--items=] * : Comma-separated list of item IDs to import. Use "all" (default) to import every tracked item. * * ## EXAMPLES * wp parallax import 42 --items=all * wp parallax import batch_12345 --items=10,12,14 */ if ( empty( $args ) ) { WP_CLI::error( 'Batch Request identifier is required.' ); } $identifier = $args[0]; $batch_request = $this->resolve_batch_request_record( $identifier ); if ( ! $batch_request ) { WP_CLI::error( sprintf( 'No batch request found matching "%s".', $identifier ) ); } $batch_request_type = $this->normalize_batch_request_type( $batch_request['type'] ?? '' ); if ( ! in_array( $batch_request_type, array( 'product', 'category' ), true ) ) { WP_CLI::error( sprintf( 'Import is not supported for batch request type "%s".', (string) ( $batch_request['type'] ?? 'unknown' ) ) ); } $items_flag = \WP_CLI\Utils\get_flag_value( $assoc_args, 'items', 'all' ); if ( is_array( $items_flag ) ) { $items_flag = implode( ',', $items_flag ); } $row_id = isset( $batch_request['id'] ) ? (int) $batch_request['id'] : 0; if ( $row_id <= 0 ) { WP_CLI::error( 'Batch Request is missing its internal identifier.' ); } $metadata = isset( $batch_request['metadata'] ) && is_array( $batch_request['metadata'] ) ? $batch_request['metadata'] : array(); $table = 'product' === $batch_request_type ? new ProductBatchRequestDetailsTable( $row_id ) : new CategoryBatchRequestDetailsTable( $row_id ); $all_item_ids = $this->get_item_ids_from_table_metadata( $table, $metadata, $batch_request_type ); if ( 'all' === $items_flag ) { if ( empty( $all_item_ids ) ) { WP_CLI::error( 'No item IDs are stored for this batch request. Pull the batch request status first or ensure the batch request contains items.' ); } $target_ids = $all_item_ids; } else { $target_ids = $this->parse_items_argument( (string) $items_flag ); if ( empty( $target_ids ) ) { WP_CLI::error( 'No valid item IDs provided via --items.' ); } if ( $all_item_ids ) { $missing = array_diff( $target_ids, $all_item_ids ); if ( $missing ) { WP_CLI::warning( sprintf( 'The following item IDs are not tracked for batch request %s: %s', $batch_request['batch_request_id'], implode( ', ', $missing ) ) ); } } } $target_ids = array_values( array_unique( array_filter( array_map( 'absint', $target_ids ), static function ( $n ) { return $n > 0; } ) ) ); if ( empty( $target_ids ) ) { WP_CLI::error( 'Resolved item list is empty after filtering.' ); } WP_CLI::log( sprintf( 'Importing %d %s item(s) for batch request %s...', count( $target_ids ), $batch_request_type, $batch_request['batch_request_id'] ) ); $processed = $this->perform_cli_import( $table, $batch_request_type, $row_id, $target_ids ); WP_CLI::success( sprintf( 'Imported %d %s item(s) for batch request %s.', $processed, $batch_request_type, $batch_request['batch_request_id'] ) ); } /** * Display details for a specific Parallax batch request. * * Accepts either the internal row ID or the Parallax batch request identifier. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function batch_request_details( $args, $assoc_args ) { /* * ## OPTIONS * * : The numeric row ID or Parallax batch request identifier (batch_request_id column). * * [--show-ids] * : Display batch request identifiers and any ID lists in metadata. * * ## EXAMPLES * wp parallax batch-request-details 42 * wp parallax batch-request-details batch_12345 */ if ( empty( $args ) ) { WP_CLI::error( 'Batch Request identifier is required.' ); } $show_ids = \WP_CLI\Utils\get_flag_value( $assoc_args, 'show-ids', false ); $identifier = $args[0]; $batch_request = $this->resolve_batch_request_record( $identifier ); if ( ! $batch_request ) { WP_CLI::error( sprintf( 'No batch request found matching "%s".', $identifier ) ); } $metadata = isset( $batch_request['metadata'] ) && is_array( $batch_request['metadata'] ) ? $batch_request['metadata'] : array(); $rows = array(); if ( $show_ids ) { $rows[] = array( 'Field' => 'ID', 'Value' => $batch_request['id'], ); $rows[] = array( 'Field' => 'Batch Request ID', 'Value' => $batch_request['batch_request_id'], ); } else { $rows[] = array( 'Field' => 'ID', 'Value' => '(hidden; use --show-ids)', ); $rows[] = array( 'Field' => 'Batch Request ID', 'Value' => '(hidden; use --show-ids)', ); } $rows[] = array( 'Field' => 'Type', 'Value' => $batch_request['type'], ); $rows[] = array( 'Field' => 'Status', 'Value' => $batch_request['status'], ); $rows[] = array( 'Field' => 'Total Expected', 'Value' => $batch_request['total_expected'], ); $rows[] = array( 'Field' => 'Batches Done', 'Value' => $batch_request['batches_done'], ); $rows[] = array( 'Field' => 'Created At', 'Value' => $batch_request['created_at'], ); $rows[] = array( 'Field' => 'Updated At', 'Value' => $batch_request['updated_at'], ); if ( ! empty( $metadata ) ) { $metadata_output = $this->sanitize_metadata_for_display( $metadata, $show_ids ); $rows[] = array( 'Field' => 'Metadata', 'Value' => wp_json_encode( $metadata_output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), ); } WP_CLI\Utils\format_items( 'table', $rows, array( 'Field', 'Value' ) ); } /** * List all Parallax batch requests with their current status. */ public function list_batch_requests() { /* * ## EXAMPLES * wp parallax list-batch-requests */ if ( ! method_exists( BatchRequest::class, 'get_all' ) ) { WP_CLI::error( 'Listing batch requests is not supported in this version.' ); } $batch_requests = BatchRequest::get_all(); if ( empty( $batch_requests ) ) { WP_CLI::log( 'No Parallax batch requests found.' ); return; } $rows = array_map( static function ( $batch_request ) { $failed_items = null; if ( isset( $batch_request['metadata'] ) ) { $metadata = is_array( $batch_request['metadata'] ) ? $batch_request['metadata'] : maybe_unserialize( $batch_request['metadata'] ); if ( is_array( $metadata ) && isset( $metadata['failedItems'] ) ) { $failed_items = $metadata['failedItems']; } } return array( 'id' => $batch_request['id'], 'batch_request_id' => $batch_request['batch_request_id'], 'type' => $batch_request['type'], 'status' => $batch_request['status'], 'batches_done' => $batch_request['batches_done'], 'total_expected' => $batch_request['total_expected'], 'failed_items' => null !== $failed_items ? $failed_items : '', 'created_at' => $batch_request['created_at'], 'updated_at' => $batch_request['updated_at'], ); }, $batch_requests ); WP_CLI\Utils\format_items( 'table', $rows, array( 'id', 'batch_request_id', 'type', 'status', 'batches_done', 'total_expected', 'failed_items', 'created_at', 'updated_at' ) ); } /** * Cleanup plugin data. * * Currently supports dropping plugin-owned tables. * * ## OPTIONS * [--tables] * : Drop plugin-owned tables. * * [--dry-run] * : Only list what would be deleted/dropped. * * [--yes] * : Answer yes to the confirmation message. * * ## EXAMPLES * wp parallax cleanup --tables --dry-run * wp parallax cleanup --tables --yes * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function cleanup( $args, $assoc_args ) { $do_tables = \WP_CLI\Utils\get_flag_value( $assoc_args, 'tables', false ); $dry_run = \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); if ( ! $do_tables ) { WP_CLI::error( 'Nothing to do. Pass --tables to drop plugin tables.' ); } global $wpdb; // Define plugin-owned tables explicitly (whitelist). $owned_tables = array( $wpdb->prefix . 'ai_content_by_parallax_batch_requests', $wpdb->prefix . 'ai_content_by_parallax_batch_request_items', $wpdb->prefix . 'wc_product_meta_snapshots', ); // Only include tables that actually exist. $existing = array(); foreach ( $owned_tables as $table_name ) { $like = $wpdb->esc_like( $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WP-CLI maintenance: lightweight table existence check; caching not appropriate. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ); if ( ! empty( $found ) ) { $existing[] = $table_name; } } if ( empty( $existing ) ) { WP_CLI::success( 'No plugin-owned tables found to drop.' ); return; } WP_CLI::log( 'Plugin-owned tables:' ); foreach ( $existing as $table_name ) { WP_CLI::log( ' - ' . $table_name ); } if ( $dry_run ) { WP_CLI::success( 'Dry run complete. No tables were dropped.' ); return; } WP_CLI::confirm( 'This will DROP the tables listed above. This is irreversible. Continue?', $assoc_args ); foreach ( $existing as $table_name ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- WP-CLI maintenance: destructive cleanup for plugin-owned tables (whitelisted); caching not appropriate. $result = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- WP-CLI maintenance: destructive cleanup for plugin-owned tables (whitelisted). $wpdb->prepare( 'DROP TABLE %i', $table_name ) ); if ( false === $result ) { WP_CLI::warning( 'Failed to drop table: ' . $table_name ); } else { WP_CLI::log( 'Dropped: ' . $table_name ); } } WP_CLI::success( 'Cleanup complete.' ); } /** * Optimize a single product/category (or a small list) via Parallax. * * Syntax: * wp parallax optimize [type] [ids...] * * Where: * - type: product|category (optional) * - ids: one or more numeric IDs (optional) * * Defaults: * - No args: optimizes the first product occurrence * - type only: optimizes first occurrence of that type * * ## EXAMPLES * wp parallax optimize * wp parallax optimize product * wp parallax optimize product 1 * wp parallax optimize product 1 2 3 * wp parallax optimize category * wp parallax optimize category 8 * * [--fields=] * : Optional comma-separated fields to optimize. * Defaults to meta_title,meta_desc,short_desc,long_desc * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function optimize( $args, $assoc_args ) { $fields = array( 'meta_title', 'meta_desc', 'short_desc', 'long_desc' ); if ( isset( $assoc_args['fields'] ) ) { $fields = array_filter( array_map( 'trim', explode( ',', (string) $assoc_args['fields'] ) ) ); } $type = 'product'; $ids = array(); if ( ! empty( $args ) ) { $maybe_type = strtolower( (string) $args[0] ); if ( in_array( $maybe_type, array( 'product', 'category' ), true ) ) { $type = $maybe_type; $args = array_slice( $args, 1 ); } } if ( ! empty( $args ) ) { foreach ( $args as $part ) { if ( is_numeric( $part ) ) { $ids[] = absint( $part ); } } $ids = array_values( array_unique( array_filter( $ids ) ) ); } if ( empty( $ids ) ) { $default_id = $this->first_occurrence_id( $type ); if ( $default_id <= 0 ) { WP_CLI::warning( sprintf( 'No %s found to optimize.', $type ) ); return; } $ids = array( $default_id ); } if ( ! class_exists( BuildRequest::class ) ) { WP_CLI::error( 'BuildRequest class not found.' ); } if ( ! method_exists( MiddlewareRequest::class, 'submit_batch_request' ) ) { WP_CLI::error( 'No middleware submit method available.' ); } $request_data = ( new BuildRequest() )->get_optimization_request_data( $fields, $ids, $type ); $response = MiddlewareRequest::submit_batch_request( $request_data, $type ); if ( is_array( $response ) && isset( $response['error'] ) ) { WP_CLI::error( 'Submit failed: ' . $response['error'] ); } $batch_request_id = $this->extract_batch_request_id_from_response( $response ); $created_id = null; if ( $batch_request_id ) { $created_id = $this->persist_cli_batch_request_row( $batch_request_id, $type, $ids, $fields, $response ); } $message = sprintf( 'Optimize submitted for %s ID(s): %s. BatchRequest: %s', $type, implode( ',', $ids ), $batch_request_id ? $batch_request_id : '(unknown)' ); if ( $created_id ) { $message .= sprintf( ' | local row id: %s', (string) $created_id ); } else { WP_CLI::warning( 'Middleware accepted the request, but no local batch request row was created. list-batch-requests will not show it.' ); } WP_CLI::success( $message ); } /** * Extract batch request identifier from a middleware response. * * @param mixed $response Response payload. * @return string|null */ private function extract_batch_request_id_from_response( $response ): ?string { if ( ! is_array( $response ) ) { return null; } $keys = array( 'batch_id', 'batchId', 'batch_request_id', 'batchRequestId', 'id', ); foreach ( $keys as $key ) { if ( isset( $response[ $key ] ) && '' !== (string) $response[ $key ] ) { return (string) $response[ $key ]; } } return null; } /** * Persist a local batch request row so it appears in list-batch-requests. * * @param string $batch_request_id Middleware batch request id. * @param string $type product|category. * @param int[] $ids Item ids. * @param array $fields Fields list. * @param mixed $response Middleware response. * @return int|null Local row id if created. */ private function persist_cli_batch_request_row( string $batch_request_id, string $type, array $ids, array $fields, $response ): ?int { $created_id = null; if ( ! class_exists( BatchRequest::class ) ) { return null; } $total_expected = count( $ids ); if ( is_array( $response ) ) { if ( isset( $response['totalItems'] ) ) { $total_expected = absint( $response['totalItems'] ); } if ( isset( $response['total_items'] ) ) { $total_expected = absint( $response['total_items'] ); } if ( $total_expected <= 0 ) { $total_expected = count( $ids ); } } $args = array( 'batch_request_id' => (string) $batch_request_id, 'status' => 'processing', 'type' => $type, 'total_expected' => (int) $total_expected, 'batches_done' => 0, 'metadata' => array( 'fields' => array_values( $fields ), 'item_ids' => array_values( array_map( 'absint', $ids ) ), 'source' => 'cli_optimize', ), ); if ( method_exists( BatchRequest::class, 'create' ) ) { $created = BatchRequest::create( $args ); if ( is_array( $created ) && isset( $created['id'] ) ) { $created_id = absint( $created['id'] ); } elseif ( is_numeric( $created ) ) { $created_id = absint( $created ); } } if ( $created_id && $created_id > 0 ) { return $created_id; } return null; } /** * Resolve an batch request record by numeric row ID or Parallax batch request identifier. * * @param mixed $identifier Row ID or batch_request_id value. * @return array|null Resolved batch request row. */ private function resolve_batch_request_record( $identifier ): ?array { $batch_request = null; if ( method_exists( BatchRequest::class, 'get' ) && is_numeric( $identifier ) ) { $batch_request = BatchRequest::get( (int) $identifier ); } if ( ! $batch_request ) { if ( method_exists( BatchRequest::class, 'get_by_batch_identifier' ) ) { $batch_request = BatchRequest::get_by_batch_identifier( (string) $identifier ); } else { return null; } } if ( ! isset( $batch_request['metadata'] ) ) { $batch_request['metadata'] = array(); return $batch_request; } $metadata = $batch_request['metadata']; if ( is_string( $metadata ) ) { $decoded = json_decode( $metadata, true ); if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { $metadata = $decoded; } else { $metadata = maybe_unserialize( $metadata ); } } elseif ( ! is_array( $metadata ) ) { $metadata = maybe_unserialize( $metadata ); } if ( ! is_array( $metadata ) ) { $metadata = array( 'value' => $metadata ); } $batch_request['metadata'] = $metadata; return $batch_request; } /** * Normalize stored batch request types to canonical labels. * * @param string $type Raw type label. * @return string */ private function normalize_batch_request_type( $type ): string { $type = strtolower( (string) $type ); if ( in_array( $type, array( 'product', 'products' ), true ) ) { return 'product'; } if ( in_array( $type, array( 'category', 'categories' ), true ) ) { return 'category'; } return ''; } /** * Get the first "occurrence" (ID) for a given optimize type. * * @param string $type product|category. * @return int */ private function first_occurrence_id( string $type ): int { if ( 'category' === $type ) { $ids = $this->all_category_ids(); return ! empty( $ids ) ? absint( $ids[0] ) : 0; } $ids = $this->all_product_ids(); return ! empty( $ids ) ? absint( $ids[0] ) : 0; } /** * Extract item IDs associated with an batch request using table helper methods. * * @param object $table Table instance. * @param array $metadata Batch request metadata. * @param string $batch_request_type Request type ('product'|'category'). * @return int[] Unique item IDs. */ private function get_item_ids_from_table_metadata( $table, array $metadata, string $batch_request_type ): array { $all_ids = array(); $ids = $this->invoke_table_method( $table, 'get_item_ids_from_metadata', array( $metadata ) ); if ( is_array( $ids ) ) { $all_ids = $ids; } if ( empty( $all_ids ) ) { $fallback = 'product' === $batch_request_type ? 'extract_product_ids_from_metadata' : 'extract_term_ids_from_metadata'; $fallback_ids = $this->invoke_table_method( $table, $fallback, array( $metadata ) ); if ( is_array( $fallback_ids ) ) { $all_ids = $fallback_ids; } } return array_values( array_unique( array_filter( array_map( 'absint', (array) $all_ids ) ) ) ); } /** * Parse an --items flag into a list of integers. * * @param mixed $value CLI flag value. * @return int[] */ private function parse_items_argument( $value ): array { if ( is_array( $value ) ) { $merged = array(); foreach ( $value as $component ) { $merged = array_merge( $merged, $this->parse_items_argument( $component ) ); } return array_values( array_unique( $merged ) ); } $value = trim( (string) $value ); if ( '' === $value ) { return array(); } if ( '[' === $value[0] && ']' === substr( $value, -1 ) ) { $value = trim( substr( $value, 1, -1 ) ); } if ( '' === $value ) { return array(); } $parts = preg_split( '/[\s,]+/', $value ); $ids = array(); foreach ( $parts as $part ) { $part = trim( $part ); if ( '' === $part ) { continue; } if ( ! is_numeric( $part ) ) { continue; } $ids[] = absint( $part ); } return array_values( array_unique( array_filter( $ids ) ) ); } /** * Perform CLI-driven bulk import by delegating to the admin table logic. * * @param object $table Table instance handling import. * @param string $batch_request_type Request type ('product'|'category'). * @param int $row_id Batch request row ID. * @param int[] $item_ids Item IDs to import. * @return int Count of processed items. */ private function perform_cli_import( $table, string $batch_request_type, int $row_id, array $item_ids ): int { if ( empty( $item_ids ) ) { return 0; } $request_key = 'product' === $batch_request_type ? 'product_batch_request_item_ids' : 'category_batch_request_item_ids'; $row_key = 'product' === $batch_request_type ? 'product_batch_request_row_id' : 'category_batch_request_row_id'; $nonce_action = 'product' === $batch_request_type ? 'ai_content_by_parallax_product_action' : 'ai_content_by_parallax_cocat_action'; $chunk_size = (int) apply_filters( 'ai_content_by_parallax_import_all_chunk_size', 40, $batch_request_type, $row_id ); if ( $chunk_size < 1 ) { $chunk_size = count( $item_ids ); } $processed = 0; $chunks = array_chunk( $item_ids, $chunk_size ); foreach ( $chunks as $index => $chunk ) { $this->trigger_table_bulk_approve( $table, $request_key, $row_key, $nonce_action, $row_id, $chunk ); $processed += count( $chunk ); WP_CLI::debug( sprintf( 'Processed import chunk %d (%d item[s]).', $index + 1, count( $chunk ) ), 'ai-content-by-parallax' ); } return $processed; } /** * Trigger the protected process_actions() workflow with the provided item IDs. * * @param object $table Table instance. * @param string $request_key Request key for IDs. * @param string $row_key Request key for row ID. * @param string $nonce_action Nonce action string. * @param int $row_id Batch request row ID. * @param int[] $item_ids Item IDs to approve. */ private function trigger_table_bulk_approve( $table, string $request_key, string $row_key, string $nonce_action, int $row_id, array $item_ids ): void { $nonce = wp_create_nonce( $nonce_action ); $request_keys = array( 'action', $request_key, '_wpnonce' ); $get_keys = array( $row_key ); $original_request = $this->snapshot_input_keys_from_superglobal( 'REQUEST', $request_keys ); $original_post = $this->snapshot_input_keys_from_superglobal( 'POST', $request_keys ); $original_get = $this->snapshot_input_keys_from_superglobal( 'GET', $get_keys ); $original_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( (string) $_SERVER['REQUEST_METHOD'] ) : null; $bulk_action_payload = array( 'action' => 'bulk_approve', $request_key => array_map( 'absint', array_values( $item_ids ) ), '_wpnonce' => $nonce, ); foreach ( $bulk_action_payload as $key => $value ) { $_REQUEST[ $key ] = $value; $_POST[ $key ] = $value; } Base_Table::set_request_override( 'action', 'bulk_approve' ); Base_Table::set_request_override_array( $request_key, array_map( 'absint', array_values( $item_ids ) ) ); Base_Table::set_request_override( '_wpnonce', $nonce ); $_GET[ $row_key ] = $row_id; Base_Table::set_request_override( $row_key, (string) $row_id ); $_SERVER['REQUEST_METHOD'] = 'POST'; $this->invoke_table_method( $table, 'process_actions' ); $this->restore_input_keys_to_superglobal( 'REQUEST', $request_keys, $original_request ); $this->restore_input_keys_to_superglobal( 'POST', $request_keys, $original_post ); $this->restore_input_keys_to_superglobal( 'GET', $get_keys, $original_get ); Base_Table::clear_request_override(); if ( null !== $original_method ) { $_SERVER['REQUEST_METHOD'] = $original_method; } else { unset( $_SERVER['REQUEST_METHOD'] ); } } /** * Capture only the specified input keys from a superglobal. * * @param string $superglobal Superglobal name (REQUEST|POST|GET). * @param array $keys Keys to capture. * @return array Keyed snapshot of captured values. */ private function snapshot_input_keys_from_superglobal( string $superglobal, array $keys ): array { if ( ! $this->is_cli_context() ) { return array(); } $snapshot = array(); foreach ( $keys as $key ) { $value = $this->get_superglobal_value( $superglobal, $key ); if ( null !== $value ) { $snapshot[ $key ] = $value; } } return $snapshot; } /** * Restore only the specified keys to a superglobal. * * @param string $superglobal Superglobal name (REQUEST|POST|GET). * @param array $keys Keys to restore. * @param array $snapshot Previously captured values. */ private function restore_input_keys_to_superglobal( string $superglobal, array $keys, array $snapshot ): void { if ( ! $this->is_cli_context() ) { return; } foreach ( $keys as $key ) { $value = array_key_exists( $key, $snapshot ) ? $snapshot[ $key ] : null; if ( null !== $value ) { $value = $this->sanitize_cli_simulated_request_value( $key, $value ); } switch ( $superglobal ) { case 'REQUEST': if ( null !== $value ) { $_REQUEST[ $key ] = $value; } else { unset( $_REQUEST[ $key ] ); } break; case 'POST': if ( null !== $value ) { $_POST[ $key ] = $value; } else { unset( $_POST[ $key ] ); } break; case 'GET': if ( null !== $value ) { $_GET[ $key ] = $value; } else { unset( $_GET[ $key ] ); } break; } } } /** * Determine if the command is running in WP-CLI context. * * @return bool */ private function is_cli_context(): bool { return defined( 'WP_CLI' ) && WP_CLI; } /** * Read a value from a superglobal in CLI-only context. * * @param string $superglobal Superglobal name (REQUEST|POST|GET). * @param string $key Key to read. * @return mixed|null */ private function get_superglobal_value( string $superglobal, string $key ) { $source = array(); switch ( $superglobal ) { case 'POST': $filtered = filter_input( INPUT_POST, $key, FILTER_UNSAFE_RAW ); if ( null !== $filtered || ! $this->is_cli_context() ) { return $this->sanitize_cli_simulated_request_value( $key, $filtered ); } $source = isset( $GLOBALS['_POST'] ) && is_array( $GLOBALS['_POST'] ) ? $GLOBALS['_POST'] : array(); break; case 'GET': $filtered = filter_input( INPUT_GET, $key, FILTER_UNSAFE_RAW ); if ( null !== $filtered || ! $this->is_cli_context() ) { return $this->sanitize_cli_simulated_request_value( $key, $filtered ); } $source = isset( $GLOBALS['_GET'] ) && is_array( $GLOBALS['_GET'] ) ? $GLOBALS['_GET'] : array(); break; case 'REQUEST': $source = isset( $GLOBALS['_REQUEST'] ) && is_array( $GLOBALS['_REQUEST'] ) ? $GLOBALS['_REQUEST'] : array(); break; } if ( ! array_key_exists( $key, $source ) ) { return null; } return $this->sanitize_cli_simulated_request_value( $key, $source[ $key ] ); } /** * Sanitize CLI-simulated request values for allowlisted keys. * * @param string $key Request key. * @param mixed $value Raw value. * @return mixed */ private function sanitize_cli_simulated_request_value( string $key, $value ) { if ( is_array( $value ) ) { $sanitized = array(); foreach ( $value as $item ) { if ( is_scalar( $item ) ) { $sanitized[] = $this->sanitize_cli_simulated_request_value( $key, $item ); } } return $sanitized; } if ( ! is_scalar( $value ) ) { return null; } $unslashed = wp_unslash( (string) $value ); if ( '_wpnonce' === $key ) { return sanitize_text_field( $unslashed ); } return sanitize_key( $unslashed ); } /** * Invoke a protected/private method on a table instance via Closure binding. * * @param object $table Table instance. * @param string $method Method name. * @param array $args Arguments to pass. * @return mixed|null Return value from the invoked method. */ private function invoke_table_method( $table, string $method, array $args = array() ) { if ( ! is_object( $table ) || ! method_exists( $table, $method ) ) { return null; } $closure = \Closure::bind( function () use ( $method, $args ) { return $this->{$method}( ...$args ); }, $table, get_class( $table ) ); return $closure(); } /** * Sanitize metadata output to optionally hide IDs before printing. * * @param array $metadata Metadata array from the batch request row. * @param bool $show_ids Whether IDs should be displayed. * @return array Sanitized metadata. */ private function sanitize_metadata_for_display( array $metadata, bool $show_ids ): array { if ( $show_ids ) { return $metadata; } return $this->redact_ids_recursive( $metadata ); } /** * Recursively redact ID fields from an array structure. * * @param mixed $data Input data (array/scalar). * @return mixed Data with ID fields redacted. */ private function redact_ids_recursive( $data ) { if ( is_array( $data ) ) { $sanitized = array(); foreach ( $data as $key => $value ) { if ( $this->should_redact_key( $key ) ) { if ( is_array( $value ) ) { $sanitized[ $key ] = sprintf( '[%d value(s) hidden]', count( $value ) ); } else { $sanitized[ $key ] = '[hidden]'; } continue; } $sanitized[ $key ] = $this->redact_ids_recursive( $value ); } return $sanitized; } if ( is_object( $data ) ) { return $this->redact_ids_recursive( (array) $data ); } return $data; } /** * Determine whether a metadata key likely contains ID data. * * @param mixed $key Metadata key. * @return bool True if the key name suggests IDs, false otherwise. */ private function should_redact_key( $key ): bool { return is_string( $key ) && stripos( $key, 'id' ) !== false; } /** * Clear ALL Parallax batch requests (and items) from local tables. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function clear_all( $args, $assoc_args ) { /* * ## OPTIONS * [--yes] * : Answer yes to the confirmation message. * * ## EXAMPLES * wp parallax clear-all --yes */ WP_CLI::confirm( 'This will DELETE all Parallax batch requests and batch request items. Continue?', $assoc_args ); // Prefer a model method if available. if ( method_exists( BatchRequest::class, 'clear_all' ) ) { $count = BatchRequest::clear_all(); WP_CLI::success( sprintf( 'Cleared %d batch request(s).', (int) $count ) ); return; } global $wpdb; $batch_requests_table = $wpdb->prefix . 'ai_content_by_parallax_batch_requests'; $items_table = $wpdb->prefix . 'ai_content_by_parallax_batch_request_items'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- WP-CLI maintenance: truncating plugin-owned tables; caching not appropriate. $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $items_table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- WP-CLI maintenance: truncating plugin-owned tables; caching not appropriate. $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $batch_requests_table ) ); WP_CLI::success( 'Cleared all Parallax batch requests and batch request items.' ); } /** * Submit an optimize-all batch for products or categories. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function optimize_all( $args, $assoc_args ) { /* * ## OPTIONS * * : What to optimize. Accepts: products, categories. * * [--fields=] * : Comma-separated fields to optimize. Defaults to meta_title,meta_desc,short_desc,long_desc. * Valid values depend on middleware, but these are commonly supported: * meta_title, meta_desc, short_desc, long_desc * * [--ids=] * : Optional comma-separated list of item IDs to include. Defaults to all published items. * * ## EXAMPLES * wp parallax optimize-all products --fields=meta_title,meta_desc * wp parallax optimize-all categories --fields=meta_title,short_desc --ids=12,34,56 **/ $type = isset( $args[0] ) ? (string) $args[0] : ''; if ( '' === $type ) { WP_CLI::error( 'Type is required. Use "products" or "categories".' ); } $type = strtolower( $type ); $type = strtolower( $type ); if ( ! in_array( $type, array( 'products', 'categories' ), true ) ) { WP_CLI::error( 'Type must be "products" or "categories".' ); } $fields = isset( $assoc_args['fields'] ) ? array_filter( array_map( 'trim', explode( ',', $assoc_args['fields'] ) ) ) : array( 'meta_title', 'meta_desc', 'short_desc', 'long_desc' ); // Resolve item IDs. $ids = array(); if ( ! empty( $assoc_args['ids'] ) ) { $ids = array_filter( array_map( 'absint', explode( ',', $assoc_args['ids'] ) ) ); } elseif ( 'products' === $type ) { $ids = $this->all_product_ids(); } else { $ids = $this->all_category_ids(); } if ( empty( $ids ) ) { WP_CLI::warning( 'No items found to optimize.' ); return; } // Build request payload using existing helper. if ( ! class_exists( BuildRequest::class ) ) { WP_CLI::error( 'BuildRequest class not found.' ); } $request_data = ( new BuildRequest() )->get_optimization_request_data( $fields, $ids, 'products' === $type ? 'product' : 'category' ); // Submit using MiddlewareRequest (preferred). $response = null; if ( method_exists( MiddlewareRequest::class, 'submit_batch_request' ) ) { $response = MiddlewareRequest::submit_batch_request( $request_data, 'products' === $type ? 'product' : 'category' ); } else { WP_CLI::error( 'No middleware submit method available.' ); } if ( is_array( $response ) && isset( $response['error'] ) ) { WP_CLI::error( 'Submit failed: ' . $response['error'] ); } // Record locally if possible. $batch_request_id = is_array( $response ) && isset( $response['batch_id'] ) ? $response['batch_id'] : ( is_array( $response ) ? ( $response['batch_request_id'] ?? null ) : null ); $created_id = null; if ( class_exists( BatchRequest::class ) && $batch_request_id ) { $batch_request_args = array( 'batch_request_id' => (string) $batch_request_id, 'status' => 'processing', 'type' => 'products' === $type ? 'product' : 'category', 'total_expected' => is_array( $response ) && isset( $response['totalItems'] ) ? (int) $response['totalItems'] : count( $ids ), 'batches_done' => 0, 'metadata' => array( 'fields' => $fields, 'item_ids' => $ids, ), ); if ( method_exists( BatchRequest::class, 'create' ) ) { $created = BatchRequest::create( $batch_request_args ); if ( $created ) { $created_id = is_array( $created ) && isset( $created['id'] ) ? $created['id'] : null; } } } WP_CLI::success( sprintf( 'Optimize-all submitted for %d %s. BatchRequest: %s%s', count( $ids ), $type, $batch_request_id ? $batch_request_id : '(unknown)', $created_id ? sprintf( ' | local row id: %s', $created_id ) : '' ) ); } /** * Restore all products and categories from stored original snapshots. */ public function restore_originals() { /* * ## EXAMPLES * wp parallax restore-originals */ if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } [$products, $terms] = OriginalSnapshot::restore_all_from_originals(); WP_CLI::success( sprintf( 'Restored %1$d product(s) and %2$d term(s) to their originals.', (int) $products, (int) $terms ) ); } /** Get all published product IDs (and drafts when enabled). */ private function all_product_ids(): array { $q = new \WP_Query( array( 'post_type' => 'product', 'post_status' => Settings::get_product_statuses_for_optimize(), 'fields' => 'ids', 'posts_per_page' => -1, 'no_found_rows' => true, ) ); return array_map( 'absint', $q->posts ); } /** Get all product category term IDs (non-empty). */ private function all_category_ids(): array { $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'ids', ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } return array_map( 'absint', $terms ); } }