'category_batch_request_item',
'plural' => 'category_batch_request_items',
'ajax' => false,
)
);
$approval_logging_enabled = defined( 'WCPAI_APPROVAL_LOGGING_ENABLED' ) ? (bool) WCPAI_APPROVAL_LOGGING_ENABLED : false;
$this->enabled_debug_logging = $approval_logging_enabled || (bool) Settings::get( 'enable_debug_support', false );
// Configure inherited base properties (declared on Base_Batch_Request_Details_Table / Base_Table).
$this->table_title = 'Category Batch Request Details:'; // base property (untyped in Base_Table).
$this->row_id_field_name = 'category_batch_request_row_id'; // declared in base.
$this->nonce_action = 'ai_content_by_parallax_cocat_action'; // declared in base.
list( $row_id, $user_selected ) = $this->resolve_row_id(
$category_batch_request_row_id,
'category_batch_request_row_id',
'batch_request_id'
);
if ( null !== $row_id ) {
$this->category_batch_request_row_id = $row_id;
$this->user_selected = $user_selected;
}
}
/**
* Let the base modal helper know which batch request row is currently active.
*/
public function get_current_batch_request_row_id(): ?int {
return $this->category_batch_request_row_id;
}
/**
* Resolve the primary item ID from a row array.
*
* @param array $item Row item.
* @return int
*/
protected function get_row_item_id( array $item ): int {
return (int) ( $item['term_id'] ?? 0 );
}
/**
* Checkbox column.
*
* @param array $item Row item.
* @return string
*/
public function column_cb( $item ) {
$tid = (int) ( $item['term_id'] ?? 0 );
if ( $tid && $this->is_rejected( $tid ) ) {
return '';
}
return sprintf(
'',
esc_attr( (string) ( $item['term_id'] ?? '' ) )
);
}
/**
* Default column rendering.
*
* @param array $item Row item.
* @param string $column_name Column name.
*/
public function column_default( $item, $column_name ) {
$ellipsis = static function ( string $val ): string {
$short = mb_strimwidth( $val, 0, 140, '…' );
return sprintf(
'%s',
esc_attr( $val ),
esc_html( $short )
);
};
$tid = (int) ( $item['term_id'] ?? 0 );
if ( $tid && $this->is_rejected( $tid ) && ! in_array( $column_name, array( 'status', 'name' ), true ) ) {
return '';
}
switch ( $column_name ) {
case 'status':
if ( $tid && $this->is_imported( $tid ) ) {
return esc_html( Messages::get( 'details.successfully_imported' ) );
}
if ( $tid && $this->is_rejected( $tid ) ) {
return sprintf(
'%s%s',
esc_html( Messages::get( 'details.rejected' ) ),
$tid,
(int) ( $this->category_batch_request_row_id ?? 0 ),
esc_html( Messages::get( 'details.undo_reject' ) )
);
}
return esc_html( (string) ( $item['status'] ?? '' ) );
case 'name':
$tid = (int) ( $item['term_id'] ?? 0 );
$name = (string) ( $item['name'] ?? '' );
$label = '' !== $name ? $name : ( '#' . $tid );
$edit_link = $tid ? get_edit_term_link( $tid, 'product_cat' ) : '';
return $edit_link
? sprintf( '%s', esc_url( $edit_link ), esc_html( $label ) )
: esc_html( $label );
case 'current_meta_title':
return $ellipsis( (string) ( $item['current']['_parallax_meta_title'] ?? '' ) );
case 'current_meta_desc':
return $ellipsis( (string) ( $item['current']['_parallax_meta_desc'] ?? '' ) );
case 'current_desc':
return $ellipsis( (string) ( $item['current']['description'] ?? '' ) );
case 'opt_name':
return $ellipsis( (string) ( $item['optimized']['optimized_name'] ?? '' ) );
case 'opt_meta_title':
return $ellipsis( (string) ( $item['optimized']['_parallax_meta_title'] ?? '' ) );
case 'opt_meta_desc':
return $ellipsis( (string) ( $item['optimized']['_parallax_meta_desc'] ?? '' ) );
case 'opt_desc':
return $ellipsis( (string) ( $item['optimized']['short_description'] ?? '' ) );
case 'actions':
return wp_kses_post( $this->actions_html( $item ) );
case 'details':
return wp_kses_post( $this->build_view_details_link( $item ) );
default:
return isset( $item[ $column_name ] ) ? esc_html( (string) $item[ $column_name ] ) : '';
}
}
/**
* Row actions cell: Approve / Reject.
*
* @param array $item Row item.
*/
protected function actions_html( array $item ): string {
$tid = (int) ( $item['term_id'] ?? 0 );
if ( ! $tid ) {
return '';
}
if ( $this->is_imported( $tid ) ) {
return $this->build_revert_action_link( $tid, $this->category_batch_request_row_id );
}
$has_opt = ! empty( $item['optimized']['_parallax_meta_title'] )
|| ! empty( $item['optimized']['_parallax_meta_desc'] )
|| ! empty( $item['optimized']['short_description'] )
|| ! empty( $item['optimized']['description'] );
if ( ! $has_opt && ( $item['status'] ?? '' ) !== 'Ready' ) {
return '' . esc_html( Messages::get( 'details.processing_category' ) ) . '';
} elseif ( ! $has_opt ) {
return '' . esc_html( Messages::get( 'details.optimization_failed' ) ) . '';
}
$nonce = wp_create_nonce( $this->nonce_action );
$base_url = $this->get_approval_import_base_url();
$approve_url = add_query_arg(
array(
$this->action_param => 'approve',
'term_id' => $tid,
'category_batch_request_row_id' => (int) ( $this->category_batch_request_row_id ?? 0 ),
'_wpnonce' => $nonce,
),
$base_url
);
$reject_url = add_query_arg(
array(
$this->action_param => 'reject',
'term_id' => $tid,
'category_batch_request_row_id' => (int) ( $this->category_batch_request_row_id ?? 0 ),
'_wpnonce' => $nonce,
),
$base_url
);
$links = $this->build_single_item_action_links(
'category',
$tid,
$this->category_batch_request_row_id,
Messages::get( 'details.import_data' ),
Messages::get( 'details.reject' ),
array(
'approve' => $approve_url,
'reject' => $reject_url,
)
);
return implode( ' ', $links );
}
/**
* Provide the "Edit Category" link data for a row in the View Details panel.
*
* @param array $item Current row item.
* @return array{url?: string, label?: string}
*/
protected function get_item_edit_link_data( array $item ): array {
$term_id = (int) ( $item['term_id'] ?? 0 );
if ( ! $term_id ) {
return array();
}
$edit_url = get_edit_term_link( $term_id, 'product_cat' );
if ( empty( $edit_url ) || is_wp_error( $edit_url ) ) {
return array();
}
$edit_url = esc_url_raw( (string) $edit_url );
if ( '' === $edit_url ) {
return array();
}
return array(
'url' => $edit_url,
'label' => Messages::get( 'details.edit_category' ),
);
}
/**
* Empty state message with optional suggestion link.
*/
public function no_items() {
echo esc_html( Messages::get( 'details.no_categories' ) );
if ( $this->suggest_populated_batch_request_row_id ) {
printf(
wp_kses_post( Messages::raw( 'details.try_recent' ) ),
esc_url(
add_query_arg(
array(
'category_batch_request_row_id' => $this->suggest_populated_batch_request_row_id,
)
)
)
);
}
}
/**
* Top-left controls in the table nav: batch request selector.
*/
protected function render_batch_request_select(): void {
$batch_requests = $this->get_batch_requests_by_types(
array( 'category', 'categories' ),
array( 'id', 'batch_request_id', 'status', 'type', 'metadata', 'created_at' )
);
$this->category_batch_request_row_id = $this->choose_default_batch_request_row_id(
$this->category_batch_request_row_id,
$batch_requests,
function ( array $meta ): array {
$ids = $this->get_item_ids_from_metadata( $meta );
return $ids ? $ids : $this->extract_term_ids_from_metadata( $meta );
}
);
$this->render_batch_request_select_control(
$batch_requests,
'wc-parallax-category-batch-request-select',
'category_batch_request_row_id',
Messages::get( 'details.batch_request_label' ),
Messages::get( 'details.choose_category' ),
$this->category_batch_request_row_id,
$this->nonce_action
);
}
/**
* Main data loader.
*/
public function prepare_items() {
$this->process_actions();
$columns = $this->get_columns();
$hidden = array();
$sortable = $this->get_sortable_columns();
$this->_column_headers = array( $columns, $hidden, $sortable );
$per_page = $this->get_items_per_page( 'ai_content_by_parallax_batch_request_category_details_per_page', 20 );
$current_page = $this->get_pagenum();
// If no batch request was explicitly selected and no param is present, pick a sensible default for initial load.
if ( ! $this->category_batch_request_row_id ) {
$batch_requests = $this->get_batch_requests_by_types( array( 'category', 'categories' ), array( 'id', 'metadata' ) );
$this->category_batch_request_row_id = $this->choose_default_batch_request_row_id(
$this->category_batch_request_row_id,
$batch_requests,
function ( array $meta ): array {
$ids = $this->get_item_ids_from_metadata( $meta );
return $ids ? $ids : $this->extract_term_ids_from_metadata( $meta );
}
);
$this->write_to_logger(
'prepare_items(): defaulted category_batch_request_row_id='
. $this->category_batch_request_row_id
);
}
// Load batch request row & metadata (IDs from metadata; optimized values from API).
$batch_request_result = $this->category_batch_request_row_id
? BatchRequest::get( $this->category_batch_request_row_id )
: null;
$this->batch_request_row = $batch_request_result ? $batch_request_result : array();
$metadata = $this->normalize_metadata( $this->batch_request_row['metadata'] ?? array() );
$batch_request_status = strtolower( (string) ( $this->batch_request_row['status'] ?? 'processing' ) );
$remote = (string) ( $this->batch_request_row['batch_request_id'] ?? '' );
$batch_request_type_raw = (string) ( $this->batch_request_row['type'] ?? 'category' );
$batch_request_type = in_array( strtolower( $batch_request_type_raw ), array( 'category', 'categories' ), true )
? 'category'
: 'product';
// Primary: item_ids from metadata (global order for API).
$item_ids = $this->get_item_ids_from_metadata( $metadata );
if ( $this->category_batch_request_row_id && $item_ids ) {
$this->cache_batch_request_item_ids( (int) $this->category_batch_request_row_id, $item_ids );
}
$this->write_to_logger(
sprintf(
'prepare_items(): category_batch_request_row_id=%s remote=%s type=%s item_ids_count=%d',
$this->category_batch_request_row_id,
$remote,
$batch_request_type,
count( $item_ids )
)
);
if ( $item_ids ) {
$sample = array_slice( $item_ids, 0, 5 );
$this->write_to_logger( 'prepare_items(): item_ids sample first 5 = ' . implode( ',', $sample ) );
}
// 1) Determine the set of term IDs to show.
$term_ids = $item_ids ? $item_ids : $this->extract_term_ids_from_metadata( $metadata );
// Suggest a populated batch request if user selected an empty one.
if ( $this->user_selected && ! $term_ids ) {
$this->suggest_populated_batch_request_row_id = $this->find_populated_batch_request_row_id(
array( 'category', 'categories' ),
function ( array $meta ): array {
$ids = $this->get_item_ids_from_metadata( $meta );
return $ids ? $ids : $this->extract_term_ids_from_metadata( $meta );
}
);
}
// 2) Paginate IDs.
$total_items = count( $term_ids );
$offset = max( 0, ( $current_page - 1 ) * $per_page );
$page_ids = array_slice( $term_ids, $offset, $per_page );
// 3) Build rows with CURRENT values from WP.
$rows = array();
foreach ( $page_ids as $term_id ) {
$term = get_term( (int) $term_id, 'product_cat' );
if ( ! $term || is_wp_error( $term ) ) {
continue;
}
$rows[ $term_id ] = array(
'term_id' => (int) $term_id,
'name' => (string) $term->name,
'is_imported' => false,
'current' => array(
'_parallax_meta_title' => (string) get_term_meta( $term_id, '_parallax_meta_title', true ),
'_parallax_meta_desc' => (string) get_term_meta( $term_id, '_parallax_meta_desc', true ),
'short_description' => (string) get_term_meta( $term_id, '_parallax_short_desc', true ),
'description' => (string) $term->description,
),
'optimized' => array(
'_parallax_meta_title' => '',
'_parallax_meta_desc' => '',
'short_description' => '',
'description' => '',
'optimized_name' => '',
),
'status' => 'Pending',
);
}
// 4) Pull optimized values directly from API and apply (map by global index → item_ids).
if (
'' !== $remote
&& in_array( $batch_request_status, BatchRequestStatus::api_fetchable(), true )
&& $page_ids
) {
$this->hydrate_rows_from_api_paged_json( $remote, $batch_request_type, $rows, $item_ids );
}
// 5) Status: ready if optimized exists OR batch request status is completed-ish.
foreach ( $rows as $tid => &$r ) {
$is_imported = $this->is_imported( (int) $tid );
$has_opt = ! empty( $r['optimized']['_parallax_meta_title'] )
|| ! empty( $r['optimized']['_parallax_meta_desc'] )
|| ! empty( $r['optimized']['short_description'] )
|| ! empty( $r['optimized']['description'] );
if ( $has_opt ) {
$r['status'] = 'Ready';
} elseif ( in_array( $batch_request_status, BatchRequestStatus::hydration_eligible(), true ) ) {
$r['status'] = 'Ready';
} else {
$r['status'] = $batch_request_status ? ucwords( str_replace( '_', ' ', $batch_request_status ) ) : 'Processing';
}
// If previously imported, override display status.
if ( $is_imported ) {
$r['status'] = Messages::get( 'details.successfully_imported' );
}
$r['is_imported'] = $is_imported;
}
unset( $r );
$this->items = array_values( $rows );
$this->set_pagination_args(
array(
'total_items' => $total_items,
'per_page' => $per_page,
'total_pages' => max( 1, (int) ceil( $total_items / $per_page ) ),
)
);
}
/**
* Extract item IDs (term IDs) from metadata using a prioritized set of keys.
*
* @param array $meta Metadata array.
* @return array Array of term IDs.
*/
protected function get_item_ids_from_metadata( array $meta ): array {
$keys = array( 'item_ids', 'items', 'ids', 'term_ids', 'category_ids', 'categories', 'targets' );
foreach ( $keys as $k ) {
if ( ! isset( $meta[ $k ] ) ) {
continue;
}
$cand = $meta[ $k ];
// Special case for 'targets' key, if targets => categories then use that for IDs.
if ( 'targets' === $k && is_array( $cand ) && isset( $cand['categories'] ) ) {
$cand = $cand['categories'];
}
$ids = $this->coerce_ids( $cand );
if ( $ids ) {
$this->write_to_logger(
'get_item_ids_from_metadata(): using key '
. $k
. ' count='
. count( $ids )
);
return array_values( $ids );
}
}
return array();
}
/**
* Coerce input data to a list of term IDs.
*
* @param mixed $value Input value.
* @return int[]
*/
protected function coerce_ids( $value ): array {
$out = array();
if ( is_numeric( $value ) ) {
$out[] = (int) $value;
} elseif ( is_string( $value ) && preg_match_all( '/\d+/', $value, $matches ) ) {
foreach ( $matches[0] as $num ) {
$out[] = (int) $num;
}
} elseif ( is_object( $value ) ) {
$out = array_merge( $out, $this->coerce_ids( get_object_vars( $value ) ) );
} elseif ( is_array( $value ) ) {
foreach ( $value as $v ) {
if ( is_numeric( $v ) ) {
$out[] = (int) $v;
continue;
}
if ( is_object( $v ) ) {
$v = get_object_vars( $v );
}
if ( is_array( $v ) ) {
foreach ( array( 'id', 'term_id', 'category_id' ) as $ik ) {
if ( isset( $v[ $ik ] ) && is_numeric( $v[ $ik ] ) ) {
$out[] = (int) $v[ $ik ];
continue 2;
}
}
}
}
}
$out = array_values(
array_unique(
array_filter(
$out,
static function ( $id ): bool {
return is_int( $id ) && $id > 0;
}
)
)
);
return $out;
}
/**
* Fallback extractor: deep-scan metadata for term IDs that actually exist.
*
* @param array $meta Metadata array.
* @return array Array of term IDs.
*/
protected function extract_term_ids_from_metadata( array $meta ): array {
foreach ( array( 'term_ids', 'category_ids', 'categories', 'ids', 'targets' ) as $key ) {
if ( isset( $meta[ $key ] ) ) {
$ids = $this->coerce_ids( $meta[ $key ] );
if ( $ids ) {
return $ids;
}
if ( 'targets' === $key && ! empty( $meta['targets']['categories'] ) ) {
$ids = $this->coerce_ids( $meta['targets']['categories'] );
if ( $ids ) {
return $ids;
}
}
}
}
// Deep-scan fallback.
$candidates = array();
$this->collect_ints_recursive( $meta, $candidates );
$candidates = array_values( array_unique( array_filter( $candidates, 'is_int' ) ) );
$valid = array();
foreach ( $candidates as $maybe_id ) {
$term = get_term( (int) $maybe_id, 'product_cat' );
if ( $term && ! is_wp_error( $term ) && (int) $term->term_id === (int) $maybe_id ) {
$valid[] = (int) $maybe_id;
}
if ( count( $valid ) > 1000 ) {
break;
}
}
return array_values( array_unique( $valid ) );
}
/**
* Normalize the API payload into our standard shape.
*
* @param array $payload Raw API payload.
* @return array Normalized payload.
*/
protected function normalize_result_payload( array $payload ): array {
if ( isset( $payload['result'] ) && is_array( $payload['result'] ) ) {
$payload = $payload['result'];
}
$title = $payload['optimizedMetaTitle'] ?? '';
$desc = $payload['optimizedMetaDescription'] ?? '';
$payload_short = $payload['optimizedShortDescription']
?? $payload['optimizedCategoryShortDescription']
?? '';
$short = $payload_short;
$primary_long = $payload['optimizedDescription']
?? $payload['optimizedLongDescription']
?? $payload['optimizedCategoryDescription']
?? $payload['optimizedCategoryLongDescription']
?? '';
$long = $primary_long;
$name = $payload['optimizedName'] ?? $payload['optimizedCategoryName'] ?? '';
// Gemini SEO rating fields (may be absent on legacy payloads).
$original_rating = $payload['originalRating'] ?? null;
$optimized_rating = $payload['optimizedRating'] ?? null;
$rating_expl = $payload['ratingImprovementExplanation'] ?? '';
// Alternative containers.
$fields = isset( $payload['fields'] ) && is_array( $payload['fields'] ) ? $payload['fields'] : null;
$attributes = isset( $payload['attributes'] ) && is_array( $payload['attributes'] ) ? $payload['attributes'] : null;
$seo = isset( $payload['seo'] ) && is_array( $payload['seo'] ) ? $payload['seo'] : null;
$data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : null;
$get = static function ( $src, array $keys ): string {
if ( ! is_array( $src ) ) {
return '';
}
foreach ( $keys as $k ) {
if ( isset( $src[ $k ] ) && '' !== $src[ $k ] ) {
return (string) $src[ $k ];
}
}
return '';
};
if ( null === $title || '' === $title ) {
$title = $this->first_non_empty(
$get,
array( $payload, $fields, $attributes, $seo, $data ),
array( '_parallax_meta_title', 'title', 'meta_title', 'seo_title' )
);
}
if ( null === $desc || '' === $desc ) {
$desc = $this->first_non_empty(
$get,
array( $payload, $fields, $attributes, $seo, $data ),
array(
'_parallax_meta_desc',
'meta_desc',
'meta_description',
'seo_description',
'description_short',
)
);
}
if ( null === $short || '' === $short ) {
$short = $this->first_non_empty(
$get,
array( $payload, $fields, $attributes, $data ),
array( 'short_description', 'description_short', 'excerpt', 'summary' )
);
}
if ( null === $long || '' === $long ) {
$long = $this->first_non_empty(
$get,
array( $payload, $fields, $attributes, $data ),
array( 'description', 'long_description', 'full_description', 'body', 'content' )
);
}
if ( null === $name || '' === $name ) {
$name = $this->first_non_empty(
$get,
array( $payload, $fields, $attributes, $data ),
array( 'optimizedName', 'optimizedCategoryName', 'categoryName', 'name' )
);
}
$title = trim( (string) $title );
$desc = trim( (string) $desc );
$short = trim( (string) $short );
$long = trim( (string) $long );
$name = trim( (string) $name );
$term_description = trim( (string) $primary_long );
if ( '' === $term_description ) {
$term_description = $short;
}
if ( $this->enabled_debug_logging ) {
if ( '' === $long ) {
$this->write_to_logger(
'normalize_result_payload: long description missing; leaving empty'
);
}
$this->write_to_logger(
'normalize_result_payload: lens title=' . strlen( $title )
. ' desc=' . strlen( $desc )
. ' short=' . strlen( $short )
. ' long=' . strlen( $long )
. ' term_desc=' . strlen( $term_description )
. ' name=' . strlen( $name )
);
}
return array(
'_parallax_meta_title' => $title,
'_parallax_meta_desc' => $desc,
'short_description' => $short,
'description' => $long,
'term_description' => $term_description,
'optimized_name' => $name,
'original_rating' => $this->stringify_rating( $original_rating ),
'seo_rating' => $this->stringify_rating( $optimized_rating ),
'rating_improvement_explanation' => is_scalar( $rating_expl ) ? trim( (string) $rating_expl ) : '',
);
}
/**
* Hydrate current-page rows using the batch's item_ids to map API items → 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.
* @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 {
$logged_item_keys = false;
if ( '' === $remote_batch_request_id || ! $rows ) {
return;
}
if ( ! is_array( $item_ids ) || ! $item_ids ) {
$this->write_to_logger(
'hydrate_rows_from_api_paged_json(): missing item_ids for batch request '
. $remote_batch_request_id
);
return;
}
for ( $p = 1; $p <= 50; $p++ ) {
$raw = 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_last_error() !== JSON_ERROR_NONE || ! 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 = is_array( $decoded['items'] ?? null ) ? $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 );
$term_id = $item_ids[ $global_index ] ?? null;
if ( ! $term_id ) {
$this->write_to_logger(
'hydrate_rows_from_api_paged_json(): no term_id for global_index=' . $global_index
);
continue;
}
$term_id = (int) $term_id;
$this->maybe_store_parallax_id( (array) $it, $term_id, $batch_request_type );
if ( ! isset( $rows[ $term_id ] ) ) {
// Not on current page.
continue;
}
if ( $this->enabled_debug_logging && ! $logged_item_keys ) {
$item = is_array( $it ) ? $it : array();
$item_keys = array_keys( $item );
$fields_keys = array();
$attributes_keys = array();
if ( isset( $item['fields'] ) && is_array( $item['fields'] ) ) {
$fields_keys = array_keys( $item['fields'] );
}
if ( isset( $item['attributes'] ) && is_array( $item['attributes'] ) ) {
$attributes_keys = array_keys( $item['attributes'] );
}
$this->write_to_logger(
'hydrate_rows_from_api_paged_json(): sample item keys=' . wp_json_encode( $item_keys )
. ' fields keys=' . wp_json_encode( $fields_keys )
. ' attributes keys=' . wp_json_encode( $attributes_keys )
);
$logged_item_keys = true;
}
$norm = $this->normalize_result_payload( (array) $it );
$title_length = strlen( $norm['_parallax_meta_title'] ?? '' );
$meta_desc_length = strlen( $norm['_parallax_meta_desc'] ?? '' );
$short_length = strlen( $norm['short_description'] ?? '' );
$desc_length = strlen( $norm['description'] ?? '' );
$this->write_to_logger(
'hydrate_rows_from_api_paged_json(): map global=' . $global_index
. ' -> term_id=' . $term_id
. ' lens T/MD/S/D=' . $title_length . '/' . $meta_desc_length . '/' . $short_length . '/' . $desc_length
);
if ( $title_length || $meta_desc_length || $short_length || $desc_length ) {
$rows[ $term_id ]['optimized'] = $norm;
$rows[ $term_id ]['status'] = 'Ready';
// Mirror the original (pre-optimization) SEO rating into the current side so
// the detail popup can render it in the Current column alongside the value.
if ( '' !== ( $norm['original_rating'] ?? '' ) ) {
$rows[ $term_id ]['current']['seo_rating'] = (string) $norm['original_rating'];
}
}
}
if ( $page_size > 0 && $page * $page_size >= $total ) {
break;
}
}
}
/**
* Approve / Reject handler for category rows.
*/
public function process_actions(): void {
// Snapshot of relevant request parameters.
$rq = array(
'method' => $this->get_request_method(),
'get' => $this->get_params_snapshot( array( $this->action_param, 'term_id', 'category_batch_request_row_id', '_wpnonce', 'action' ), 'get' ),
'post' => $this->get_params_snapshot( array( 'action', '_wpnonce', 'category_batch_request_item_ids' ), 'post' ),
);
$this->write_to_logger( 'process_actions: request snapshot ' . wp_json_encode( $rq ) );
// Accept nonce from either GET (row action link) or POST (bulk).
$nonce = $this->get_request_param( '_wpnonce' );
$nonce_ok = false;
if ( $nonce ) {
$nonce_ok = wp_verify_nonce( $nonce, $this->get_row_action_nonce_action() )
|| wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] );
}
if ( ! $nonce_ok ) {
$this->write_to_logger( 'process_actions: nonce missing/invalid - aborting' );
return;
}
// Prefer our custom GET param for row actions; fallback to list-table action.
$action = $this->get_request_param( $this->action_param ) ?? $this->current_action();
$this->write_to_logger( 'process_actions: entry, action=' . $action );
if ( ! $action ) {
return;
}
$category_batch_request_row_id = $this->get_request_int_param( 'category_batch_request_row_id' )
?? $this->category_batch_request_row_id;
$batch_request_result = $category_batch_request_row_id
? BatchRequest::get( $category_batch_request_row_id )
: null;
$batch_request = $batch_request_result ? $batch_request_result : array();
$remote = (string) ( $batch_request['batch_request_id'] ?? '' );
$otype = (string) ( $batch_request['type'] ?? 'category' );
$otype = in_array( strtolower( $otype ), array( 'category', 'categories' ), true ) ? 'category' : 'product';
$metadata = $this->normalize_metadata( $batch_request['metadata'] ?? array() );
// Load cached item_ids if possible; fallback to metadata.
$item_ids = $category_batch_request_row_id
? $this->get_cached_batch_request_item_ids( $category_batch_request_row_id )
: array();
if ( ! $item_ids ) {
$item_ids = $this->get_item_ids_from_metadata( $metadata );
if ( $category_batch_request_row_id && $item_ids ) {
$this->cache_batch_request_item_ids( $category_batch_request_row_id, $item_ids );
}
}
$position_lookup = array();
foreach ( $item_ids as $idx => $id ) {
$tid = (int) $id;
if ( $tid > 0 && ! isset( $position_lookup[ $tid ] ) ) {
$position_lookup[ $tid ] = (int) $idx;
}
}
$this->write_to_logger(
'process_actions: category_batch_request_row_id='
. $category_batch_request_row_id
. ' remote=' . $remote
. ' type=' . $otype
. ' item_ids_count=' . count( $item_ids )
);
// Collect term IDs from request (row or bulk).
$term_ids = array();
if ( 'bulk_approve' === $action || 'bulk_reject' === $action ) {
$term_ids = $this->get_request_int_array_param( 'category_batch_request_item_ids' );
} elseif ( in_array( $action, array( 'approve', 'reject', 'revert', 'undo_reject' ), true ) ) {
$selected_id = $this->get_request_int_param( 'term_id' );
$term_ids = $selected_id ? array( $selected_id ) : array();
}
$term_ids = array_values( array_filter( $term_ids ) );
$this->write_to_logger( 'process_actions: term_ids=' . wp_json_encode( $term_ids ) );
// BULK REJECT: delete the whole batch request and bounce.
if ( 'bulk_reject' === $action ) {
$this->write_to_logger( 'process_actions: BULK REJECT triggered' );
$this->delete_whole_batch_request( $category_batch_request_row_id );
if ( $this->is_ajax_context() ) {
return;
}
$this->redirect_back(
array( $this->action_param, 'term_id', '_wpnonce', 'category_batch_request_row_id' )
);
}
// SINGLE REJECT: mark item rejected, notify API only; batch and row are preserved.
if ( 'reject' === $action ) {
$this->write_to_logger( 'process_actions: SINGLE REJECT - notify only, batch preserved' );
foreach ( $term_ids as $tid ) {
$this->set_rejected_status( (int) $tid );
}
if ( $this->is_ajax_context() ) {
return;
}
$this->redirect_back(
array( $this->action_param, 'term_id', '_wpnonce', 'category_batch_request_row_id' )
);
}
// UNDO REJECT: clear the rejected flag; item returns to its ready/unimported state.
if ( 'undo_reject' === $action && $term_ids ) {
foreach ( $term_ids as $tid ) {
$this->clear_rejected_status( (int) $tid );
}
if ( $this->is_ajax_context() ) {
return;
}
$add = $category_batch_request_row_id ? array( 'category_batch_request_row_id' => $category_batch_request_row_id ) : array();
$this->redirect_back( array( $this->action_param, 'term_id', '_wpnonce' ), $add );
}
// REVERT: restore backup/snapshot per selected category.
if ( 'revert' === $action && $term_ids ) {
foreach ( $term_ids as $tid ) {
$tid = (int) $tid;
$ok = $this->revert_import( $tid );
$this->write_to_logger( 'process_actions: REVERT tid=' . $tid . ' ok=' . ( $ok ? 'yes' : 'no' ) );
}
if ( $this->is_ajax_context() ) {
return;
}
$add = $category_batch_request_row_id ? array( 'category_batch_request_row_id' => $category_batch_request_row_id ) : array();
$this->redirect_back( array( $this->action_param, 'term_id', '_wpnonce' ), $add );
}
// APPROVE: write optimized values per category.
if ( ( 'approve' === $action || 'bulk_approve' === $action ) && '' !== $remote && $term_ids ) {
// process_actions fires before any term mutations for all import entry points (UI bulk, single, CLI),
// so take a one-time snapshot here to capture current state and avoid duplicate snapshots per run.
$this->ensure_pre_import_snapshots( $term_ids );
$this->write_to_logger( 'process_actions: APPROVE start (remote=' . $remote . ')' );
$optimized_cache = array();
if ( 'bulk_approve' === $action ) {
$optimized_cache = $this->fetch_bulk_optimized_by_terms(
$remote,
$otype,
$item_ids,
$term_ids,
$position_lookup
);
$this->write_to_logger(
'approve: prefetched optimized payloads count=' . count( $optimized_cache )
);
}
foreach ( $term_ids as $tid ) {
$tid = (int) $tid;
if ( $this->is_rejected( $tid ) ) {
$this->write_to_logger( 'approve: skipping rejected tid=' . $tid );
continue;
}
// BEFORE values.
$before_title = (string) get_term_meta( $tid, '_parallax_meta_title', true );
$before_desc = (string) get_term_meta( $tid, '_parallax_meta_desc', true );
$before_short = (string) get_term_meta( $tid, '_parallax_short_desc', true );
$term_obj = get_term( $tid );
$taxonomy = ( $term_obj && ! is_wp_error( $term_obj ) && isset( $term_obj->taxonomy ) )
? (string) $term_obj->taxonomy
: 'product_cat';
$before_long = ( $term_obj && ! is_wp_error( $term_obj ) )
? (string) $term_obj->description
: '';
$this->write_to_logger(
sprintf(
'[wc-parallax] approve: BEFORE tid=%d len(title)=%d len(desc)=%d len(short)=%d len(long)=%d',
$tid,
strlen( $before_title ),
strlen( $before_desc ),
strlen( $before_short ),
strlen( $before_long )
)
);
$this->write_to_logger(
'approve: term description len BEFORE tid=' . $tid . ' len=' . strlen( $before_long )
);
// Deterministic single fetch by position.
if ( isset( $optimized_cache[ $tid ] ) ) {
$opt = $optimized_cache[ $tid ];
} else {
$opt = $this->fetch_single_optimized_by_position(
$remote,
$otype,
$item_ids,
$tid,
$position_lookup
);
}
$title = isset( $opt['_parallax_meta_title'] ) ? trim( (string) $opt['_parallax_meta_title'] ) : '';
$desc = isset( $opt['_parallax_meta_desc'] ) ? trim( (string) $opt['_parallax_meta_desc'] ) : '';
$short = isset( $opt['short_description'] ) ? trim( (string) $opt['short_description'] ) : '';
$term_description = isset( $opt['term_description'] ) ? trim( (string) $opt['term_description'] ) : '';
if ( '' !== $term_description ) {
$term_description = wp_kses_post( $term_description );
}
$oname = isset( $opt['optimized_name'] ) ? trim( (string) $opt['optimized_name'] ) : '';
$this->write_to_logger(
'approve: FETCH tid=' . $tid
. ' lens title=' . strlen( $title )
. ' desc=' . strlen( $desc )
. ' short=' . strlen( $short )
. ' term_desc=' . strlen( $term_description )
. ' name=' . strlen( $oname )
);
$this->write_to_logger(
'approve: incoming term description len tid=' . $tid . ' len=' . strlen( $term_description )
);
$wrote = false;
$ret_title = null;
$ret_desc = null;
$ret_short = null;
$safe_title = Sanitize::truncate_meta( $title, 'meta_title' );
if ( '' !== $title ) {
$ret_title = update_term_meta( $tid, '_parallax_meta_title', wp_kses_post( $safe_title ) );
$wrote = $wrote || (bool) $ret_title;
$this->write_to_logger(
'approve: update_term_meta(title) tid='
. $tid
. ' return='
. $ret_title
);
}
if ( '' !== $desc ) {
$ret_desc = update_term_meta( $tid, '_parallax_meta_desc', wp_kses_post( $desc ) );
$wrote = $wrote || (bool) $ret_desc;
$this->write_to_logger(
'approve: update_term_meta(desc) tid='
. $tid
. ' return='
. $ret_desc
);
}
if ( '' !== $short ) {
$ret_short = update_term_meta( $tid, '_parallax_short_desc', wp_kses_post( $short ) );
$wrote = $wrote || (bool) $ret_short;
$this->write_to_logger(
'approve: update_term_meta(short) tid='
. $tid
. ' return='
. $ret_short
);
}
// Rename category if provided.
if ( '' !== $oname ) {
// Note: do NOT set slug unless you explicitly want to change URLs.
$res_name = wp_update_term(
$tid,
$taxonomy,
array( 'name' => wp_kses_post( $oname ) )
);
if ( is_wp_error( $res_name ) ) {
$this->write_to_logger(
'approve: wp_update_term(name) ERROR tid='
. $tid
. ' : '
. $res_name->get_error_message()
);
} else {
$wrote = true;
$this->write_to_logger(
'approve: wp_update_term(name) tid='
. $tid
. ' ok new_name="'
. $oname
. '"'
);
}
}
if ( '' !== $term_description ) {
$res = wp_update_term(
$tid,
$taxonomy,
array( 'description' => $term_description )
);
if ( is_wp_error( $res ) ) {
$this->write_to_logger(
'approve: wp_update_term(description) ERROR tid='
. $tid
. ' : '
. $res->get_error_message()
);
} else {
$wrote = true;
$this->write_to_logger(
'approve: wp_update_term(description) tid='
. $tid
. ' ok'
);
}
} elseif ( '' === $term_description ) {
$this->write_to_logger(
'approve: optimized term description missing; skip wp_update_term(description) tid=' . $tid
);
}
// HARD FALLBACK: if meta still missing, write directly to wp_termmeta.
$after_title1 = (string) get_term_meta( $tid, '_parallax_meta_title', true );
$after_desc1 = (string) get_term_meta( $tid, '_parallax_meta_desc', true );
$after_short1 = (string) get_term_meta( $tid, '_parallax_short_desc', true );
if ( '' !== $title && '' === $after_title1 ) {
$ok = $this->force_write_termmeta( $tid, '_parallax_meta_title', $safe_title );
$wrote = $wrote || $ok;
}
if ( '' !== $desc && '' === $after_desc1 ) {
$ok = $this->force_write_termmeta( $tid, '_parallax_meta_desc', $desc );
$wrote = $wrote || $ok;
}
if ( '' !== $short && '' === $after_short1 ) {
$ok = $this->force_write_termmeta( $tid, '_parallax_short_desc', $short );
$wrote = $wrote || $ok;
}
// Clear caches aggressively (term_meta + terms).
if ( function_exists( 'wp_cache_delete' ) ) {
wp_cache_delete( $tid, 'term_meta' );
wp_cache_delete( 'all_ids', 'terms' );
wp_cache_delete( $tid, 'terms' );
}
clean_term_cache( $tid, $taxonomy );
// AFTER values.
$after_title = (string) get_term_meta( $tid, '_parallax_meta_title', true );
$after_desc = (string) get_term_meta( $tid, '_parallax_meta_desc', true );
$after_short = (string) get_term_meta( $tid, '_parallax_short_desc', true );
$term_obj2 = get_term( $tid, $taxonomy );
$after_long = ( $term_obj2 && ! is_wp_error( $term_obj2 ) )
? (string) $term_obj2->description
: '';
$this->write_to_logger(
sprintf(
'[wc-parallax] approve: AFTER tid=%d wrote=%s len(title)=%d len(desc)=%d len(short)=%d len(long)=%d',
$tid,
$wrote ? 'yes' : 'no',
strlen( $after_title ),
strlen( $after_desc ),
strlen( $after_short ),
strlen( $after_long )
)
);
$this->write_to_logger(
'approve: term description len AFTER tid=' . $tid . ' len=' . strlen( $after_long )
);
$this->debug_term_meta_db(
$tid,
is_int( $ret_title ) ? $ret_title : null,
is_int( $ret_desc ) ? $ret_desc : null,
is_int( $ret_short ) ? $ret_short : null
);
if ( $wrote ) {
$this->set_imported_status( $tid );
}
}
}
if ( $this->is_ajax_context() ) {
return;
}
$add = $category_batch_request_row_id
? array( 'category_batch_request_row_id' => $category_batch_request_row_id )
: array();
$this->redirect_back( array( $this->action_param, 'term_id', '_wpnonce' ), $add );
}
/**
* Capture a fresh original snapshot for categories about to be imported.
* Runs once per import execution to avoid duplicate snapshots across hooks.
*
* @param int[] $term_ids Term IDs slated for import.
*/
protected function ensure_pre_import_snapshots( array $term_ids ): void {
static $snapshot_taken = false;
if ( $snapshot_taken ) {
return;
}
$snapshot_taken = true;
foreach ( array_unique( array_map( 'absint', $term_ids ) ) as $term_id ) {
if ( $term_id <= 0 ) {
continue;
}
$term = get_term( $term_id );
$taxonomy = ( $term && ! is_wp_error( $term ) && isset( $term->taxonomy ) ) ? (string) $term->taxonomy : 'product_cat';
// Force a new snapshot immediately before import to preserve current category data.
OriginalSnapshot::save_original_if_missing_term( $term_id, $taxonomy, true );
}
}
/**
* Restore previously captured values for a category.
*
* @param int $term_id Term id to restore.
* @return bool
*/
protected function revert_import( int $term_id ): bool {
$term = get_term( $term_id );
$taxonomy = ( $term && ! is_wp_error( $term ) && isset( $term->taxonomy ) )
? (string) $term->taxonomy
: 'product_cat';
$restored = OriginalSnapshot::restore_term( $term_id, $taxonomy );
$this->clear_import_markers( $term_id );
return $restored;
}
/**
* Fetch term meta for categories.
*
* @param int $item_id Item id.
* @param string $key Meta key.
* @return mixed
*/
protected function get_item_meta( int $item_id, string $key ) {
return get_term_meta( $item_id, $key, true );
}
/**
* Update term meta for categories.
*
* @param int $item_id Item id.
* @param string $key Meta key.
* @param mixed $value Meta value.
* @return bool
*/
protected function update_item_meta( int $item_id, string $key, $value ): bool {
return (bool) update_term_meta( $item_id, $key, $value );
}
/**
* Delete term meta for categories.
*
* @param int $item_id Item id.
* @param string $key Meta key.
* @return bool
*/
protected function delete_item_meta( int $item_id, string $key ): bool {
return (bool) delete_term_meta( $item_id, $key );
}
/**
* Return current WP field values for a single category term.
*
* @param int $item_id Term ID.
* @return array
*/
protected function build_current_values( int $item_id ): array {
$term = get_term( $item_id, 'product_cat' );
if ( ! $term || is_wp_error( $term ) ) {
return array(
'_parallax_meta_title' => '',
'_parallax_meta_desc' => '',
'short_description' => '',
'description' => '',
);
}
return array(
'_parallax_meta_title' => (string) get_term_meta( $item_id, '_parallax_meta_title', true ),
'_parallax_meta_desc' => (string) get_term_meta( $item_id, '_parallax_meta_desc', true ),
'short_description' => (string) get_term_meta( $item_id, '_parallax_short_desc', true ),
'description' => (string) $term->description,
);
}
/**
* Return the category term display name.
*
* @param int $item_id Term ID.
* @return string
*/
protected function get_item_display_name( int $item_id ): string {
$term = get_term( $item_id, 'product_cat' );
return ( $term && ! is_wp_error( $term ) ) ? (string) $term->name : ( '#' . $item_id );
}
/**
* Fetch all pages and build a map term_id => normalized optimized payload,
* using the batch's item_ids to map global item position -> term_id.
*
* @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.
* @return array Map of term_id => normalized optimized payload.
*/
protected function fetch_optimized_by_item_position(
string $remote_batch_request_id,
string $batch_request_type,
array $item_ids
): array {
$out = array();
if ( '' === $remote_batch_request_id || ! $item_ids ) {
return $out;
}
for ( $p = 1; $p <= 50; $p++ ) {
$raw = 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_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
$this->write_to_logger(
'fetch_optimized_by_item_position(): JSON decode failed p='
. $p
);
break;
}
$items = is_array( $decoded['items'] ?? null ) ? $decoded['items'] : array();
$page = (int) ( $decoded['page'] ?? $p );
$page_size = (int) ( $decoded['pageSize'] ?? 0 );
$total = (int) ( $decoded['total'] ?? 0 );
$this->write_to_logger(
'fetch_optimized_by_item_position(): page=' . $page
. ' pageSize=' . $page_size
. ' total=' . $total
. ' items=' . count( $items )
);
foreach ( $items as $idx => $it ) {
$global = $this->global_index( $page, $page_size, (int) $idx );
if ( ! array_key_exists( $global, $item_ids ) ) {
$this->write_to_logger(
'fetch_optimized_by_item_position(): no term id for global index=' . $global
);
continue;
}
$term_id = (int) $item_ids[ $global ];
$norm = $this->normalize_result_payload( (array) $it );
if (
( $norm['_parallax_meta_title'] ?? '' ) !== ''
|| ( $norm['_parallax_meta_desc'] ?? '' ) !== ''
|| ( $norm['short_description'] ?? '' ) !== ''
|| ( $norm['description'] ?? '' ) !== ''
|| ( $norm['term_description'] ?? '' ) !== ''
|| ( $norm['optimized_name'] ?? '' ) !== ''
) {
$out[ $term_id ] = $norm;
} else {
$this->write_to_logger(
'fetch_optimized_by_item_position(): empty normalized payload for term_id='
. $term_id
. ' global=' . $global
);
}
}
if ( $page_size > 0 && $page * $page_size >= $total ) {
break;
}
}
return $out;
}
/**
* Fetch the optimized payload for ONE term 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 $term_id Term ID to fetch.
* @param array $position_lookup Optional precomputed position lookup map term_id => position.
* @return array Normalized optimized payload for the term, or empty array on failure.
*/
protected function fetch_single_optimized_by_position(
string $remote_batch_request_id,
string $batch_request_type,
array $item_ids,
int $term_id,
array $position_lookup = array()
): array {
$item_ids = array_values( $item_ids );
if ( '' === $remote_batch_request_id || ! $item_ids ) {
$this->write_to_logger( 'single_fetch: missing remote or empty item_ids' );
return array();
}
if ( isset( $position_lookup[ $term_id ] ) ) {
$pos = $position_lookup[ $term_id ];
} else {
$pos = array_search( $term_id, $item_ids, true );
}
if ( false === $pos ) {
$this->write_to_logger(
'single_fetch: term_id '
. $term_id
. ' not found in item_ids ('
. count( $item_ids )
. ' ids)'
);
return array();
}
$raw1 = 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_last_error() !== JSON_ERROR_NONE || ! 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) ( is_array( $d1['items'] ?? null ) ? count( $d1['items'] ) : 10 )
);
}
$total = (int) ( $d1['total'] ?? 0 );
$page = (int) floor( $pos / $page_size ) + 1;
$local = (int) ( $pos % $page_size );
$this->write_to_logger(
sprintf(
'[wc-parallax] single_fetch: tid=%d pos=%d page=%d local=%d pageSize=%d total=%d',
$term_id,
$pos,
$page,
$local,
$page_size,
$total
)
);
$raw = 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_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
$this->write_to_logger(
'single_fetch: JSON decode fail page='
. $page
. ' err='
. json_last_error_msg()
);
return array();
}
$items = is_array( $decoded['items'] ?? null ) ? $decoded['items'] : array();
$items_count = count( $items );
$page_id = (int) ( $decoded['page'] ?? $page );
$page_size = (int) ( $decoded['pageSize'] ?? $page_size );
$this->write_to_logger(
'single_fetch: fetched page=' . $page_id
. ' pageSize=' . $page_size
. ' items=' . $items_count
);
if ( ! array_key_exists( $local, $items ) ) {
$this->write_to_logger(
'single_fetch: items missing local idx '
. $local
. ' (items='
. $items_count
. ')'
);
return array();
}
$raw_item = (array) $items[ $local ];
$dbg_title = isset( $raw_item['optimizedMetaTitle'] )
? mb_substr( (string) $raw_item['optimizedMetaTitle'], 0, 80 )
: '';
$dbg_desc = isset( $raw_item['optimizedMetaDescription'] )
? mb_substr( (string) $raw_item['optimizedMetaDescription'], 0, 80 )
: '';
$dbg_short = isset( $raw_item['optimizedShortDescription'] )
? mb_substr( (string) $raw_item['optimizedShortDescription'], 0, 80 )
: ( isset( $raw_item['optimizedCategoryShortDescription'] )
? mb_substr( (string) $raw_item['optimizedCategoryShortDescription'], 0, 80 )
: ''
);
$dbg_long_desc = isset( $raw_item['optimizedDescription'] )
? mb_substr( (string) $raw_item['optimizedDescription'], 0, 80 )
: '';
$this->write_to_logger(
'single_fetch: rawItem[' . $local
. '] title="'
. $dbg_title
. '" desc="'
. $dbg_desc
. '" short="'
. $dbg_short
. '" long="'
. $dbg_long_desc
. '"'
);
$norm = $this->normalize_result_payload( $raw_item );
$title_length = strlen( $norm['_parallax_meta_title'] ?? '' );
$desc_length = strlen( $norm['_parallax_meta_desc'] ?? '' );
$short_length = strlen( $norm['short_description'] ?? '' );
$desc_long_length = strlen( $norm['description'] ?? '' );
$term_desc_length = strlen( $norm['term_description'] ?? '' );
$this->write_to_logger(
'single_fetch: norm lens title='
. $title_length
. ' desc='
. $desc_length
. ' short='
. $short_length
. ' long='
. $desc_long_length
. ' term_desc='
. $term_desc_length
);
return $norm;
}
/**
* Fetch optimized payloads for a set of term 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_term_ids Term IDs to fetch.
* @param int[] $position_lookup Optional precomputed position lookup map term_id => position.
* @return array Map of term_id => normalized optimized payload.
*/
protected function fetch_bulk_optimized_by_terms(
string $remote_batch_request_id,
string $batch_request_type,
array $item_ids,
array $target_term_ids,
array $position_lookup
): array {
return $this->fetch_bulk_optimized_by_targets(
$remote_batch_request_id,
$batch_request_type,
$item_ids,
$target_term_ids,
$position_lookup
);
}
/**
* Delete the whole batch request row (and known related items) safely.
*
* @param int|null $category_batch_request_row_id Batch request row ID.
* @return void
*/
protected function delete_whole_batch_request( ?int $category_batch_request_row_id ): void {
if ( ! $category_batch_request_row_id ) {
return;
}
try {
if ( method_exists( '\AIContentByParallax\Models\BatchRequest', 'delete_with_items' ) ) {
BatchRequest::delete_with_items( $category_batch_request_row_id );
} elseif ( method_exists( '\AIContentByParallax\Models\BatchRequest', 'delete' ) ) {
BatchRequest::delete( $category_batch_request_row_id );
}
$this->write_to_logger(
'delete_whole_batch_request(): deleted category_batch_request_row_id='
. $category_batch_request_row_id
);
} catch ( \Throwable $e ) {
$this->write_to_logger(
'delete_whole_batch_request(): failed to delete via model: '
. $e->getMessage()
);
}
}
/**
* Build a resolver for terms by name/slug (kept for completeness - not used in main flow).
*
* @param int[] $term_ids Term IDs to build resolver for.
* @return array Map of 'n:{normalized_name}' or 's:{normalized_slug}' => term_id.
*/
protected function build_resolver_for_terms( array $term_ids ): array {
$resolver = array();
foreach ( $term_ids as $tid ) {
$term = get_term( (int) $tid, 'product_cat' );
if ( $term && ! is_wp_error( $term ) ) {
$name = (string) $term->name;
$slug = (string) $term->slug;
if ( '' !== $name ) {
$resolver[ 'n:' . $this->norm_key( $name ) ] = (int) $tid;
}
if ( '' !== $slug ) {
$resolver[ 's:' . $this->norm_key( $slug ) ] = (int) $tid;
}
}
}
return $resolver;
}
/**
* Deep diagnostics for term meta writes (meta API; avoids direct DB reads).
*
* @param int $term_id Term ID.
* @param int|null $meta_id_title Meta ID for title (if known).
* @param int|null $meta_id_desc Meta ID for description (if known).
* @param int|null $meta_id_short Meta ID for short description (if known).
* @return void
*/
protected function debug_term_meta_db( int $term_id, ?int $meta_id_title, ?int $meta_id_desc, ?int $meta_id_short = null ): void {
if ( 0 >= $term_id ) {
$this->write_to_logger( '[debug_term_meta_db] invalid term_id=' . (string) $term_id );
return;
}
$keys = array(
'_parallax_meta_title',
'_parallax_meta_desc',
'_parallax_short_desc',
);
// Prime cache once; get_term_meta() will be cheap after this.
update_termmeta_cache( array( $term_id ) );
$meta_rows = array();
foreach ( $keys as $key ) {
$values = get_term_meta( $term_id, $key, false ); // false = all values.
$meta_rows[ $key ] = array(
'count' => is_array( $values ) ? count( $values ) : 0,
'values' => $values,
);
}
$this->write_to_logger(
'[debug_term_meta_db] term_id='
. (string) $term_id
. ' meta_id_title='
. ( null === $meta_id_title ? 'null' : (string) $meta_id_title )
. ' meta_id_desc='
. ( null === $meta_id_desc ? 'null' : (string) $meta_id_desc )
. ' meta_id_short='
. ( null === $meta_id_short ? 'null' : (string) $meta_id_short )
. ' rows='
. wp_json_encode( $meta_rows )
);
}
/**
* Hard fallback: write term meta via WordPress meta API (avoids direct DB writes).
*
* @param int $term_id Term ID.
* @param string $meta_key Meta key.
* @param string $value Meta value.
* @return bool True on success, false on failure.
*/
protected function force_write_termmeta( int $term_id, string $meta_key, string $value ): bool {
$meta_key = (string) $meta_key;
$value = wp_kses_post( $value );
if ( 0 >= $term_id || '' === $meta_key ) {
$this->write_to_logger(
'force_write_termmeta: invalid args term_id=' . (string) $term_id . ' key=' . $meta_key
);
return false;
}
/*
* update_term_meta() returns:
* - true if updated
* - int meta_id if added (didn't exist)
* - false if failure OR if the value is unchanged
*
* To avoid treating "unchanged" as failure, compare first.
*/
$current = get_term_meta( $term_id, $meta_key, true );
if ( $current === $value ) {
$this->write_to_logger(
'force_write_termmeta: no-op (unchanged) term_id=' . (string) $term_id . ' key=' . $meta_key
);
return true;
}
$result = update_term_meta( $term_id, $meta_key, $value );
if ( false === $result ) {
$this->write_to_logger(
'force_write_termmeta: update_term_meta failed term_id=' . (string) $term_id . ' key=' . $meta_key
);
return false;
}
// If $result is int, WP added a new meta row; if true, it updated an existing row.
$this->write_to_logger(
'force_write_termmeta: OK term_id=' . (string) $term_id . ' key=' . $meta_key
);
return true;
}
/**
* Ensure pagination preserves the currently selected category batch request.
*
* @return array
*/
protected function get_context_query_args(): array {
$args = parent::get_context_query_args();
if ( $this->category_batch_request_row_id ) {
$args['category_batch_request_row_id'] = (int) $this->category_batch_request_row_id;
}
return $args;
}
/**
* Optional: header buttons hooked into the modal content.
*/
protected function show_action_buttons(): void {
echo wp_kses_post(
$this->display_action_button_link(
Messages::get( 'details.import_all' ),
'#',
array(
'class' => 'button-secondary wc-parallax-run-pending-items',
'data-item-type' => $this->item_type,
'data-batch-request-id' => (int) ( $this->category_batch_request_row_id ?? 0 ),
)
)
);
echo wp_kses_post(
$this->display_action_button_link(
Messages::get( 'details.revert_batch' ),
'#',
array(
'class' => 'button-secondary wc-parallax-revert-batch',
'data-item-type' => $this->item_type,
'data-batch-request-id' => (int) ( $this->category_batch_request_row_id ?? 0 ),
)
)
);
// Import All Selected - visible only when checkboxes are ticked (JS toggles display).
echo ' ';
echo wp_kses_post(
$this->display_action_button_link(
Messages::get( 'details.import_selected' ),
'#import-selected',
array(
'data-batch-request-id' => (int) ( $this->category_batch_request_row_id ?? 0 ),
'data-item-type' => $this->item_type,
),
'button-primary wc-parallax-import-selected'
)
);
}
}