*/
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 '
';
echo '
' . esc_html( $this->get_heading() ) . '
';
echo 'X';
echo '';
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;
}
}