*/ protected array $batch_request_row = array(); /** * Controls whether the header shows the "Refresh" action. * * @var bool */ protected bool $show_refresh_button = true; /** * If true, "Refresh" may be wired to AJAX in the UI. * * @var bool */ protected bool $show_refresh_button_ajax = false; /** * DOM id of the modal container used by this table. * Subclasses should set this (e.g. "product-batch-request-details"). * * @var string */ protected string $modal_id = ''; /** * Nonce action string used for the modal form. * Subclasses should override as needed. * * @var string */ protected string $nonce_action = ''; /** * Nonce action string used for per-row actions. * Defaults to $nonce_action when not set explicitly. * * @var string */ protected string $row_action_nonce_action = ''; /** * Request param used for per-row actions (e.g. product_action/cocat_action). * * @var string */ protected string $action_param = ''; /** * Request param used for the row item id (e.g. product_id/term_id). * * @var string */ protected string $item_id_param = ''; /** * Hidden field name that holds the selected BatchRequest row id * (e.g. "product_batch_request_row_id" or "category_batch_request_row_id"). * * @var string */ protected string $row_id_field_name = ''; /** * Track if the user explicitly selected a batch request from the UI. * When true, "smart fallback" defaults are disabled. * * @var bool */ protected bool $user_selected = false; /** * For empty state: the most recent batch request id that actually has items. * * @var int|null */ protected ?int $suggest_populated_batch_request_id = null; /** * Toggle debug logging for this table. * Usually wired to a constant like WCPAI_APPROVAL_LOGGING_ENABLED. * * @var bool */ protected bool $enabled_debug_logging = false; /** * Prefix used when writing to the logger. * * @var string */ protected string $log_prefix = 'Batch_Request_Details_Table'; /** * Enable or disable the "Reject" action links in the table. * * Override in a subclass and set to false to suppress the Reject button * for a specific table type without touching the shared rendering logic. * * @var bool */ protected bool $enable_reject_action = true; /** * Write to the plugin logger (if enabled). * * @param string $msg Message to log. */ protected function write_to_logger( string $msg ): void { if ( $this->enabled_debug_logging ) { Logger::write_log( 'info', $this->log_prefix . ': ' . $msg ); } } /** * Fetch a meta value for the current item type. * * @param int $item_id Item id. * @param string $key Meta key. * @return mixed */ abstract protected function get_item_meta( int $item_id, string $key ); /** * Update a meta value for the current item type. * * @param int $item_id Item id. * @param string $key Meta key. * @param mixed $value Meta value. * @return bool */ abstract protected function update_item_meta( int $item_id, string $key, $value ): bool; /** * Delete a meta value for the current item type. * * @param int $item_id Item id. * @param string $key Meta key. * @return bool */ abstract protected function delete_item_meta( int $item_id, string $key ): bool; /** * Resolve the primary item ID from a row array (product_id or term_id). * * @param array $item Row item. * @return int */ abstract protected function get_row_item_id( array $item ): int; /** * Render a single table row, adding the rejected CSS class when applicable. * * @param array $item Row item. */ public function single_row( $item ): void { $item_id = $this->get_row_item_id( $item ); $class = $item_id && $this->is_rejected( $item_id ) ? ' class="wc-parallax-row-rejected"' : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- class is a safe literal string. $this->single_row_columns( $item ); echo ''; } /** * Return the current WP field values for a single item. * * Keys must include: _parallax_meta_title, _parallax_meta_desc, * short_description, description. * * @param int $item_id Item ID (product_id or term_id). * @return array */ abstract protected function build_current_values( int $item_id ): array; /** * Return the human-readable display name for a single item. * * @param int $item_id Item ID (product_id or term_id). * @return string */ abstract protected function get_item_display_name( int $item_id ): string; /** * Normalize a raw API result payload into the canonical {current, optimized} * shape used throughout the detail tables. * * Called by the hydration/fetch helpers in this base class * (hydrate_rows_from_api_paged_json, fetch_single_optimized_by_position, * etc.), so both subclasses MUST implement it — declaring it abstract * makes that contract explicit and prevents a future subclass from * silently triggering a runtime fatal by omission. * * @param array $payload Raw API item payload. * @return array Normalized payload. */ abstract protected function normalize_result_payload( array $payload ): array; /** * Get available batch request statuses for validation. * * @return array The array of valid batch request statuses. */ public static function get_batch_request_status_enum_array(): array { return BatchRequestStatus::options_array(); } /** * Resolve nonce action used for row actions. * * @return string The nonce action for row actions. */ protected function get_row_action_nonce_action(): string { return $this->row_action_nonce_action ? $this->row_action_nonce_action : $this->nonce_action; } /** * Build a single "Revert" action link for a row. * * @param int $item_id Item id. * @param int|null $batch_request_row_id Batch request row id. * @return string HTML for the revert action link. */ protected function build_revert_action_link( int $item_id, ?int $batch_request_row_id ): string { $nonce = wp_create_nonce( $this->get_row_action_nonce_action() ); $base_url = $this->get_approval_import_base_url(); $revert_url = add_query_arg( array( $this->action_param => 'revert', $this->item_id_param => (int) $item_id, $this->row_id_field_name => (int) ( $batch_request_row_id ?? 0 ), '_wpnonce' => $nonce, ), $base_url ); $ajax_nonce = wp_create_nonce( 'ai_content_by_parallax_ajax_import_item' ); $revert_attrs = array( 'data-item-id' => (int) $item_id, 'data-batch-request-id' => (int) ( $batch_request_row_id ?? 0 ), 'data-item-type' => $this->item_type, 'data-action-type' => 'revert', 'data-nonce' => $ajax_nonce, ); return self::display_action_button_link( Messages::get( 'details.revert' ), $revert_url, $revert_attrs, 'button-small wc-parallax-single-action revert-item-single revert-item-single-' . $this->item_type . ' button-secondary' ); } /** * Determine whether the item has already been imported. * * @param int $item_id Item id to check. * @return bool */ protected function is_imported( int $item_id ): bool { return (string) $this->get_item_meta( $item_id, '_parallax_import_status' ) === 'imported'; } /** * Mark an item as imported. * * @param int $item_id Item id to mark. */ protected function set_imported_status( int $item_id ): void { $this->update_item_meta( $item_id, '_parallax_import_status', 'imported' ); $this->update_item_meta( $item_id, '_parallax_imported_at', current_time( 'mysql' ) ); } /** * Check if an item has been rejected. * * @param int $item_id Item id to check. * @return bool */ protected function is_rejected( int $item_id ): bool { return (string) $this->get_item_meta( $item_id, '_parallax_import_status' ) === 'rejected'; } /** * Mark an item as rejected. * * @param int $item_id Item id to mark. */ protected function set_rejected_status( int $item_id ): void { $this->update_item_meta( $item_id, '_parallax_import_status', 'rejected' ); } /** * Clear the rejected flag so the item returns to its unimported state. * * @param int $item_id Item id to clear. */ protected function clear_rejected_status( int $item_id ): void { $this->update_item_meta( $item_id, '_parallax_import_status', 'original' ); } /** * Clear import markers so the item returns to an unimported state. * * @param int $item_id Item id to clear. */ protected function clear_import_markers( int $item_id ): void { $this->update_item_meta( $item_id, '_parallax_import_status', 'original' ); $this->delete_item_meta( $item_id, '_parallax_prev_payload' ); $this->delete_item_meta( $item_id, '_parallax_imported_at' ); } /** * Resolve a batch request row ID from: * 1) explicit constructor param * 2) current request param * 3) legacy request param * * @param int|null $provided Optional explicit ID from constructor. * @param string $param Primary request param name. * @param string $legacy Legacy request param name. * * @return array{0:int|null,1:bool} Tuple of (row_id, user_selected). */ protected function resolve_row_id( ?int $provided, string $param, string $legacy ): array { if ( $provided ) { return array( absint( $provided ), true ); } $request_row_id = $this->get_request_int_param( $param ); if ( null !== $request_row_id ) { return array( $request_row_id, true ); } $legacy_row_id = $this->get_request_int_param( $legacy ); if ( null !== $legacy_row_id ) { return array( $legacy_row_id, true ); } return array( null, false ); } /** * Default columns shared by product and category batch request details. * Subclasses can override if they need a different layout. * * @return array */ public function get_columns() { return array( 'cb' => '', 'status' => Messages::get( 'columns.status' ), 'name' => Messages::get( 'details.name' ), 'current_meta_title' => Messages::get( 'details.current_meta_title' ), 'current_meta_desc' => Messages::get( 'details.current_meta_desc' ), 'current_desc' => Messages::get( 'details.current_desc' ), 'opt_name' => Messages::get( 'details.opt_name' ), 'opt_meta_title' => Messages::get( 'details.opt_meta_title' ), 'opt_meta_desc' => Messages::get( 'details.opt_meta_desc' ), 'opt_desc' => Messages::get( 'details.opt_desc' ), 'actions' => Messages::get( 'details.actions' ), 'details' => Messages::get( 'details.details_column' ), ); } /** * Default sortable columns. * * @return array */ protected function get_sortable_columns(): array { return array( 'name' => array( 'name', false ), 'status' => array( 'status', false ), ); } /** * Build the base URL used for per-row actions (Approve/Reject/Revert/etc). */ protected function get_approval_import_base_url(): string { $args = $this->get_context_query_args(); $args = array_merge( array( 'page' => 'ai-content-by-parallax', 'tab' => 'approval_import', ), $args ); return add_query_arg( $args, admin_url( 'admin.php' ) ); } /** * Context query args that should be preserved across pagination/actions. * Subclasses can extend this (e.g. by adding current batch request id). * * @return array */ protected function get_context_query_args(): array { return array(); } /** * Build AJAX-friendly Approve/Reject links for a batch request item. * * @param string $item_type Item type (product|category). * @param int $item_id Item identifier for the row. * @param int|null $batch_request_row_id Local batch request row id. * @param string $approve_label Label for the approve/import action. * @param string $reject_label Label for the reject action. * @param array $fallback_urls Optional array with 'approve' and 'reject' URLs for non-JS fallback. * @return array */ protected function build_single_item_action_links( string $item_type, int $item_id, ?int $batch_request_row_id, string $approve_label, string $reject_label, array $fallback_urls = array() ): array { $nonce = wp_create_nonce( 'ai_content_by_parallax_ajax_import_item' ); $data_attrs = array( 'data-item-id' => (int) $item_id, 'data-batch-request-id' => (int) ( $batch_request_row_id ?? 0 ), 'data-item-type' => $item_type, 'data-nonce' => $nonce, ); $approve_attrs = array_merge( $data_attrs, array( 'data-action-type' => 'approve' ) ); $reject_attrs = array_merge( $data_attrs, array( 'data-action-type' => 'reject' ) ); $approve_link = self::display_action_button_link( $approve_label, $fallback_urls['approve'] ?? '#', $approve_attrs, 'button-small wc-parallax-single-action import-item-single import-item-single-' . $item_type . ' button-primary' ); if ( ! $this->enable_reject_action ) { return array( $approve_link, '' ); } $reject_link = self::display_action_button_link( $reject_label, $fallback_urls['reject'] ?? '#', $reject_attrs, 'button-small wc-parallax-single-action reject-item-single reject-item-single-' . $item_type . ' button-danger' ); return array( $approve_link, $reject_link ); } /** * Query batch requests by type with shared defaults. * * @param array $types Types to include. * @param array $select Columns to select. * @return array> */ protected function get_batch_requests_by_types( array $types, array $select = array( 'id', 'batch_request_id', 'status', 'type', 'metadata', 'created_at' ) ): array { return BatchRequest::query( array( 'types' => $types, 'orderby' => 'created_at', 'order' => 'DESC', 'select' => $select, ) ); } /** * Pick the default batch request id when the user has not selected one. * * @param int|null $current_row_id Existing selected row id (if any). * @param array $batch_requests Batch request rows. * @param callable $extract_ids Callback that accepts normalized metadata and returns int[]. * @return int|null */ protected function choose_default_batch_request_row_id( ?int $current_row_id, array $batch_requests, callable $extract_ids ): ?int { if ( $this->user_selected || $current_row_id || ! $batch_requests ) { return $current_row_id; } foreach ( $batch_requests as $row ) { $meta = $this->normalize_metadata( $row['metadata'] ?? array() ); $ids = $extract_ids( $meta ); if ( $ids ) { return (int) $row['id']; } } return isset( $batch_requests[0]['id'] ) ? (int) $batch_requests[0]['id'] : null; } /** * Find the most recent populated batch request for the given types. * * @param array $types Types to include. * @param callable $extract_ids Callback that accepts normalized metadata and returns int[]. * @return int|null */ protected function find_populated_batch_request_row_id( array $types, callable $extract_ids ): ?int { $batch_requests = $this->get_batch_requests_by_types( $types, array( 'id', 'metadata' ) ); foreach ( $batch_requests as $row ) { $meta = $this->normalize_metadata( $row['metadata'] ?? array() ); $ids = $extract_ids( $meta ); if ( $ids ) { return (int) $row['id']; } } return null; } /** * Render the shared batch request dropdown control. * * @param array> $batch_requests Batch request rows. * @param string $select_id DOM id for the select. * @param string $select_name Select name attribute. * @param string $label_text Visible label text. * @param string $screen_reader Screen reader label. * @param int|null $current_row_id Current selected row id. * @param string $nonce_action Nonce action for the form. */ protected function render_batch_request_select_control( array $batch_requests, string $select_id, string $select_name, string $label_text, string $screen_reader, ?int $current_row_id, string $nonce_action ): void { echo ''; echo '' . esc_html( $label_text ) . ''; echo ''; wp_nonce_field( $nonce_action ); } /** * Column header action buttons (e.g. Refresh / Run Pending). * This is intentionally generic and uses get_current_batch_request_row_id(). */ protected function show_action_buttons(): void { if ( ! method_exists( $this, 'get_current_batch_request_row_id' ) ) { return; } $current_id = $this->get_current_batch_request_row_id(); if ( ! $current_id ) { return; } // Refresh. echo wp_kses_post( self::display_action_button_link( Messages::get( 'details.refresh_status' ), '#', array( 'class' => 'button wc-parallax-refresh-batch-request', 'data-batch-request-id' => (int) $current_id, 'data-batch-request-type' => property_exists( $this, 'item_type' ) ? (string) $this->item_type : '', ) ) ); // Run Pending (if desired by subclass). echo ' '; echo wp_kses_post( self::display_action_button_link( Messages::get( 'details.run_pending' ), '#', array( 'class' => 'button wc-parallax-run-pending-batch-request', 'data-batch-request-id' => (int) $current_id, 'data-batch-request-type' => property_exists( $this, 'item_type' ) ? (string) $this->item_type : '', ) ) ); // Import All Selected — visible only when checkboxes are ticked (JS toggles display). echo ' '; $item_type = property_exists( $this, 'item_type' ) ? (string) $this->item_type : ''; echo wp_kses_post( self::display_action_button_link( Messages::get( 'details.import_selected' ), '#import-selected', array( 'data-batch-request-id' => (int) $current_id, 'data-item-type' => $item_type, ), 'button-primary wc-parallax-import-selected' ) ); } /** * Build a "View Details" link for a row. * * Only minimal identifiers (item_id, batch_request_id, item_type) are embedded * in the DOM to avoid bloating the HTML with large description fields. * Full details are fetched on demand via AJAX when the user clicks the link. * * Attribute name `data-batch-request-id` matches the convention used by the * rest of the admin tables/JS (refresh, import, modal handlers). * * @param array $item Current row item. * @return string HTML for the view details link. */ protected function build_view_details_link( array $item ): string { $item_id_key = 'category' === $this->item_type ? 'term_id' : 'product_id'; $item_id = (int) ( $item[ $item_id_key ] ?? 0 ); $batch_request_id = 0; if ( method_exists( $this, 'get_current_batch_request_row_id' ) ) { $batch_request_id = (int) ( $this->get_current_batch_request_row_id() ?? 0 ); } return sprintf( '%s', $item_id, $batch_request_id, esc_attr( $this->item_type ), esc_html( Messages::get( 'details.view_details' ) ) ); } /** * Return the edit-screen link data for a row item. * * Subclasses override this to expose an "Edit Product" / "Edit Category" * link inside the expanded View Details panel. Return an empty array when * no link should be rendered (missing post/term, capability denied). * * @param array $item Current row item. * @return array{url?: string, label?: string} */ protected function get_item_edit_link_data( array $item ): array { unset( $item ); return array(); } /** * Fetch current and optimized field data for a single item on demand. * * Used by the "View Details" AJAX handler so the full payload does not * have to be embedded in HTML attributes for every table row. The shape * matches what v1.42.2's DOM-embedded payload produced, so the JS panel * (SEO rating score table + Edit link + field diff) renders identically. * * @param int $item_id Product ID or term ID. * @return array{current: array, optimized: array, name: string, status: string, is_imported: bool, edit_url?: string, edit_label?: string}|array{} * Populated detail array on success, or an empty array when the * batch row can't be resolved, metadata is missing, or the * requested item_id is not part of this batch. */ public function get_single_item_details( int $item_id ): array { $batch_request_row_id = null; if ( method_exists( $this, 'get_current_batch_request_row_id' ) ) { $batch_request_row_id = $this->get_current_batch_request_row_id(); } if ( ! $batch_request_row_id ) { return array(); } $batch_row = BatchRequest::get( $batch_request_row_id ); if ( ! is_array( $batch_row ) ) { return array(); } $metadata = $this->normalize_metadata( $batch_row['metadata'] ?? array() ); $remote = (string) ( $batch_row['batch_request_id'] ?? '' ); $type_raw = (string) ( $batch_row['type'] ?? $this->item_type ); $api_type = in_array( strtolower( $type_raw ), array( 'product', 'products' ), true ) ? 'product' : 'category'; $item_ids = method_exists( $this, 'get_item_ids_from_metadata' ) ? $this->get_item_ids_from_metadata( $metadata ) : array(); // Only return details for items that belong to this batch. Prevents callers // from fetching arbitrary product/term content through this endpoint and // from surfacing misleading "Processing" results for IDs outside the batch. $item_ids_int = array_map( 'intval', $item_ids ); if ( empty( $item_ids_int ) || ! in_array( (int) $item_id, $item_ids_int, true ) ) { return array(); } $current = $this->build_current_values( $item_id ); $optimized = array(); if ( '' !== $remote ) { $optimized = $this->fetch_single_optimized_by_position( $remote, $api_type, $item_ids_int, $item_id ); } // Lift the original SEO rating off the optimized payload into current, matching // the paged-hydration path in prepare_items() — keeps the View Details score // table's "current score" column populated in the AJAX flow. if ( isset( $optimized['original_rating'] ) && '' !== (string) $optimized['original_rating'] ) { $current['seo_rating'] = (string) $optimized['original_rating']; } $is_imported = $this->is_imported( (int) $item_id ); $is_rejected = $this->is_rejected( (int) $item_id ); /* * Mirror the row-level status logic in ProductTable / CategoryTable::actions_html * so the View Details header label matches what the row action column shows: * - optimized data present => "Ready" * - no optimized data, batch in hydration_eligible() status * (batch claims complete but nothing came back for this item) => "Optimization Failed" * - no optimized data, batch not yet complete => "Processing ..." * - already imported => "Successfully Imported" * * $has_opt is keyed off the same four content fields that actions_html * checks, not array_filter() — otherwise ancillary keys like seo_rating * would flip the header to "Ready" while the row shows "Optimization Failed". */ $has_opt = ! empty( $optimized['_parallax_meta_title'] ) || ! empty( $optimized['_parallax_meta_desc'] ) || ! empty( $optimized['short_description'] ) || ! empty( $optimized['description'] ); $batch_status_raw = strtolower( (string) ( $batch_row['status'] ?? '' ) ); if ( $has_opt ) { $status = Messages::get( 'enums.status_ready' ); } elseif ( in_array( $batch_status_raw, BatchRequestStatus::hydration_eligible(), true ) ) { $status = Messages::get( 'details.optimization_failed' ); } else { $processing_key = 'category' === $api_type ? 'details.processing_category' : 'details.processing_product'; $status = Messages::get( $processing_key ); } if ( $is_imported ) { $status = Messages::get( 'details.successfully_imported' ); } elseif ( $is_rejected ) { $status = Messages::get( 'details.rejected' ); } $payload = array( 'current' => $current, 'optimized' => $optimized, 'name' => $this->get_item_display_name( $item_id ), 'status' => $status, 'is_imported' => $is_imported, 'is_rejected' => $is_rejected, ); $edit = $this->get_item_edit_link_data( array( 'product_id' => (int) $item_id, 'term_id' => (int) $item_id, ) ); $edit_url = isset( $edit['url'] ) ? esc_url_raw( (string) $edit['url'] ) : ''; $edit_label = isset( $edit['label'] ) ? trim( (string) $edit['label'] ) : ''; if ( '' !== $edit_url && '' !== $edit_label ) { $payload['edit_url'] = $edit_url; $payload['edit_label'] = $edit_label; } return $payload; } /** * Safe redirect helper that preserves context and removes transient args. * * @param string[] $remove_args List of query args to remove. * @param array $add_args List of query args to add. */ protected function redirect_back( array $remove_args = array(), array $add_args = array() ): void { $url = remove_query_arg( $remove_args ); if ( ! empty( $add_args ) ) { $url = add_query_arg( $add_args, $url ); } wp_safe_redirect( $url ); exit; } /** * Retrieve a sanitized scalar request parameter. * * @param string $key The parameter key. * @param string $source One of 'get', 'post', or 'request'. */ protected function get_request_param( string $key, string $source = 'request' ): ?string { $allowed_type = $this->get_allowed_request_type( $key ); if ( null === $allowed_type || 0 === strpos( $allowed_type, 'array_' ) ) { return null; } foreach ( $this->resolve_request_sources( $source ) as $input_type ) { $sanitized = $this->get_superglobal_value( $input_type, $key, $allowed_type ); if ( null !== $sanitized ) { return $sanitized; } } $override = static::get_request_override( $key ); if ( is_string( $override ) ) { return $this->sanitize_request_value( $override, $allowed_type ); } return null; } /** * Retrieve a sanitized integer request parameter. * * @param string $key The parameter key. * @param string $source One of 'get', 'post', or 'request'. */ protected function get_request_int_param( string $key, string $source = 'request' ): ?int { $value = $this->get_request_param( $key, $source ); if ( null === $value || '' === $value ) { return null; } return max( 0, (int) $value ); } /** * Retrieve a sanitized array parameter. * * @param string $key The parameter key. * @param string $source One of 'get', 'post', or 'request'. * @return array */ protected function get_request_array_param( string $key, string $source = 'request' ): array { $allowed_type = $this->get_allowed_request_type( $key ); if ( null === $allowed_type || 0 !== strpos( $allowed_type, 'array_' ) ) { return array(); } foreach ( $this->resolve_request_sources( $source ) as $input_type ) { $sanitized = $this->get_superglobal_array_value( $input_type, $key, $allowed_type ); if ( array() !== $sanitized ) { return $sanitized; } } $override = static::get_request_override( $key ); if ( is_array( $override ) ) { return 'array_int' === $allowed_type ? array_map( 'absint', $override ) : $this->sanitize_array_values( $override ); } return array(); } /** * Retrieve an integer array request parameter. * * @param string $key The parameter key. * @param string $source One of 'get', 'post', or 'request'. * @return array */ protected function get_request_int_array_param( string $key, string $source = 'request' ): array { $values = $this->get_request_array_param( $key, $source ); if ( empty( $values ) ) { return array(); } $out = array(); foreach ( $values as $v ) { $n = (int) $v; if ( $n > 0 ) { $out[] = $n; } } return array_values( array_unique( $out ) ); } /** * Build a sanitized snapshot of selected parameters for logging. * * @param array $keys The parameter keys. * @param string $source One of 'get', 'post', or 'request'. * @return array */ protected function get_params_snapshot( array $keys, string $source = 'request' ): array { $snapshot = array(); foreach ( $keys as $key ) { $array_value = $this->get_request_array_param( $key, $source ); if ( array() !== $array_value ) { $snapshot[ $key ] = $array_value; continue; } $value = $this->get_request_param( $key, $source ); if ( null !== $value ) { $snapshot[ $key ] = $value; } } return $snapshot; } /** * Sanitize each value in an array. * * @param array $values Input values. * @return array */ private function sanitize_array_values( array $values ): array { $clean = array(); foreach ( $values as $value ) { if ( is_scalar( $value ) ) { $clean[] = sanitize_text_field( wp_unslash( (string) $value ) ); } } return $clean; } /** * Determine whether the request should behave like AJAX (including CLI). * * @return bool */ protected function is_ajax_context(): bool { $doing_ajax = function_exists( 'wp_doing_ajax' ) ? wp_doing_ajax() : ( defined( 'DOING_AJAX' ) && DOING_AJAX ); return $doing_ajax || ( defined( 'WP_CLI' ) && WP_CLI ); } /** * Resolve the input types to inspect based on requested source. * * @param string $source One of 'get', 'post', or 'request'. * @return array */ private function resolve_request_sources( string $source ): array { if ( 'get' === $source ) { return array( INPUT_GET ); } if ( 'post' === $source ) { return array( INPUT_POST ); } // default: request (POST then GET). return array( INPUT_POST, INPUT_GET ); } /** * Request allowlist for parameters read from superglobals. * * @return array */ private function get_request_allowlist(): array { return array( 'action' => 'key', '_wpnonce' => 'text', 'product_action' => 'key', 'cocat_action' => 'key', 'product_id' => 'int', 'term_id' => 'int', 'product_batch_request_row_id' => 'int', 'category_batch_request_row_id' => 'int', 'product_batch_request_item_ids' => 'array_int', 'category_batch_request_item_ids' => 'array_int', ); } /** * Resolve the allowed request type for a key. * * @param string $key Parameter key. * @return string|null */ private function get_allowed_request_type( string $key ): ?string { if ( '' !== $this->action_param && $key === $this->action_param ) { return 'key'; } $allowlist = $this->get_request_allowlist(); return $allowlist[ $key ] ?? null; } /** * Retrieve request values from PHP superglobals as a fallback. * * This method intentionally reads from superglobals in a controlled way; * sanitization occurs at the caller. Nonce verification must occur at the action handler. * * @param int $type Input type. * @param string $key Parameter key. * @param string $allowed_type Allowed input type for allowlist sanitation. * @return string|null Sanitized request value or null. */ private function get_superglobal_value( int $type, string $key, string $allowed_type ): ?string { $raw = null; if ( INPUT_GET === $type ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter parameter. if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- unslashed + sanitized below. $raw = wp_unslash( $_GET[ $key ] ); } } elseif ( INPUT_POST === $type ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by caller. if ( isset( $_POST[ $key ] ) && is_string( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- unslashed + sanitized below. $raw = wp_unslash( $_POST[ $key ] ); } } return $this->sanitize_request_value( $raw, $allowed_type ); } /** * Retrieve array request values from PHP superglobals as a fallback. * * This method intentionally reads from superglobals in a controlled way. * Nonce verification must occur at the action handler. * * Note: Array sanitization is deferred to the caller (e.g., sanitize_array_values) * to allow context-appropriate sanitization based on the array's intended use. * * @param int $type Input type. * @param string $key Parameter key. * @param string $allowed_type Allowed input type for allowlist sanitation. * @return array Sanitized array data. */ private function get_superglobal_array_value( int $type, string $key, string $allowed_type ): array { $raw = null; if ( INPUT_GET === $type ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter parameter. if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- unslashed + sanitized below. $raw = wp_unslash( $_GET[ $key ] ); } $raw = is_array( $raw ) ? $raw : null; } elseif ( INPUT_POST === $type ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by caller. if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- unslashed + sanitized below. $raw = wp_unslash( $_POST[ $key ] ); } $raw = is_array( $raw ) ? $raw : null; } if ( ! is_array( $raw ) ) { return array(); } if ( 'array_int' === $allowed_type ) { return array_map( 'absint', $raw ); } return array_map( 'sanitize_text_field', $raw ); } /** * Sanitize a scalar request value based on allowlist type. * * @param mixed $raw Raw request value. * @param string $type Allowlist type. * @return string|null */ private function sanitize_request_value( $raw, string $type ): ?string { if ( ! is_scalar( $raw ) ) { return null; } switch ( $type ) { case 'key': return sanitize_key( (string) $raw ); case 'int': return (string) absint( $raw ); case 'bool': return in_array( $raw, array( '0', '1' ), true ) ? (string) $raw : null; default: return sanitize_text_field( (string) $raw ); } } /** * Get the sanitized HTTP method for the current request. */ protected function get_request_method(): string { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; return $method; } /** * Normalize BatchRequest metadata into an array. * * @param mixed $metadata_raw Raw metadata value. * @return array */ protected function normalize_metadata( $metadata_raw ): array { if ( is_array( $metadata_raw ) ) { return $metadata_raw; } if ( is_string( $metadata_raw ) ) { $decoded = json_decode( $metadata_raw, true ); if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { return $decoded; } } return array(); } /** * Coerce arbitrary data into an int[] of IDs. * * @param mixed $value Input value. * @return int[] */ protected function coerce_ids( $value ): array { $out = array(); if ( is_array( $value ) ) { foreach ( $value as $v ) { if ( is_numeric( $v ) ) { $out[] = (int) $v; } elseif ( is_array( $v ) ) { if ( isset( $v['id'] ) && is_numeric( $v['id'] ) ) { $out[] = (int) $v['id']; } elseif ( isset( $v['term_id'] ) && is_numeric( $v['term_id'] ) ) { $out[] = (int) $v['term_id']; } } } } elseif ( is_string( $value ) && preg_match_all( '/\d+/', $value, $matches ) ) { foreach ( $matches[0] as $num ) { $out[] = (int) $num; } } return array_values( array_unique( array_filter( $out ) ) ); } /** * Deep-scan a structure and collect integers (used as a last-resort extractor). * * @param mixed $node Input node to scan. * @param int[] $bucket Accumulator for found integers. */ protected function collect_ints_recursive( $node, array &$bucket ): void { if ( is_numeric( $node ) ) { $bucket[] = (int) $node; return; } if ( is_array( $node ) ) { foreach ( $node as $child ) { $this->collect_ints_recursive( $child, $bucket ); } } elseif ( is_object( $node ) ) { foreach ( get_object_vars( $node ) as $child ) { $this->collect_ints_recursive( $child, $bucket ); } } } /** * Global index helper for paged API responses. * * @param int $page Current page number (1-based). * @param int $page_size Number of items per page. * @param int $index Zero-based index within the current page. * @return int Global zero-based index. */ protected function global_index( int $page, int $page_size, int $index ): int { return max( 0, ( $page - 1 ) * $page_size + $index ); } /** * Cache the resolved item IDs for a given BatchRequest row id. * * @param int $row_id BatchRequest row ID. * @param int[] $item_ids List of item IDs to cache. */ protected function cache_batch_request_item_ids( int $row_id, array $item_ids ): void { $key = 'ai_content_by_parallax_batch_items_' . (int) $row_id; update_option( $key, array_values( array_unique( array_map( 'intval', $item_ids ) ) ), false ); } /** * Get cached item IDs for a BatchRequest row id (if any). * * @param int $row_id BatchRequest row ID. * @return int[] */ protected function get_cached_batch_request_item_ids( int $row_id ): array { $key = 'ai_content_by_parallax_batch_items_' . (int) $row_id; $value = get_option( $key ); if ( is_array( $value ) ) { return array_values( array_map( 'intval', $value ) ); } return array(); } /** * Static entry point used by: * * ProductBatchRequestDetailsTable::output_table() * CategoryBatchRequestDetailsTable::output_table() * * Subclasses *may* override this if they need to pass an explicit * batch_request_row_id into the constructor, but this is a safe default. */ public static function output_table(): void { /** * Instantiate the table. */ $table = new static(); // If the subclass didn't specify a type, bail quickly. if ( ! property_exists( $table, 'item_type' ) || '' === $table->item_type ) { return; } // Skip if there is no batch for this type. if ( ! BatchRequest::exists( $table->item_type ) ) { return; } $table->render_modal(); } /** * Subclasses should override this to provide a meaningful heading. */ protected function get_heading(): string { return sprintf( /* translators: 1: item type, 2: "Batch Request Details" */ '%s %s', esc_html( ucfirst( $this->item_type ) ), esc_html( Messages::get( 'details.batch_details' ) ) ); } /** * Internal renderer for the modal HTML. * Uses the subclass' get_heading(), item_type, modal_id, etc. */ protected function render_modal(): void { $this->prepare_items(); $current_batch_request_id = null; if ( method_exists( $this, 'get_current_batch_request_row_id' ) ) { $current_batch_request_id = $this->get_current_batch_request_row_id(); } $form_action = add_query_arg( array( 'page' => 'ai-content-by-parallax', 'tab' => 'approval_import', ), admin_url( 'admin.php' ) ); $modal_id = '' !== $this->modal_id ? $this->modal_id : 'batch-request-details'; $nonce_action = '' !== $this->nonce_action ? $this->nonce_action : 'bulk-batch_request_items'; $row_id_field_name = '' !== $this->row_id_field_name ? $this->row_id_field_name : 'batch_request_row_id'; $row_id_field_id = str_replace( '_', '-', $row_id_field_name ) . '-field'; $settings = get_option( 'ai_content_by_parallax_settings' ); $disable_modal_overlays = is_array( $settings ) && ! empty( $settings['disable_modal_overlays'] ); $modal_position = $disable_modal_overlays ? 'modal-unset' : 'modal-togglable-overlay'; echo ''; } /** * Return the first non-empty value found across sources and keys. * * @param callable $get Getter callback. * @param array $sources Ordered list of source arrays. * @param array $keys Ordered list of keys to check. * @return string|null */ protected function first_non_empty( callable $get, array $sources, array $keys ) { foreach ( $sources as $source ) { $value = $get( $source, $keys ); if ( null !== $value && '' !== $value ) { return $value; } } return null; } /** * Normalize a rating value (may arrive as int, float, numeric string, or null) to a display string. * * Non-scalar or empty values collapse to '' so the JS renderer can omit the row. * * @param mixed $value Raw rating value from API payload. * @return string Normalized rating string. */ protected function stringify_rating( $value ): string { if ( ! is_scalar( $value ) ) { return ''; } return trim( (string) $value ); } /** * Persist the Parallax-assigned UUID for a single API item into post/term meta. * * Called during paged hydration so that approve/reject/revert flows can later * resolve WooCommerce item IDs to the UUIDs required by the middleware status * endpoint (`additionalProperties: false` — only UUID strings are accepted). * * @param array $it Raw API item payload (single item from paged response). * @param int $local_id Local WooCommerce product ID or term ID. * @param string $batch_request_type Batch request type: 'product' or 'category'. */ protected function maybe_store_parallax_id( array $it, int $local_id, string $batch_request_type ): void { $uuid = isset( $it['parallaxId'] ) ? sanitize_text_field( (string) $it['parallaxId'] ) : ''; if ( '' === $uuid || $local_id <= 0 ) { return; } if ( 'product' === $batch_request_type ) { update_post_meta( $local_id, '_parallax_id', $uuid ); } else { update_term_meta( $local_id, '_parallax_id', $uuid ); } } /** * Hydrate current-page rows using the batch's item_ids to map API items → local item id. * * Rows MUST be keyed by the local item id (product_id or term_id). * * @param string $remote_batch_request_id Remote batch request ID. * @param string $batch_request_type Batch request type. * @param array $rows Reference to current page rows (keyed by item id). * @param array $item_ids Full ordered list of item IDs from the batch request. */ protected function hydrate_rows_from_api_paged_json( string $remote_batch_request_id, string $batch_request_type, array &$rows, array $item_ids ): void { if ( '' === $remote_batch_request_id || empty( $rows ) ) { return; } $item_ids = array_values( array_map( 'intval', $item_ids ) ); if ( empty( $item_ids ) ) { $this->write_to_logger( 'hydrate_rows_from_api_paged_json(): missing item_ids for batch request ' . $remote_batch_request_id ); return; } // Determine which optimized keys exist for logging lengths (varies by subclass). $length_keys = array( '_parallax_meta_title', '_parallax_meta_desc', 'short_description', 'description', 'optimized_name' ); for ( $p = 1; $p <= 50; $p++ ) { $raw = \AIContentByParallax\Api\MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $batch_request_type, $p ); if ( ! is_string( $raw ) || '' === $raw ) { break; } $decoded = json_decode( $raw, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { $this->write_to_logger( 'hydrate_rows_from_api_paged_json(): JSON decode failed p=' . $p . ' err=' . json_last_error_msg() ); break; } $items = ( isset( $decoded['items'] ) && is_array( $decoded['items'] ) ) ? $decoded['items'] : array(); $page = (int) ( $decoded['page'] ?? $p ); $page_size = (int) ( $decoded['pageSize'] ?? 0 ); $total = (int) ( $decoded['total'] ?? 0 ); $this->write_to_logger( 'hydrate_rows_from_api_paged_json(): page=' . $page . ' pageSize=' . $page_size . ' total=' . $total . ' items=' . count( $items ) ); foreach ( $items as $idx => $it ) { $global_index = $this->global_index( $page, $page_size, (int) $idx ); $local_id = $item_ids[ $global_index ] ?? 0; $this->maybe_store_parallax_id( (array) $it, $local_id, $batch_request_type ); if ( $local_id <= 0 ) { $this->write_to_logger( 'hydrate_rows_from_api_paged_json(): no item_id for global_index=' . $global_index ); continue; } if ( ! isset( $rows[ $local_id ] ) ) { // Not on current page. continue; } $norm = $this->normalize_result_payload( (array) $it ); // Calculate a few lengths for logging (don’t double-assign like the old category code did). $lens = array(); foreach ( $length_keys as $k ) { $lens[ $k ] = isset( $norm[ $k ] ) ? strlen( (string) $norm[ $k ] ) : 0; } $this->write_to_logger( 'hydrate_rows_from_api_paged_json(): map global=' . $global_index . ' -> id=' . $local_id . ' lens=' . wp_json_encode( $lens ) ); $has_any = false; foreach ( $lens as $len ) { if ( $len > 0 ) { $has_any = true; break; } } if ( $has_any ) { $rows[ $local_id ]['optimized'] = $norm; $rows[ $local_id ]['status'] = 'Ready'; } } if ( $page_size > 0 && ( $page * $page_size ) >= $total ) { break; } } } /** * Fetch the optimized payload for ONE item by using its position in the batch item_ids array. * * @param string $remote_batch_request_id Remote batch request ID. * @param string $batch_request_type Batch request type. * @param array $item_ids Full ordered list of item IDs from the batch request. * @param int $target_id Item ID to fetch (product_id or term_id). * @param array $position_lookup Optional precomputed position lookup map id => position. * @return array Normalized optimized payload for the item, or empty array. */ protected function fetch_single_optimized_by_position( string $remote_batch_request_id, string $batch_request_type, array $item_ids, int $target_id, array $position_lookup = array() ): array { $item_ids = array_values( array_map( 'intval', $item_ids ) ); if ( '' === $remote_batch_request_id || empty( $item_ids ) ) { $this->write_to_logger( 'single_fetch: missing remote or empty item_ids' ); return array(); } $pos = null; if ( isset( $position_lookup[ $target_id ] ) ) { $pos = (int) $position_lookup[ $target_id ]; } else { $found = array_search( $target_id, $item_ids, true ); if ( false !== $found ) { $pos = (int) $found; } } if ( null === $pos ) { $this->write_to_logger( 'single_fetch: id ' . $target_id . ' not found in item_ids (' . count( $item_ids ) . ' ids)' ); return array(); } $raw1 = \AIContentByParallax\Api\MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $batch_request_type, 1 ); if ( ! is_string( $raw1 ) || '' === $raw1 ) { $this->write_to_logger( 'single_fetch: empty response on p1 for ' . $remote_batch_request_id ); return array(); } $d1 = json_decode( $raw1, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $d1 ) ) { $this->write_to_logger( 'single_fetch: JSON decode fail on p1: ' . json_last_error_msg() ); return array(); } $page_size = (int) ( $d1['pageSize'] ?? 0 ); if ( $page_size <= 0 ) { $page_size = max( 1, (int) ( isset( $d1['items'] ) && is_array( $d1['items'] ) ? count( $d1['items'] ) : 10 ) ); } $page = (int) floor( $pos / $page_size ) + 1; $local = (int) ( $pos % $page_size ); $raw = \AIContentByParallax\Api\MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $batch_request_type, $page ); if ( ! is_string( $raw ) || '' === $raw ) { $this->write_to_logger( 'single_fetch: empty response on page=' . $page ); return array(); } $decoded = json_decode( $raw, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { $this->write_to_logger( 'single_fetch: JSON decode fail page=' . $page . ' err=' . json_last_error_msg() ); return array(); } $items = ( isset( $decoded['items'] ) && is_array( $decoded['items'] ) ) ? $decoded['items'] : array(); if ( ! array_key_exists( $local, $items ) ) { $this->write_to_logger( 'single_fetch: items missing local idx ' . $local . ' (items=' . count( $items ) . ')' ); return array(); } return $this->normalize_result_payload( (array) $items[ $local ] ); } /** * Fetch optimized payloads for a set of target IDs with minimal remote requests. * * @param string $remote_batch_request_id Remote batch request ID. * @param string $batch_request_type Batch request type. * @param int[] $item_ids Full ordered list of item IDs from the batch request. * @param int[] $target_ids Target IDs to fetch. * @param array $position_lookup Precomputed position lookup map id => position. * @return array Map id => normalized optimized payload. */ protected function fetch_bulk_optimized_by_targets( string $remote_batch_request_id, string $batch_request_type, array $item_ids, array $target_ids, array $position_lookup ): array { $item_ids = array_values( array_map( 'intval', $item_ids ) ); $target_ids = array_values( array_unique( array_map( 'intval', $target_ids ) ) ); if ( '' === $remote_batch_request_id || empty( $item_ids ) || empty( $target_ids ) ) { return array(); } // Build pos => id map for targets. $positions = array(); foreach ( $target_ids as $id ) { if ( isset( $position_lookup[ $id ] ) ) { $positions[ (int) $position_lookup[ $id ] ] = $id; } } if ( empty( $positions ) ) { return array(); } $first_page = \AIContentByParallax\Api\MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $batch_request_type, 1 ); if ( ! is_string( $first_page ) || '' === $first_page ) { $this->write_to_logger( 'bulk_fetch: empty response for page 1' ); return array(); } $decoded_first = json_decode( $first_page, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded_first ) ) { $this->write_to_logger( 'bulk_fetch: JSON decode failed page=1 err=' . json_last_error_msg() ); return array(); } $page_size = (int) ( $decoded_first['pageSize'] ?? 0 ); if ( $page_size <= 0 ) { $page_size = max( 1, (int) ( isset( $decoded_first['items'] ) && is_array( $decoded_first['items'] ) ? count( $decoded_first['items'] ) : 10 ) ); } $pages_needed = array(); foreach ( $positions as $pos => $_id ) { $page = (int) floor( $pos / $page_size ) + 1; $pages_needed[ $page ] = true; } $out = array(); $pages_to_fetch = array_keys( $pages_needed ); sort( $pages_to_fetch, SORT_NUMERIC ); foreach ( $pages_to_fetch as $page ) { if ( 1 === $page ) { $raw = $decoded_first; } else { $resp = \AIContentByParallax\Api\MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $batch_request_type, $page ); if ( ! is_string( $resp ) || '' === $resp ) { $this->write_to_logger( 'bulk_fetch: empty response for page=' . $page ); continue; } $raw = json_decode( $resp, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $raw ) ) { $this->write_to_logger( 'bulk_fetch: JSON decode failed page=' . $page . ' err=' . json_last_error_msg() ); continue; } } $items = ( isset( $raw['items'] ) && is_array( $raw['items'] ) ) ? $raw['items'] : array(); foreach ( $items as $idx => $item ) { $global_index = $this->global_index( (int) ( $raw['page'] ?? $page ), $page_size, (int) $idx ); if ( ! isset( $positions[ $global_index ] ) ) { continue; } $id = (int) $positions[ $global_index ]; $norm = $this->normalize_result_payload( (array) $item ); $has_payload = false; foreach ( $norm as $v ) { if ( is_string( $v ) && '' !== trim( $v ) ) { $has_payload = true; break; } } if ( $has_payload ) { $out[ $id ] = $norm; } } } return $out; } }