id ?? '' ) ) { return; } // "Is Optimized" column. add_filter( 'manage_edit-product_columns', array( self::class, 'add_columns' ), 20 ); add_action( 'manage_product_posts_custom_column', array( self::class, 'render_column' ), 10, 2 ); // Collapsible optimization form above the product list. add_action( 'admin_notices', array( self::class, 'render_optimize_form' ) ); // "Optimize" row action on each product. add_filter( 'post_row_actions', array( self::class, 'add_row_actions' ), 10, 2 ); // "Optimize Selected" bulk action. add_filter( 'bulk_actions-edit-product', array( self::class, 'add_bulk_actions' ) ); // Filter dropdowns for optimized / excluded status. add_action( 'restrict_manage_posts', array( self::class, 'render_filter_dropdowns' ), 10, 1 ); add_action( 'pre_get_posts', array( self::class, 'apply_filter_query' ) ); // Enqueue product-list JS/CSS bundle. add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_assets' ) ); } /** * Field definitions for the optimization form. * * Each entry maps an internal field key to its label and per-item credit cost. * * @return array */ public static function get_optimize_fields(): array { return array( 'product_title_original' => array( 'label' => __( 'Product Name', 'ai-content-by-parallax' ), 'credits' => 1, ), 'product_description_original' => array( 'label' => __( 'Product Description', 'ai-content-by-parallax' ), 'credits' => 1, ), 'meta_title' => array( 'label' => __( 'Meta Title', 'ai-content-by-parallax' ), 'credits' => 1, ), 'meta_desc' => array( 'label' => __( 'Meta Description', 'ai-content-by-parallax' ), 'credits' => 2, ), 'short_desc' => array( 'label' => __( 'Short Description', 'ai-content-by-parallax' ), 'credits' => 3, ), 'long_desc' => array( 'label' => __( 'Description', 'ai-content-by-parallax' ), 'credits' => 5, ), ); } /** * Build the admin URL for the Approval/Import tab. * * @return string Admin URL pointing at the approval/import tab. */ public static function get_approval_import_url(): string { return add_query_arg( array( 'page' => 'ai-content-by-parallax', 'tab' => 'approval_import', ), admin_url( 'admin.php' ) ); } /** * Render the collapsible optimization form above the product list. * * Displays field checkboxes with per-field credit costs, total credits * available/required, and a "Start Optimization" button. * * @return void */ public static function render_optimize_form(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $fields = self::get_optimize_fields(); $client = Client::load(); $credits = $client->get_credits(); $approval_import_url = self::get_approval_import_url(); ?>

Parallax AI SEO Optimization — Learn more & manage credits', 'ai-content-by-parallax' ), esc_url( Endpoints::get_marketing_site_url() ) ), array( 'strong' => array(), 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ) ); ?>

$field ) : ?>
admin_url( 'admin-ajax.php' ), 'approval_import_url' => self::get_approval_import_url(), 'optimize_all_nonce' => wp_create_nonce( AjaxRequestHandlers::get_nonce_action( 'optimize_all' ) ), 'submit_nonce' => wp_create_nonce( AjaxRequestHandlers::get_nonce_action( 'submit_batreq' ) ), 'get_products_nonce' => wp_create_nonce( AjaxRequestHandlers::get_nonce_action( 'get_products' ) ), 'namespace' => WCPAI_NAMESPACE, 'credits_available' => $client->get_credits(), 'is_authenticated' => $client->is_user_authenticated(), 'toggle_excluded_nonce' => wp_create_nonce( WCPAI_NAMESPACE . '_toggle_excluded' ), 'bulk_toggle_lock_nonce' => wp_create_nonce( WCPAI_NAMESPACE . '_bulk_toggle_lock' ), 'include_drafts' => Settings::include_drafts(), 'i18n_optimize_seo' => __( 'Optimize SEO', 'ai-content-by-parallax' ), 'icon_url' => WCPAI_PLUGIN_URL . 'assets/icons/parallax.svg', 'i18n' => array( 'select_field' => __( 'Please select at least one field to optimize.', 'ai-content-by-parallax' ), 'select_product' => __( 'Please select at least one product.', 'ai-content-by-parallax' ), /* translators: %s: formatted credit count */ 'confirm_optimize_all' => __( "Would you like to optimize all products?\nTotal Credits Required: %s", 'ai-content-by-parallax' ), /* translators: %d: number of products */ 'submitted_count' => __( 'Optimization submitted for %d product(s).', 'ai-content-by-parallax' ), 'submitted_all' => __( 'Optimization request submitted for all products.', 'ai-content-by-parallax' ), 'request_failed' => __( 'Optimization request failed.', 'ai-content-by-parallax' ), 'error_occurred' => __( 'An error occurred while submitting the optimization request.', 'ai-content-by-parallax' ), 'optimizing' => __( 'Optimizing\u2026', 'ai-content-by-parallax' ), 'submitted' => __( 'Submitted', 'ai-content-by-parallax' ), 'optimize' => __( 'Optimize', 'ai-content-by-parallax' ), 'optimization_failed' => __( 'Optimization failed.', 'ai-content-by-parallax' ), 'error_optimizing_product' => __( 'An error occurred while optimizing this product.', 'ai-content-by-parallax' ), 'saving' => __( 'Saving\u2026', 'ai-content-by-parallax' ), 'unlock' => __( 'Unlock', 'ai-content-by-parallax' ), 'lock' => __( 'Lock', 'ai-content-by-parallax' ), 'lock_failed' => __( 'Failed to update lock status.', 'ai-content-by-parallax' ), 'error_lock' => __( 'An error occurred while updating lock status.', 'ai-content-by-parallax' ), 'all_products' => __( 'All Products', 'ai-content-by-parallax' ), 'all_published_products' => __( 'All Published Products', 'ai-content-by-parallax' ), /* translators: %d: number of draft items excluded */ 'drafts_skipped' => __( '%d draft item(s) were excluded from this batch. Enable "Include drafts" in settings to optimize them.', 'ai-content-by-parallax' ), 'optimize_all' => __( 'Optimize All', 'ai-content-by-parallax' ), /* translators: %d: number of selected products */ 'optimize_selected' => __( 'Optimize Selected (%d)', 'ai-content-by-parallax' ), /* translators: %d: number of selected products */ 'selected_count' => __( '%d Selected', 'ai-content-by-parallax' ), 'toggle_field_selector' => __( 'Toggle field selector', 'ai-content-by-parallax' ), 'locked_excluded' => __( 'Locked — excluded from optimization', 'ai-content-by-parallax' ), 'optimized' => __( 'Optimized', 'ai-content-by-parallax' ), 'not_optimized' => __( 'Not optimized', 'ai-content-by-parallax' ), 'select_product_lock' => __( 'Please select at least one product.', 'ai-content-by-parallax' ), /* translators: %d: number of products toggled */ 'toggle_lock_success' => __( 'Lock status toggled for %d product(s).', 'ai-content-by-parallax' ), 'toggle_lock_failed' => __( 'Failed to toggle lock status.', 'ai-content-by-parallax' ), 'no_eligible' => __( 'No eligible products to optimize. Locked products are excluded.', 'ai-content-by-parallax' ), 'insufficient_credits' => __( 'Insufficient credits:', 'ai-content-by-parallax' ), /* translators: 1: required credits, 2: available credits */ 'credits_needed' => __( 'You need %1$s credits but only have %2$s available. Please purchase more credits to proceed or remove items from the batch request.', 'ai-content-by-parallax' ), 'credits_sufficient' => __( 'You have enough credits to run this batch.', 'ai-content-by-parallax' ), 'authentication_required' => __( 'Cannot submit batch request: the configured Parallax API key could not be verified. Check your API credentials on the Connection tab and try again.', 'ai-content-by-parallax' ), 'not_enough_credits_btn' => __( 'Not enough credits to optimize the selected items.', 'ai-content-by-parallax' ), 'view_status_link' => __( 'View optimization status', 'ai-content-by-parallax' ), ), ) ); } /** * Render filter dropdowns for optimized and excluded status. * * @param string $post_type Current post type. * @return void */ public static function render_filter_dropdowns( $post_type ): void { if ( 'product' !== $post_type ) { return; } $current_optimized = filter_input( INPUT_GET, 'parallax_optimized', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $current_excluded = filter_input( INPUT_GET, 'parallax_excluded', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); ?> is_main_query() ) { return; } if ( 'product' !== ( $query->get( 'post_type' ) ) ) { return; } $meta_query = (array) $query->get( 'meta_query' ); $excluded_filter = filter_input( INPUT_GET, 'parallax_excluded', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); if ( 'yes' === $excluded_filter ) { $meta_query[] = array( 'key' => self::META_EXCLUDED, 'value' => '1', 'compare' => '=', ); } elseif ( 'no' === $excluded_filter ) { $meta_query[] = array( 'relation' => 'OR', array( 'key' => self::META_EXCLUDED, 'compare' => 'NOT EXISTS', ), array( 'key' => self::META_EXCLUDED, 'value' => '1', 'compare' => '!=', ), ); } $optimized_filter = filter_input( INPUT_GET, 'parallax_optimized', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); if ( 'yes' === $optimized_filter ) { $meta_query[] = array( 'key' => self::META_IMPORT_STATUS, 'value' => 'imported', 'compare' => '=', ); } elseif ( 'no' === $optimized_filter ) { $meta_query[] = array( 'relation' => 'OR', array( 'key' => self::META_IMPORT_STATUS, 'compare' => 'NOT EXISTS', ), array( 'key' => self::META_IMPORT_STATUS, 'value' => 'imported', 'compare' => '!=', ), ); } if ( ! empty( $meta_query ) ) { $query->set( 'meta_query', $meta_query ); } } /** * AJAX handler to toggle the excluded (locked) meta for a product. * * @return void */ public static function ajax_toggle_excluded(): void { check_ajax_referer( WCPAI_NAMESPACE . '_toggle_excluded' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-content-by-parallax' ) ), 403 ); } $raw_product_id = isset( $_POST['product_id'] ) ? wp_unslash( $_POST['product_id'] ) : 0; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value is validated as scalar then sanitized with absint below. if ( ! is_scalar( $raw_product_id ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'ai-content-by-parallax' ) ), 400 ); } $product_id = absint( $raw_product_id ); if ( ! $product_id || 'product' !== get_post_type( $product_id ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'ai-content-by-parallax' ) ), 400 ); } // Settings-excluded products cannot have their manual-lock state toggled here; // the exclusion is config-driven and must be managed on the Settings page. if ( self::is_settings_excluded( $product_id ) ) { wp_send_json_error( array( 'message' => __( 'This product is excluded via the Skip Product IDs setting. Manage it on the Settings page.', 'ai-content-by-parallax' ) ), 400 ); } $currently_excluded = self::is_product_excluded( $product_id ); if ( $currently_excluded ) { delete_post_meta( $product_id, self::META_EXCLUDED ); } else { update_post_meta( $product_id, self::META_EXCLUDED, '1' ); } $now_excluded = ! $currently_excluded; wp_send_json_success( array( 'product_id' => $product_id, 'excluded' => $now_excluded, 'optimized' => ! $now_excluded ? self::is_product_optimized( $product_id ) : false, ) ); } /** * AJAX handler to bulk-toggle the excluded (locked) meta for multiple products. * * @return void */ public static function ajax_bulk_toggle_lock(): void { check_ajax_referer( WCPAI_NAMESPACE . '_bulk_toggle_lock' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-content-by-parallax' ) ), 403 ); } $raw_ids = isset( $_POST['product_ids'] ) ? wp_unslash( $_POST['product_ids'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each scalar ID is validated and sanitized with absint below. if ( ! is_array( $raw_ids ) || empty( $raw_ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'ai-content-by-parallax' ) ), 400 ); } $results = array(); $settings_excluded_count = 0; foreach ( $raw_ids as $raw_id ) { // Reject non-scalar entries; absint(array) coerces to 1 and could // match an unintended post. if ( ! is_scalar( $raw_id ) ) { continue; } $product_id = absint( $raw_id ); if ( ! $product_id || 'product' !== get_post_type( $product_id ) ) { continue; } // Skip config-driven exclusions; they cannot be toggled per-product. if ( self::is_settings_excluded( $product_id ) ) { ++$settings_excluded_count; continue; } $currently_excluded = self::is_product_excluded( $product_id ); if ( $currently_excluded ) { delete_post_meta( $product_id, self::META_EXCLUDED ); } else { update_post_meta( $product_id, self::META_EXCLUDED, '1' ); } $now_excluded = ! $currently_excluded; $results[] = array( 'product_id' => $product_id, 'excluded' => $now_excluded, 'optimized' => ! $now_excluded ? self::is_product_optimized( $product_id ) : false, ); } if ( empty( $results ) ) { // Tailor the message when we know settings-exclusion was the cause; // fall back to a generic message for invalid/non-product IDs. $message = $settings_excluded_count > 0 ? __( 'No eligible products to toggle. Settings-excluded products cannot be manually locked or unlocked.', 'ai-content-by-parallax' ) : __( 'No eligible products to toggle.', 'ai-content-by-parallax' ); wp_send_json_error( array( 'message' => $message ), 400 ); } wp_send_json_success( array( 'products' => $results ) ); } /** * Check whether a product is excluded (locked) from optimization via post meta. * * This reflects only the per-product manual lock. For the settings-driven * exclusion (Skip Product IDs), use is_settings_excluded(). The optimization * filter in AjaxRequestHandlers checks both independently. * * @param int $product_id Product ID. * @return bool */ public static function is_product_excluded( int $product_id ): bool { return '1' === (string) get_post_meta( $product_id, self::META_EXCLUDED, true ); } /** * Check whether a product is excluded via the "Skip Product IDs" setting. * * This exclusion is configuration-driven and cannot be toggled per-product * from the product list; the user must edit the Settings page. * * @param int $product_id Product ID. * @return bool */ public static function is_settings_excluded( int $product_id ): bool { return in_array( $product_id, self::get_settings_excluded_ids(), true ); } /** * Return the settings-excluded product ID list, request-memoized. * * Avoids re-parsing the CSV option on every product row render. * * Note: the list is cached once per PHP request. If the underlying option * is updated later in the same request the cached value will be stale. * This is intentional — settings saves are form submissions that redirect, * so in practice the cache lifetime is a single page load. * * @return int[] */ private static function get_settings_excluded_ids(): array { if ( null === self::$settings_excluded_ids ) { self::$settings_excluded_ids = Settings::get_excluded_product_ids(); } return self::$settings_excluded_ids; } /** * Add an "Optimize" link to the product row actions. * * The link opens the collapsible optimization form (if closed) and triggers * a single-product optimization via JS using the product's post ID. * * @param array $actions Existing row actions. * @param \WP_Post $post Current post object. * @return array */ public static function add_row_actions( array $actions, $post ): array { if ( ! current_user_can( 'manage_options' ) ) { return $actions; } if ( 'product' !== ( $post->post_type ?? '' ) ) { return $actions; } // Products excluded via the "Skip Product IDs" setting are config-locked. // Suppress Optimize and Lock/Unlock actions — the exclusion must be // managed on the Settings page, not toggled per-product here. if ( self::is_settings_excluded( (int) $post->ID ) ) { return $actions; } $is_excluded = self::is_product_excluded( (int) $post->ID ); $actions['parallax_optimize'] = sprintf( '%s', (int) $post->ID, esc_html__( 'Optimize', 'ai-content-by-parallax' ) ); $actions['parallax_lock'] = sprintf( '%s', (int) $post->ID, $is_excluded ? '1' : '0', $is_excluded ? esc_html__( 'Unlock', 'ai-content-by-parallax' ) : esc_html__( 'Lock', 'ai-content-by-parallax' ) ); return $actions; } /** * Add "Optimize Selected" to the bulk actions dropdown. * * The actual optimization is handled client-side via JS, which intercepts * the bulk action and submits selected product IDs via AJAX. * * @param array $actions Existing bulk actions. * @return array */ public static function add_bulk_actions( array $actions ): array { if ( current_user_can( 'manage_options' ) ) { $actions['parallax_optimize_selected'] = __( 'Optimize Selected', 'ai-content-by-parallax' ); $actions['parallax_toggle_lock'] = __( 'Toggle Optimization Lock', 'ai-content-by-parallax' ); } return $actions; } /** * Add the "Optimized" column to the product list table. * * @param array $columns Existing columns. * @return array */ public static function add_columns( array $columns ): array { $columns['parallax_optimized'] = __( 'Optimized', 'ai-content-by-parallax' ); return $columns; } /** * Render the "Optimized" column value for a product row. * * @param string $column Column key. * @param int $post_id Product (post) ID. * @return void */ public static function render_column( string $column, int $post_id ): void { if ( 'parallax_optimized' !== $column ) { return; } // Settings-excluded products show a muted lock; the tooltip directs // the user to the Settings page since the exclusion is config-driven. if ( self::is_settings_excluded( $post_id ) ) { // parallax-locked-icon is included so JS bulk-action selectors // (which test for .parallax-locked-icon) treat this product as // non-actionable and exclude it from optimizable counts/estimates. // parallax-settings-locked-icon is declared after parallax-locked-icon // in product-list.css, so its grey colour wins the cascade. printf( '', esc_attr__( 'Excluded via Skip Product IDs setting — manage on the Settings page', 'ai-content-by-parallax' ) ); return; } $is_excluded = self::is_product_excluded( $post_id ); if ( $is_excluded ) { printf( '', esc_attr__( 'Locked — excluded from optimization', 'ai-content-by-parallax' ) ); return; } $optimized = self::is_product_optimized( $post_id ); if ( $optimized ) { printf( '', esc_attr__( 'Optimized', 'ai-content-by-parallax' ) ); } else { printf( '', esc_attr__( 'Not optimized', 'ai-content-by-parallax' ) ); } } /** * Determine if a product has been optimized by Parallax AI. * * Reuses the same heuristic as AjaxRequestHandlers::is_product_optimized_for_optimize_all(). * * @param int $product_id Product ID. * @return bool */ public static function is_product_optimized( int $product_id ): bool { if ( $product_id <= 0 ) { return false; } $status = (string) get_post_meta( $product_id, self::META_IMPORT_STATUS, true ); if ( 'imported' === $status ) { return true; } if ( '' === $status ) { $timestamp = (string) get_post_meta( $product_id, self::META_IMPORTED_AT, true ); $has_backup = metadata_exists( 'post', $product_id, OriginalSnapshot::PREV_PAYLOAD_META_KEY ); if ( '' !== $timestamp && $has_backup ) { return true; } } $product = get_post( $product_id ); if ( ! $product ) { return false; } $snapshot = get_post_meta( $product_id, OriginalSnapshot::POST_META_KEY, true ); $snapshot = maybe_unserialize( $snapshot ); if ( is_array( $snapshot ) ) { $original_title = (string) ( $snapshot['title'] ?? '' ); if ( '' !== $original_title && (string) $product->post_title !== $original_title ) { return true; } } $legacy_original = (string) get_post_meta( $product_id, 'product_title_original', true ); if ( '' !== $legacy_original && (string) $product->post_title !== $legacy_original ) { return true; } return false; } }