/** * AJAX: Receive worker status pings (hello, heartbeat, resource info). * Registers or updates the worker in the live UI. */ public static function ajax_worker_status_ping() { $worker = isset($_POST['worker']) ? sanitize_text_field($_POST['worker']) : ''; $secret = isset($_POST['secret']) ? sanitize_text_field($_POST['secret']) : ''; $cpu = isset($_POST['cpu']) ? sanitize_text_field($_POST['cpu']) : ''; $ram = isset($_POST['ram']) ? sanitize_text_field($_POST['ram']) : ''; $meta = isset($_POST['meta']) ? $_POST['meta'] : array(); if (!$worker || !$secret) { wp_send_json_error(['message' => 'Missing worker or secret.'], 400); } // Load configured workers and secrets $settings = class_exists('Wave_Woo_Bridge\\Settings') ? \Wave_Woo_Bridge\Settings::get() : array(); $workers = isset($settings['offload_workers']) ? $settings['offload_workers'] : array(); $found = false; foreach ($workers as $w) { if (!empty($w['domain']) && $w['domain'] === $worker && !empty($w['secret']) && $w['secret'] === $secret) { $found = true; break; } } if (!$found) { wp_send_json_error(['message' => 'Worker not registered or secret mismatch.'], 403); } // Save live status $live_workers = get_option('catasync_workers', array()); $live_workers[$worker] = array( 'worker' => $worker, 'last_ping' => time(), 'cpu' => $cpu, 'ram' => $ram, 'meta' => $meta, ); update_option('catasync_workers', $live_workers, false); wp_send_json_success(['message' => 'Worker registered and pinged.', 'worker' => $worker]); } // ...existing code... /** * AJAX: Get all worker statuses for live UI refresh. */ public static function ajax_worker_status_all() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } $live_workers = get_option( 'catasync_workers', array() ); wp_send_json_success( $live_workers ); } const NONCE = 'catasync_admin'; /** Allowed values for staging search ?wpp= */ const SEARCH_PER_PAGE_CHOICES = array( 24, 50, 100, 200, 500 ); /** * Whether LAFU Pro bulk hooks for Factory were registered (avoid duplicate add_filter). * * @var bool */ private static $lafucaep_wave_store_integration_registered = false; /** * Init. */ public static function init() { // Before Lafucaep_Bulk_Processor::register_bulk_modal_footer() (admin_init priority 5). add_action( 'admin_init', array( __CLASS__, 'register_lafucaep_wave_store_integration' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ), 30 ); add_action( 'admin_init', array( __CLASS__, 'handle_settings_post' ) ); add_action( 'wp_ajax_catasync_progress', array( __CLASS__, 'ajax_progress' ) ); add_action( 'wp_ajax_catasync_rabbit_probe', array( __CLASS__, 'ajax_rabbit_probe' ) ); add_action( 'wp_ajax_catasync_worker_status_ping', array( __CLASS__, 'ajax_worker_status_ping' ) ); add_action( 'wp_ajax_catasync_worker_status_all', array( __CLASS__, 'ajax_worker_status_all' ) ); add_action( 'wp_ajax_catasync_queue_import', array( __CLASS__, 'ajax_queue_import' ) ); add_action( 'wp_ajax_catasync_run_fetch', array( __CLASS__, 'ajax_run_fetch' ) ); add_action( 'wp_ajax_catasync_openai_probe', array( __CLASS__, 'ajax_openai_probe' ) ); add_action( 'wp_ajax_catasync_feed_probe', array( __CLASS__, 'ajax_feed_probe' ) ); add_action( 'wp_ajax_catasync_sync_product', array( __CLASS__, 'ajax_sync_product' ) ); add_action( 'wp_ajax_catasync_fetch_product_video', array( __CLASS__, 'ajax_fetch_product_video' ) ); add_action( 'wp_ajax_catasync_fetch_product_energy_class', array( __CLASS__, 'ajax_fetch_product_energy_class' ) ); add_action( 'wp_ajax_catasync_bulk_shop_sync_start', array( __CLASS__, 'ajax_bulk_shop_sync_start' ) ); add_action( 'wp_ajax_catasync_bulk_shop_sync_step', array( __CLASS__, 'ajax_bulk_shop_sync_step' ) ); add_action( 'wp_ajax_catasync_reconcile_step', array( __CLASS__, 'ajax_reconcile_step' ) ); add_action( 'wp_ajax_catasync_dismiss_markup_shop_sync_notice', array( __CLASS__, 'ajax_dismiss_markup_shop_sync_notice' ) ); add_action( 'wp_ajax_catasync_preview_product_images', array( __CLASS__, 'ajax_preview_product_images' ) ); add_action( 'wp_ajax_catasync_preview_product_images_ai', array( __CLASS__, 'ajax_preview_product_images_ai' ) ); add_action( 'wp_ajax_catasync_apply_product_images', array( __CLASS__, 'ajax_apply_product_images' ) ); add_action( 'wp_ajax_catasync_store_bulk_wave_images_step', array( __CLASS__, 'ajax_store_bulk_wave_images_step' ) ); add_action( 'wp_ajax_catasync_store_bulk_video_step', array( __CLASS__, 'ajax_store_bulk_video_step' ) ); add_action( 'wp_ajax_catasync_store_bulk_publish', array( __CLASS__, 'ajax_store_bulk_publish' ) ); add_action( 'wp_ajax_catasync_store_update_title', array( __CLASS__, 'ajax_store_update_title' ) ); add_action( 'wp_ajax_catasync_store_delete_product', array( __CLASS__, 'ajax_store_delete_product' ) ); add_action( 'wp_ajax_catasync_store_bulk_delete', array( __CLASS__, 'ajax_store_bulk_delete' ) ); add_action( 'wp_ajax_catasync_store_bulk_update_slugs', array( __CLASS__, 'ajax_store_bulk_update_slugs' ) ); add_action( 'wp_ajax_catasync_store_bulk_remove_low_res_images', array( __CLASS__, 'ajax_store_bulk_remove_low_res_images' ) ); add_action( 'wp_ajax_catasync_store_bulk_copy_step', array( __CLASS__, 'ajax_store_bulk_copy_step' ) ); add_action( 'wp_ajax_catasync_store_bulk_enrich_step', array( __CLASS__, 'ajax_store_bulk_enrich_step' ) ); add_action( 'wp_ajax_nopriv_catasync_offload_callback', array( __CLASS__, 'ajax_offload_callback' ) ); add_action( 'wp_ajax_catasync_offload_callback', array( __CLASS__, 'ajax_offload_callback' ) ); add_action( 'wp_ajax_catasync_search_staging', array( __CLASS__, 'ajax_search_staging' ) ); add_action( 'wp_ajax_catasync_search_staging_categories', array( __CLASS__, 'ajax_search_staging_categories' ) ); add_action( 'wp_ajax_catasync_search_staging_ids', array( __CLASS__, 'ajax_search_staging_ids' ) ); add_action( 'wp_ajax_catasync_store_products_fragment', array( __CLASS__, 'ajax_store_products_fragment' ) ); } /** * Admin hook suffix for WAVE → Factory (matches $hook in admin_enqueue_scripts, WP_Screen::$id, and admin_footer-{$suffix}). * * @return string Empty if plugin API is unavailable. */ public static function wave_store_screen_hook() { if ( ! function_exists( 'get_plugin_page_hookname' ) ) { return ''; } return (string) get_plugin_page_hookname( 'wave-woo-store', 'wave-woo-search' ); } /** * LAFU Category Admin Enhancer Pro: load bulk category modal + assets on WAVE → Factory (same UI as WooCommerce → Products). */ public static function register_lafucaep_wave_store_integration() { if ( self::$lafucaep_wave_store_integration_registered ) { return; } if ( ! class_exists( \Lafucaep_Bulk_Processor::class, false ) ) { return; } self::$lafucaep_wave_store_integration_registered = true; add_filter( 'lafucaep_is_bulk_ui_screen', array( __CLASS__, 'lafucaep_bulk_ui_on_wave_store_screen' ), 10, 2 ); add_filter( 'lafucaep_bulk_modal_footer_hooks', array( __CLASS__, 'lafucaep_append_wave_store_bulk_footer_hook' ) ); } /** * @param bool $is Default bulk UI flag. * @param \WP_Screen|null $screen Current screen. * @return bool */ public static function lafucaep_bulk_ui_on_wave_store_screen( $is, $screen ) { if ( $is ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Screen sniff only. if ( isset( $_GET['page'] ) && 'wave-woo-store' === sanitize_key( wp_unslash( (string) $_GET['page'] ) ) ) { return true; } $suffix = self::wave_store_screen_hook(); if ( $suffix && $screen && isset( $screen->id ) && $suffix === $screen->id ) { return true; } return false; } /** * @param string[] $hooks Footer action hooks. * @return string[] */ public static function lafucaep_append_wave_store_bulk_footer_hook( $hooks ) { if ( ! is_array( $hooks ) ) { $hooks = array(); } $suffix = self::wave_store_screen_hook(); if ( $suffix ) { $hooks[] = 'admin_footer-' . $suffix; } return array_values( array_unique( array_filter( $hooks ) ) ); } /** * Whether LAFU Pro bulk "Enhance categories" is enabled in settings. * * @return bool */ public static function is_lafucaep_bulk_enhance_available() { return class_exists( \Lafucaep_Settings::class, false ) && ! empty( \Lafucaep_Settings::get_options()['lafucaep_enable_bulk_ai'] ); } /** * LAFU Category Admin Enhancer Pro active: markup-by-category rows can use the same AJAX category search as bulk tools. * * @return bool */ public static function is_lafucaep_markup_category_search_available() { if ( ! taxonomy_exists( 'product_cat' ) || ! function_exists( 'WC' ) || ! WC() ) { return false; } return class_exists( \Lafucaep_Ajax::class, false ); } /** * Admin menu: Search first, Settings second. */ public static function menu() { $cap = 'manage_woocommerce'; add_menu_page( __( 'CataSync', 'wave-woo-bridge' ), __( 'CataSync', 'wave-woo-bridge' ), $cap, 'wave-woo-search', array( __CLASS__, 'render_search' ), 'dashicons-download', 56 ); add_submenu_page( 'wave-woo-search', __( 'Search products', 'wave-woo-bridge' ), __( 'Search products', 'wave-woo-bridge' ), $cap, 'wave-woo-search', array( __CLASS__, 'render_search' ) ); add_submenu_page( 'wave-woo-search', __( 'Factory', 'wave-woo-bridge' ), __( 'Factory', 'wave-woo-bridge' ), $cap, 'wave-woo-store', array( __CLASS__, 'render_store_products' ) ); add_submenu_page( 'wave-woo-search', __( 'Settings', 'wave-woo-bridge' ), __( 'Settings', 'wave-woo-bridge' ), $cap, 'wave-woo-settings', array( __CLASS__, 'render_settings' ) ); } /** * Enqueue admin assets. * * @param string $hook Hook. */ public static function assets( $hook ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing hint for asset enqueue. $page_slug = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; $on_wave_page = ( false !== strpos( $hook, 'wave-woo' ) ) || ( '' !== $page_slug && 0 === strpos( $page_slug, 'wave-woo' ) ); $on_settings = ( false !== strpos( $hook, 'wave-woo-settings' ) ) || 'wave-woo-settings' === $page_slug; $on_wave_search = ( false !== strpos( $hook, 'wave-woo-search' ) ) || 'wave-woo-search' === $page_slug; $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $on_product = $screen && 'product' === $screen->post_type && in_array( $screen->base, array( 'edit', 'post' ), true ); $wave_store_hook = self::wave_store_screen_hook(); $is_wave_store_screen = ( $wave_store_hook && $screen && isset( $screen->id ) && $wave_store_hook === $screen->id ) || ( $wave_store_hook && $wave_store_hook === $hook ) || ( 'wave-woo-store' === $page_slug ); if ( ! $on_wave_page && ! $on_product ) { return; } if ( $on_wave_page ) { wp_enqueue_style( 'dashicons' ); } wp_enqueue_style( 'wave-woo-admin', WAVE_WOO_PLUGIN_URL . 'assets/admin.css', array(), WAVE_WOO_VERSION ); if ( $on_wave_page || $on_product ) { if ( $on_settings && self::is_lafucaep_markup_category_search_available() ) { wp_enqueue_script( 'selectWoo' ); if ( ! wp_style_is( 'select2', 'registered' ) ) { wp_register_style( 'select2', WC()->plugin_url() . '/assets/css/select2.css', array(), WC()->version ); } wp_enqueue_style( 'select2' ); } // Ensure LAFU Pro localizes + prints its bulk/improve modals on Factory too. // Calling enqueue_bulk_assets() repeatedly is safe (wp_enqueue_* is idempotent) and avoids // cases where the script is registered but modal/localized payload was not prepared for this screen. if ( $is_wave_store_screen && self::is_lafucaep_bulk_enhance_available() && class_exists( 'Lafucaep_Bulk_Processor', false ) ) { \Lafucaep_Bulk_Processor::enqueue_bulk_assets(); } if ( $is_wave_store_screen && current_user_can( 'edit_products' ) && class_exists( 'Lafucaep_Bulk_Processor', false ) ) { \Lafucaep_Bulk_Processor::enqueue_factory_category_quick_pick_assets(); } $script_deps = array( 'jquery' ); if ( $is_wave_store_screen && self::is_lafucaep_bulk_enhance_available() && wp_script_is( 'lafu-cae-pro-bulk', 'registered' ) ) { $script_deps[] = 'lafu-cae-pro-bulk'; } if ( $is_wave_store_screen && current_user_can( 'edit_products' ) && wp_script_is( 'lafu-cae-pro-factory-cat', 'registered' ) ) { $script_deps[] = 'lafu-cae-pro-factory-cat'; } if ( ( $on_settings || $on_wave_search || $is_wave_store_screen ) && wp_script_is( 'selectWoo', 'registered' ) ) { $script_deps[] = 'selectWoo'; } wp_enqueue_script( 'wave-woo-admin', WAVE_WOO_PLUGIN_URL . 'assets/admin.js', $script_deps, WAVE_WOO_VERSION, true ); if ( $on_settings || $on_wave_search || $is_wave_store_screen ) { wp_enqueue_style( 'woocommerce_admin_styles' ); } $wave_opts = Settings::get(); $localize = array( 'ajax' => admin_url( 'admin-ajax.php' ), 'adminPhp' => admin_url( 'admin.php' ), 'wcPostEdit' => admin_url( 'post.php' ), 'nonce' => wp_create_nonce( self::NONCE ), 'i18n' => array( 'confirmImport' => __( 'Import selected products? Items already in your store are skipped.', 'wave-woo-bridge' ), 'selectAllResults' => __( 'Select all results', 'wave-woo-bridge' ), 'selectAllResultsBusy' => __( 'Loading…', 'wave-woo-bridge' ), 'selectAllResultsCapped' => __( 'Result list was truncated by a server limit.', 'wave-woo-bridge' ), 'selectAllResultsNone' => __( 'No rows match the current filters.', 'wave-woo-bridge' ), 'valid' => __( 'Valid', 'wave-woo-bridge' ), 'invalid' => __( 'Invalid', 'wave-woo-bridge' ), 'checking' => __( 'Checking…', 'wave-woo-bridge' ), 'noKey' => __( 'No key saved', 'wave-woo-bridge' ), 'saved' => __( 'Saved', 'wave-woo-bridge' ), 'productSyncing' => __( 'Syncing with WAVE…', 'wave-woo-bridge' ), 'productSyncSuccess' => __( 'Success: price, stock, pre-order/availability, and SKU updated from staging.', 'wave-woo-bridge' ), 'productSyncFail' => __( 'Sync failed.', 'wave-woo-bridge' ), 'fetchVideoWorking' => __( 'Finding a video…', 'wave-woo-bridge' ), 'fetchVideoSuccess' => __( 'Video URL saved to the configured meta key.', 'wave-woo-bridge' ), 'fetchVideoFail' => __( 'No video found.', 'wave-woo-bridge' ), 'fetchEnergyClassWorking' => __( 'Loading WAVE page…', 'wave-woo-bridge' ), /* translators: %s: energy class such as A+++ or G */ 'fetchEnergyClassSaved' => __( 'Saved energy class %s.', 'wave-woo-bridge' ), /* translators: 1: energy class, 2: extra note (PDF/image) */ 'fetchEnergyClassSavedDetail' => __( 'Saved class %1$s. %2$s', 'wave-woo-bridge' ), 'fetchEnergyClassPdfNote' => __( 'Product data sheet (PDF) attached to the product.', 'wave-woo-bridge' ), 'fetchEnergyClassImageNote' => __( 'Energy label image attached for preview.', 'wave-woo-bridge' ), 'fetchEnergyClassSkip' => __( 'Skipped — nothing saved.', 'wave-woo-bridge' ), 'bulkShopSyncConfirm' => __( 'Re-sync all WAVE-linked products from the current staging data? This updates regular price (using your markup rules), stock, pre-order dates and availability, and the formatted WAVE SKU. Rows that indicate an unknown delivery date are set to out of stock. Products with no matching staging row are skipped. Download the latest CSV first if staging may be stale; save Settings first if you changed pricing.', 'wave-woo-bridge' ), 'bulkShopSyncNone' => __( 'No products with a WAVE supplier key were found.', 'wave-woo-bridge' ), 'bulkShopSyncRunning' => __( 'Syncing', 'wave-woo-bridge' ), 'factoryReconcileWorking' => __( 'Applying staging to the shop…', 'wave-woo-bridge' ), /* translators: 1: products updated this batch, 2: staging rows skipped this batch, 3: cumulative products updated, 4: cumulative rows skipped */ 'factoryReconcileBatch' => __( 'This batch: %1$d updated, %2$d skipped — total so far %3$d updated, %4$d skipped.', 'wave-woo-bridge' ), /* translators: 1: total products updated this run, 2: total staging rows skipped this run */ 'factoryReconcileDone' => __( 'Staging sync finished: %1$d products updated, %2$d staging rows skipped.', 'wave-woo-bridge' ), 'factoryReconcileError' => __( 'Staging sync failed. Try again or check the activity log.', 'wave-woo-bridge' ), 'fetchImagesWorking' => __( 'Loading image sources…', 'wave-woo-bridge' ), /* translators: %d: number of images added to the product. */ 'fetchImagesSuccess' => __( 'Added %d image(s). Reload the page if the gallery does not refresh.', 'wave-woo-bridge' ), 'fetchImagesFail' => __( 'Could not load images.', 'wave-woo-bridge' ), 'imgPickerTitle' => __( 'Choose product images', 'wave-woo-bridge' ), 'imgPickerPageUrl' => __( 'Page URL (optional)', 'wave-woo-bridge' ), 'imgPickerLoad' => __( 'Load / refresh sources', 'wave-woo-bridge' ), 'imgPickerSelectAll' => __( 'Select all', 'wave-woo-bridge' ), 'imgPickerSelectNone' => __( 'Clear', 'wave-woo-bridge' ), 'imgPickerFeatured' => __( 'Featured', 'wave-woo-bridge' ), 'imgPickerGallery' => __( 'Gallery', 'wave-woo-bridge' ), 'imgPickerSource' => __( 'Source', 'wave-woo-bridge' ), 'imgPickerApply' => __( 'Add to product', 'wave-woo-bridge' ), 'imgPickerCancel' => __( 'Cancel', 'wave-woo-bridge' ), 'imgPickerHint' => __( 'Pick a source, select images, then “Add to product”.', 'wave-woo-bridge' ), 'imgPickerNone' => __( 'No images match the current filters. Try another page URL or check WAVE → Settings → Enrichment.', 'wave-woo-bridge' ), 'imgPickerSelectOne' => __( 'Select at least one image for the gallery, or pick a featured image.', 'wave-woo-bridge' ), 'imgPickerDimsSmall' => __( 'Long side under 500px or short side under 300px — may look soft when enlarged.', 'wave-woo-bridge' ), 'sourceWave' => __( 'WAVE', 'wave-woo-bridge' ), 'sourceIcecat' => __( 'Open Icecat', 'wave-woo-bridge' ), 'sourceHinta' => __( 'Hinta.fi', 'wave-woo-bridge' ), 'imgPickerHintaWorking' => __( 'Loading images from Hinta.fi…', 'wave-woo-bridge' ), 'sourceCsv' => __( 'CSV', 'wave-woo-bridge' ), 'sourceAi' => __( 'AI', 'wave-woo-bridge' ), 'imgPickerAiWorking' => __( 'Asking AI for image URLs…', 'wave-woo-bridge' ), 'imgPickerAiNeedUrls' => __( 'Paste one or more page URLs first (one per line or comma-separated).', 'wave-woo-bridge' ), 'imgPickerAiHint' => __( 'AI loads the pasted pages and extracts image URLs.', 'wave-woo-bridge' ), 'importRunning' => __( 'Importing', 'wave-woo-bridge' ), 'importBetweenWaves' => __( 'Next wave starting soon (normal between batches)…', 'wave-woo-bridge' ), 'importWaitingWorker' => __( 'Waiting for background worker — if this does not change for several minutes, open WooCommerce → Status → Scheduled Actions or check WP-Cron.', 'wave-woo-bridge' ), 'importDoneShort' => __( 'Import queue finished.', 'wave-woo-bridge' ), 'importRunTitle' => __( 'Import products', 'wave-woo-bridge' ), 'importRunQueued' => __( 'Queued', 'wave-woo-bridge' ), 'importRunUpdated' => __( 'Updated', 'wave-woo-bridge' ), 'importRunFailed' => __( 'Failed', 'wave-woo-bridge' ), 'importRunEdit' => __( 'Edit product', 'wave-woo-bridge' ), /* translators: %d: number of products successfully updated in the import run modal. */ 'importRunProductsOk' => __( '%d updated', 'wave-woo-bridge' ), /* translators: %d: number of products that failed in the import run modal. */ 'importRunProductsFail' => __( '%d failed', 'wave-woo-bridge' ), /* translators: %d: number of products still queued in the import run modal. */ 'importRunProductsPending' => __( '%d queued', 'wave-woo-bridge' ), 'importRunDone' => __( 'Import queue finished.', 'wave-woo-bridge' ), 'searching' => __( 'Searching…', 'wave-woo-bridge' ), 'stagingCatAll' => __( 'All categories', 'wave-woo-bridge' ), 'stagingCatNoResults' => __( 'No matching categories', 'wave-woo-bridge' ), 'searchFail' => __( 'Search failed. Try again or reload the page.', 'wave-woo-bridge' ), /* translators: 1: processed CSV rows, 2: total CSV rows. */ 'catalogParsingRows' => __( 'CSV %1$s/%2$s', 'wave-woo-bridge' ), /* translators: %s: datetime of the last completed CSV parse batch. */ 'catalogParsingUpdated' => __( 'Last batch: %s', 'wave-woo-bridge' ), 'resumeParseQueue' => __( 'Resume', 'wave-woo-bridge' ), 'resumeParseQueued' => __( 'Parse job queued. The line count should move shortly.', 'wave-woo-bridge' ), 'resumeParseQueuedShort' => __( 'Queued…', 'wave-woo-bridge' ), 'parseBusyShort' => __( 'Parsing…', 'wave-woo-bridge' ), 'bgJobsStopUnavailable' => __( 'Stop is not available for this job — it will finish on the server.', 'wave-woo-bridge' ), 'resumeParseAlready' => __( 'A parse job is already scheduled.', 'wave-woo-bridge' ), 'resumeParseFail' => __( 'Could not resume parse.', 'wave-woo-bridge' ), 'inStoreRibbon' => __( 'In store', 'wave-woo-bridge' ), 'inStoreRibbonTitle' => __( 'Already in your store. Use “Sync WAVE” on the product to refresh price and stock.', 'wave-woo-bridge' ), 'importedToShopTitle' => __( 'Already in your store — use “Sync WAVE” on the product to update price and stock.', 'wave-woo-bridge' ), 'storeBulkWaveTitle' => __( 'Fetch images', 'wave-woo-bridge' ), 'storeBulkWaveRunning' => __( 'Fetching…', 'wave-woo-bridge' ), 'storeBulkWaveDone' => __( 'Done.', 'wave-woo-bridge' ), 'storeBulkWaveNone' => __( 'No products on this page to fetch (try another page or adjust filters).', 'wave-woo-bridge' ), /* translators: %d: number of images added. */ 'storeBulkWaveAdded' => __( 'Added %d', 'wave-woo-bridge' ), /* translators: %d: number of products updated. */ 'storeBulkWaveProductsOk' => __( '%d updated', 'wave-woo-bridge' ), /* translators: %d: number of products skipped. */ 'storeBulkWaveProductsSkip' => __( '%d skipped', 'wave-woo-bridge' ), /* translators: %d: number of products that failed. */ 'storeBulkWaveProductsFail' => __( '%d failed', 'wave-woo-bridge' ), 'storeBulkWaveSkip' => __( 'Skipped', 'wave-woo-bridge' ), 'storeBulkWaveFail' => __( 'Failed', 'wave-woo-bridge' ), 'storeBulkWaveEdit' => __( 'Edit product', 'wave-woo-bridge' ), 'storeBulkWaveClose' => __( 'Close', 'wave-woo-bridge' ), 'storeBulkWaveStoppedRow' => __( 'Stopped', 'wave-woo-bridge' ), 'storeBulkWaveStoppedEarly' => __( 'Stopped early.', 'wave-woo-bridge' ), /* translators: %d: number of products queued for fetch. */ 'storeBulkWaveReady' => __( 'Ready to start. %d products in queue.', 'wave-woo-bridge' ), 'storeBulkWaveConfirm' => __( 'Fetch images for each product on this page? Uses WAVE first, then Hinta.fi (n8n), then Open Icecat if still no images. Same quality rules as manual image fetch. Concurrency is set under WAVE → Settings → Enrichment. Use the “Has image” filter to limit which products appear.', 'wave-woo-bridge' ), 'storeBulkVideoTitle' => __( 'Fetch videos', 'wave-woo-bridge' ), 'storeBulkVideoRunning' => __( 'Fetching…', 'wave-woo-bridge' ), 'storeBulkVideoConfirm' => __( 'Fetch product videos for each selected product (or all on this page if none selected)? Prioritizes official brand showcases and review videos using title + review, EAN/MPN, and manufacturer context.', 'wave-woo-bridge' ), 'storeBulkVideoUpdated' => __( 'Video saved', 'wave-woo-bridge' ), 'storeBulkVideoSkip' => __( 'Skipped', 'wave-woo-bridge' ), 'storeBulkVideoFail' => __( 'Failed', 'wave-woo-bridge' ), 'storeBulkVideoEdit' => __( 'Edit product', 'wave-woo-bridge' ), 'storeBulkPublish' => __( 'Publish selected', 'wave-woo-bridge' ), 'storeBulkPublishConfirm' => __( 'Publish the selected WAVE-linked products live? Only draft, pending review, and private products are updated; already published products are skipped.', 'wave-woo-bridge' ), 'storeBulkSlug' => __( 'Update slugs', 'wave-woo-bridge' ), 'storeBulkSlugConfirm' => __( 'Regenerate the URL slug from each selected product\'s current title? Products already matching stay unchanged. This changes permalinks where the slug differs.', 'wave-woo-bridge' ), 'storeBulkLowResRemove' => __( 'Remove low-res images', 'wave-woo-bridge' ), 'storeBulkLowResRemoveConfirm' => __( 'Remove low-resolution featured/gallery images from selected products and permanently delete those files from the Media Library when they are not used by any other product? Uses WAVE → Settings → Import image quality thresholds.', 'wave-woo-bridge' ), 'storeBulkCopyTitle' => __( 'Improve titles', 'wave-woo-bridge' ), 'storeBulkSeoTitle' => __( 'Improve titles', 'wave-woo-bridge' ), 'storeBulkSeoConfirm' => __( 'Rewrite product titles with AI using the max word limit from WAVE → Settings → Import title (max words), retail-style wording (brand, model, specs) with the product type as the last word, entirely in your store language — for each selected product or all on this page? Several requests run in parallel for speed.', 'wave-woo-bridge' ), 'storeBulkCopyShort' => __( 'Improve short description', 'wave-woo-bridge' ), 'storeBulkCopyLong' => __( 'Improve long description', 'wave-woo-bridge' ), 'storeBulkCopyConfirmTitle' => __( 'Rewrite product titles with AI for each selected product (or all products on this page if none are selected)? Titles end with the product type as the last word (site language). Uses WAVE → Settings language, OpenAI model, and optional WAVE / Open Icecat sources.', 'wave-woo-bridge' ), 'storeBulkCopyConfirmShort' => __( 'Rewrite short descriptions (max 5 bullet lines, retail style) with AI for each selected product (or all on this page if none selected)? Same settings as import enrichment.', 'wave-woo-bridge' ), 'storeBulkCopyConfirmLong' => __( 'Rewrite long descriptions with AI for each selected product (or all on this page if none selected)? Output matches site language and retail HTML style.', 'wave-woo-bridge' ), 'storeBulkCopyModalTitle' => __( 'Improve copy', 'wave-woo-bridge' ), 'storeBulkCopyRowOk' => __( 'Updated', 'wave-woo-bridge' ), 'storeBulkEnrichTitle' => __( 'Enrich', 'wave-woo-bridge' ), 'storeBulkEnrichRunning' => __( 'Enriching…', 'wave-woo-bridge' ), 'storeBulkEnrichConfirm' => __( 'Run full enrichment (like reimport) for each selected product (or all on this page if none selected)? Product names use the same rules as import (product type as the last word in your store language). Images are skipped when the product already has featured + gallery images.', 'wave-woo-bridge' ), 'storeBulkEnrichDone' => __( 'Done.', 'wave-woo-bridge' ), 'storeBulkEnrichOk' => __( 'Updated', 'wave-woo-bridge' ), 'storeBulkEnrichTitleUnchanged' => __( 'Title unchanged', 'wave-woo-bridge' ), 'storeBulkEnrichSkip' => __( 'Skipped', 'wave-woo-bridge' ), 'storeBulkEnrichFail' => __( 'Failed', 'wave-woo-bridge' ), /* translators: %d: number of products updated. */ 'storeBulkEnrichProductsOk' => __( '%d updated', 'wave-woo-bridge' ), /* translators: %d: number of products skipped. */ 'storeBulkEnrichProductsSkip' => __( '%d skipped', 'wave-woo-bridge' ), /* translators: %d: number of products that failed. */ 'storeBulkEnrichProductsFail' => __( '%d failed', 'wave-woo-bridge' ), 'storeBulkEnrichEdit' => __( 'Edit product', 'wave-woo-bridge' ), 'storeShortPreviewShow' => __( 'Show short description', 'wave-woo-bridge' ), 'storeShortPreviewHide' => __( 'Hide short description', 'wave-woo-bridge' ), 'storeTitleEmpty' => __( 'Title cannot be empty.', 'wave-woo-bridge' ), 'storeTitleSaveFail' => __( 'Could not save title.', 'wave-woo-bridge' ), /* translators: %s: product title */ 'storeDeleteProductConfirm' => __( 'Move “%s” to the trash? You can restore it from WooCommerce → Products → Trash.', 'wave-woo-bridge' ), 'storeDeleteProductFail' => __( 'Could not delete product.', 'wave-woo-bridge' ), /* translators: %d: number of selected products */ 'storeBulkDeleteConfirm' => __( 'Move %d selected WAVE-linked product(s) to the trash? You can restore them from WooCommerce → Products → Trash.', 'wave-woo-bridge' ), 'storeBulkDeleteFail' => __( 'Bulk delete failed.', 'wave-woo-bridge' ), 'storeLafucaepCategoriesNone' => __( 'No products on this page.', 'wave-woo-bridge' ), 'storeLafucaepCategoriesNoScript' => __( 'LAFU Category Admin Enhancer Pro bulk script is not loaded. Enable “Bulk AI categorization” in the enhancer settings.', 'wave-woo-bridge' ), 'storeLafucaepCategoriesNoModal' => __( 'The category enhancer modal is not available on this screen. Reload the page or confirm LAFU Pro bulk AI is enabled.', 'wave-woo-bridge' ), ), 'openaiModel' => isset( $wave_opts['openai_model'] ) ? $wave_opts['openai_model'] : 'gpt-4o-mini', 'storeWaveScrape' => ! empty( $wave_opts['enable_wave_scrape'] ), 'storeBulkFetch' => ! empty( $wave_opts['enable_wave_scrape'] ) || ! empty( $wave_opts['enable_icecat'] ) || Hinta_N8n::is_active(), 'hintaBulkConcurrency' => max( 1, min( 8, (int) ( $wave_opts['hinta_n8n_bulk_concurrency'] ?? 5 ) ) ), 'storeBulkVideo' => '' !== trim( (string) ( $wave_opts['video_meta_key'] ?? '' ) ), 'storeBulkCopy' => '' !== trim( (string) Settings::get_secrets()['openai_key'] ), 'storeBulkEnrich' => '' !== trim( (string) Settings::get_secrets()['openai_key'] ), 'storeLafucaepCategories' => $is_wave_store_screen && self::is_lafucaep_bulk_enhance_available(), ); if ( $is_wave_store_screen ) { $fcats = Store_Products::factory_category_filter_options(); $localize['storeFactoryCats'] = array_map( static function ( $row ) { return array( 'id' => (string) (int) ( $row['id'] ?? 0 ), 'text' => (string) ( $row['label'] ?? '' ), ); }, $fcats ); } if ( $on_settings ) { $localize['mapper'] = array( 'autoLabel' => __( 'Auto-detect', 'wave-woo-bridge' ), ); $lafucaep_markup_cat = self::is_lafucaep_markup_category_search_available(); $localize['lafucaepMarkupCat'] = array( 'enabled' => $lafucaep_markup_cat && current_user_can( 'edit_products' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'lafucaep_bulk_nonce' ), 'searchAction' => $lafucaep_markup_cat ? \Lafucaep_Ajax::BULK_SEARCH_CATEGORIES_ACTION : '', 'placeholder' => __( 'Search category…', 'wave-woo-bridge' ), 'inputTooShort' => __( 'Type at least one character to search.', 'wave-woo-bridge' ), 'searching' => __( 'Searching…', 'wave-woo-bridge' ), 'noResults' => __( 'No categories match.', 'wave-woo-bridge' ), 'loadingMore' => __( 'Loading more…', 'wave-woo-bridge' ), 'loadError' => __( 'Could not load categories.', 'wave-woo-bridge' ), ); } wp_localize_script( 'wave-woo-admin', 'waveWoo', $localize ); } } /** * Save settings form. */ public static function handle_settings_post() { if ( empty( $_POST['wave_woo_save'] ) || ! current_user_can( 'manage_woocommerce' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return; } if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'wave_woo_settings' ) ) { return; } Settings::save_from_post( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized Scheduler::reschedule_wp_crons(); add_settings_error( 'wave_woo', 'saved', __( 'Settings saved.', 'wave-woo-bridge' ), 'success' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- gated by same nonce check as settings save. if ( ! empty( $_POST['offload_test_and_save'] ) ) { $cfg = Settings::get_offload_config(); $rabbit = isset( $cfg['rabbitmq'] ) && is_array( $cfg['rabbitmq'] ) ? $cfg['rabbitmq'] : array(); $probe = self::test_rabbitmq_connection( $rabbit ); if ( is_wp_error( $probe ) ) { add_settings_error( 'wave_woo', 'offload_connect_error', sprintf( /* translators: %s: connection error details */ __( 'Saved, but RabbitMQ connection test failed: %s', 'wave-woo-bridge' ), $probe->get_error_message() ), 'error' ); } else { add_settings_error( 'wave_woo', 'offload_connect_ok', sprintf( /* translators: 1: host, 2: port, 3: transport */ __( 'Saved and connected to RabbitMQ at %1$s:%2$d via %3$s.', 'wave-woo-bridge' ), isset( $rabbit['host'] ) ? (string) $rabbit['host'] : '', isset( $rabbit['port'] ) ? (int) $rabbit['port'] : 0, isset( $probe['transport'] ) ? (string) $probe['transport'] : 'tcp' ), 'success' ); } } } /** * Lightweight RabbitMQ reachability test used by settings "Connect + save". * * @param array $rabbit RabbitMQ config from Settings::get_offload_config(). * @return array|\WP_Error */ private static function test_rabbitmq_connection( array $rabbit ) { $host = isset( $rabbit['host'] ) ? trim( (string) $rabbit['host'] ) : ''; $port = isset( $rabbit['port'] ) ? (int) $rabbit['port'] : 5672; $tls = ! empty( $rabbit['tls'] ); $timeout = isset( $rabbit['connect_timeout'] ) ? max( 1, min( 30, (int) $rabbit['connect_timeout'] ) ) : 3; if ( '' === $host ) { return new \WP_Error( 'offload_connect_host', __( 'Host is required.', 'wave-woo-bridge' ) ); } if ( $port < 1 || $port > 65535 ) { return new \WP_Error( 'offload_connect_port', __( 'Port is invalid.', 'wave-woo-bridge' ) ); } $transport = $tls ? 'tls' : 'tcp'; $target = $transport . '://' . $host . ':' . $port; $errno = 0; $errstr = ''; $stream = @stream_socket_client( $target, $errno, $errstr, (float) $timeout, STREAM_CLIENT_CONNECT ); if ( false === $stream ) { $details = ''; if ( '' !== (string) $errstr ) { $details = (string) $errstr; } if ( $errno ) { $details = '' !== $details ? ( $details . ' (' . $errno . ')' ) : (string) $errno; } if ( '' === $details ) { $details = __( 'unknown error', 'wave-woo-bridge' ); } return new \WP_Error( 'offload_connect_failed', $details ); } fclose( $stream ); return array( 'transport' => $transport ); } /** * AJAX: probe saved RabbitMQ endpoint reachability. * * @return void */ public static function ajax_rabbit_probe() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } $cfg = Settings::get_offload_config(); $rabbit = isset( $cfg['rabbitmq'] ) && is_array( $cfg['rabbitmq'] ) ? $cfg['rabbitmq'] : array(); $host = isset( $rabbit['host'] ) ? trim( (string) $rabbit['host'] ) : ''; $port = isset( $rabbit['port'] ) ? (int) $rabbit['port'] : 5672; $vhost = isset( $rabbit['vhost'] ) ? (string) $rabbit['vhost'] : '/'; $tls = ! empty( $rabbit['tls'] ); if ( '' === $host ) { wp_send_json_success( array( 'connected' => false, 'host' => '', 'port' => $port, 'vhost' => $vhost, 'tls' => $tls, 'message' => __( 'Host is empty.', 'wave-woo-bridge' ), ) ); } // Cache key based on connection settings $cache_key = 'wave_rabbit_probe_' . md5( serialize( array( $host, $port, $vhost, $tls ) ) ); $cached = get_transient( $cache_key ); if ( $cached !== false ) { wp_send_json_success( $cached ); } $probe = self::test_rabbitmq_connection( $rabbit ); $result = array(); if ( is_wp_error( $probe ) ) { $result = array( 'connected' => false, 'host' => $host, 'port' => $port, 'vhost' => $vhost, 'tls' => $tls, 'message' => $probe->get_error_message(), ); } else { $result = array( 'connected' => true, 'host' => $host, 'port' => $port, 'vhost' => $vhost, 'tls' => $tls, 'transport' => isset( $probe['transport'] ) ? (string) $probe['transport'] : ( $tls ? 'tls' : 'tcp' ), ); } // Cache for 5 minutes set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS ); wp_send_json_success( $result ); } /** * Parse staging search parameters from GET or POST. * * @param array $src Raw request array (e.g. wp_unslash( $_GET )). * @return array{q:string,cat:string,page:int,per_page:int,in_stock_only:bool,coming_soon_only:bool,not_imported_only:bool} */ public static function parse_staging_search_input( array $src ) { $q = isset( $src['wq'] ) ? sanitize_text_field( wp_unslash( (string) $src['wq'] ) ) : ''; $cat = isset( $src['wcat'] ) ? sanitize_text_field( wp_unslash( (string) $src['wcat'] ) ) : ''; $page = isset( $src['paged'] ) ? max( 1, (int) $src['paged'] ) : 1; $pp_req = isset( $src['wpp'] ) ? (int) $src['wpp'] : 24; $per_page = in_array( $pp_req, self::SEARCH_PER_PAGE_CHOICES, true ) ? $pp_req : 24; // Until the user touches the "Only in stock" filter (any form that sends wavail_sent), show all rows. $in_stock_only = false; if ( isset( $src['wavail_sent'] ) ) { $in_stock_only = ! empty( $src['wavail'] ); } $coming_soon_only = false; if ( isset( $src['wsoon_sent'] ) ) { $coming_soon_only = ! empty( $src['wsoon'] ); } $not_imported_only = false; if ( isset( $src['wni_sent'] ) ) { $not_imported_only = ! empty( $src['wni'] ); } return compact( 'q', 'cat', 'page', 'per_page', 'in_stock_only', 'coming_soon_only', 'not_imported_only' ); } /** * Run staging search + flags for templates (full page or AJAX partial). * * @param array $parsed Output of parse_staging_search_input(). * @param bool $ajax When true, do not auto-kick empty-catalog fetch. * @return array Merged vars for admin-search.php / staging-search-grid partial. */ public static function staging_search_bundle( array $parsed, $ajax = false ) { $res = Database::search_staging( array( 'q' => $parsed['q'], 'category' => $parsed['cat'], 'page' => $parsed['page'], 'per_page' => $parsed['per_page'], 'in_stock_only' => $parsed['in_stock_only'], 'coming_soon_only' => ! empty( $parsed['coming_soon_only'] ), 'not_imported_only' => ! empty( $parsed['not_imported_only'] ), ) ); $staging_total = Database::staging_row_count(); $s = Settings::get(); $export_url_trim = trim( (string) ( $s['export_url'] ?? '' ) ); $wave_can_auto_sync = ( 0 === $staging_total && '' !== $export_url_trim ); $wave_search_sync_started = false; // Search page should never auto-trigger fetch+parse: parsing is handled by schedule or explicit user action. return array_merge( $parsed, array( 'res' => $res, 'staging_total' => $staging_total, 'wave_can_auto_sync' => $wave_can_auto_sync, 'wave_search_sync_started' => $wave_search_sync_started, ) ); } /** * WAVE-linked products already in WooCommerce. */ public static function render_store_products() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission.', 'wave-woo-bridge' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filters. $parsed = Store_Products::parse_input( wp_unslash( $_GET ) ); $res = Store_Products::query( $parsed ); include WAVE_WOO_PLUGIN_DIR . 'templates/admin-store-products.php'; } /** * AJAX: Store products grid + pagination (live search / paging without full reload). */ public static function ajax_store_products_fragment() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; POST read-only. $parsed = Store_Products::parse_input( wp_unslash( $_POST ) ); $res = Store_Products::query( $parsed ); $per_page = (int) $parsed['per_page']; $page = (int) $parsed['page']; $total_pages = max( 1, (int) ceil( (int) $res['total'] / max( 1, $per_page ) ) ); $admin_base = admin_url( 'admin.php' ); ob_start(); include WAVE_WOO_PLUGIN_DIR . 'templates/partials/store-products-body.php'; $html = ob_get_clean(); wp_send_json_success( array( 'html' => $html, 'total' => (int) $res['total'], 'total_label' => sprintf( /* translators: %d: count */ __( '%d items', 'wave-woo-bridge' ), (int) $res['total'] ), 'page' => $page, 'total_pages' => $total_pages, 'has_rows' => ! empty( $res['rows'] ), ) ); } /** * Search / browse staging. */ public static function render_search() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only browse params. $bundle = self::staging_search_bundle( self::parse_staging_search_input( wp_unslash( $_GET ) ), false ); extract( $bundle ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract $cats = Database::list_categories(); $wave_search_cat_ajax = wp_script_is( 'selectWoo', 'registered' ); $last_fetch = get_option( 'wave_woo_last_fetch', array() ); $progress = get_option( WAVE_WOO_PROGRESS_OPTION, array() ); $wave_parse_state = get_option( CSV_Sync::PARSE_STATE, null ); $wave_fetch_prog = isset( $progress['fetch'] ) && is_array( $progress['fetch'] ) ? $progress['fetch'] : array(); $wave_fetch_status = isset( $wave_fetch_prog['status'] ) ? (string) $wave_fetch_prog['status'] : ''; $wave_parse_busy = ( is_array( $wave_parse_state ) && ! empty( $wave_parse_state['file'] ) ) || ( 'parsing' === $wave_fetch_status ); $wave_parse_rows = max( (int) ( $wave_fetch_prog['rows'] ?? 0 ), ( is_array( $wave_parse_state ) ? (int) ( $wave_parse_state['processed'] ?? 0 ) : 0 ) ); $wave_parse_total = max( (int) ( $wave_fetch_prog['total_rows'] ?? 0 ), ( is_array( $wave_parse_state ) ? (int) ( $wave_parse_state['total_rows'] ?? 0 ) : 0 ) ); include WAVE_WOO_PLUGIN_DIR . 'templates/admin-search.php'; } /** * AJAX: searchable category paths for the staging search filter (SelectWoo). */ public static function ajax_search_staging_categories() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; POST read-only. $q = isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['q'] ) ) : ''; $raw = Database::search_staging_categories( $q, 80 ); $results = array(); foreach ( $raw as $path ) { $path = (string) $path; $results[] = array( 'id' => $path, 'text' => Settings::feed_category_option_label( $path ), ); } wp_send_json_success( array( 'results' => $results ) ); } /** * AJAX: return HTML fragment for grid + pagination (no full page reload). */ public static function ajax_search_staging() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce above; POST read-only. $bundle = self::staging_search_bundle( self::parse_staging_search_input( wp_unslash( $_POST ) ), true ); extract( $bundle ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract ob_start(); include WAVE_WOO_PLUGIN_DIR . 'templates/partials/staging-search-grid.php'; $html = ob_get_clean(); $total_pages = max( 1, (int) ceil( (int) $res['total'] / max( 1, (int) $per_page ) ) ); wp_send_json_success( array( 'html' => $html, 'total' => (int) $res['total'], 'page' => (int) $page, 'total_pages' => $total_pages, 'total_label' => sprintf( /* translators: %d: number of staging rows matching filters */ __( '%d items', 'wave-woo-bridge' ), (int) $res['total'] ), ) ); } /** * AJAX: staging row IDs for current search filters (same order as grid). */ public static function ajax_search_staging_ids() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce above; POST read-only. $parsed = self::parse_staging_search_input( wp_unslash( $_POST ) ); $out = Database::search_staging_ids( array( 'q' => $parsed['q'], 'category' => $parsed['cat'], 'in_stock_only' => $parsed['in_stock_only'], 'coming_soon_only' => ! empty( $parsed['coming_soon_only'] ), 'not_imported_only' => ! empty( $parsed['not_imported_only'] ), 'limit' => 0, ) ); wp_send_json_success( $out ); } /** * Settings page. */ public static function render_settings() { settings_errors( 'wave_woo' ); $s = Settings::get(); $secrets = Settings::get_secrets(); $bp = $secrets['basic_pass']; $okey = $secrets['openai_key']; $ytkey = $secrets['youtube_key']; $wave_basic_mask = ( '' !== $bp ) ? Crypto::masked_secret_preview( $bp ) : array( 'fingerprint' => '', 'suffix' => '', ); $wave_openai_saved = ( '' !== $okey ); $wave_openai_mask = $wave_openai_saved ? Crypto::masked_secret_preview( $okey ) : array( 'fingerprint' => '', 'suffix' => '', ); $wave_youtube_saved = ( '' !== $ytkey ); $wave_icecat_saved = ( '' !== trim( (string) ( $secrets['icecat_access_key'] ?? '' ) ) ); include WAVE_WOO_PLUGIN_DIR . 'templates/admin-settings.php'; } /** * Normalize import ID lists on a progress payload for JSON clients. * * @param array $p Progress option (mutated when import present). * @return array */ public static function sanitize_progress_for_json( array $p ) { if ( ! empty( $p['import'] ) && is_array( $p['import'] ) ) { foreach ( array( 'batch_ids', 'active_staging_ids', 'done_staging_ids', 'failed_staging_ids' ) as $ik ) { if ( isset( $p['import'][ $ik ] ) && is_array( $p['import'][ $ik ] ) ) { $p['import'][ $ik ] = array_values( array_map( 'intval', $p['import'][ $ik ] ) ); } else { $p['import'][ $ik ] = array(); } } $dt = array(); if ( ! empty( $p['import']['done_tiles'] ) && is_array( $p['import']['done_tiles'] ) ) { foreach ( $p['import']['done_tiles'] as $sid => $tile ) { $sid = (int) $sid; if ( $sid <= 0 || ! is_array( $tile ) ) { continue; } $dt[ (string) $sid ] = array( 'product_id' => (int) ( $tile['product_id'] ?? 0 ), 'thumb' => esc_url_raw( (string) ( $tile['thumb'] ?? '' ) ), 'edit_url' => esc_url_raw( (string) ( $tile['edit_url'] ?? '' ) ), ); } } $p['import']['done_tiles'] = $dt; } return $p; } /** * AJAX: progress JSON. */ public static function ajax_progress() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } Scheduler::maybe_kick_action_scheduler_for_import(); $p = get_option( WAVE_WOO_PROGRESS_OPTION, array() ); if ( ! is_array( $p ) ) { $p = array(); } $p = self::sanitize_progress_for_json( $p ); wp_send_json_success( $p ); } /** * Parse staging row IDs from queue-import POST (array ids[], comma-separated string, or JSON array). * * @param array $post $_POST-like array. * @return int[] */ public static function parse_import_staging_ids_from_post( array $post ) { $ids = array(); if ( isset( $post['ids_json'] ) && is_string( $post['ids_json'] ) ) { $dec = json_decode( wp_unslash( $post['ids_json'] ), true ); if ( is_array( $dec ) ) { $ids = array_map( 'intval', $dec ); } } if ( empty( $ids ) && isset( $post['ids'] ) ) { $raw = wp_unslash( $post['ids'] ); if ( is_array( $raw ) ) { $ids = array_map( 'intval', $raw ); } elseif ( is_string( $raw ) && '' !== trim( $raw ) ) { $ids = array_map( 'intval', explode( ',', $raw ) ); } } $ids = array_values( array_unique( array_filter( $ids, static function ( $n ) { return is_int( $n ) && $n > 0; } ) ) ); return $ids; } /** * AJAX: queue import. */ public static function ajax_queue_import() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only parse of int list. $ids = self::parse_import_staging_ids_from_post( $_POST ); if ( empty( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'wave-woo-bridge' ) ) ); } $part = Database::partition_staging_ids_for_import( $ids ); if ( empty( $part['queue'] ) ) { if ( $part['skipped_in_store'] > 0 ) { $notice = sprintf( /* translators: %d: number of skipped staging rows */ __( 'No new imports queued. %d selected row(s) are already in your store. Open each product in WooCommerce and use “Sync WAVE” to refresh price and stock without a full re-import.', 'wave-woo-bridge' ), $part['skipped_in_store'] ); wp_send_json_success( array( 'queued' => 0, 'skipped_in_store' => $part['skipped_in_store'], 'notice' => $notice, ) ); } wp_send_json_error( array( 'message' => __( 'No valid staging rows in your selection.', 'wave-woo-bridge' ) ) ); } Scheduler::queue_imports( $part['queue'] ); $notice = ''; if ( $part['skipped_in_store'] > 0 ) { $notice = sprintf( /* translators: 1: queued count, 2: skipped count */ __( '%1$d product(s) queued for import. Skipped %2$d already in your store — use “Sync WAVE” on those products to update price and stock.', 'wave-woo-bridge' ), count( $part['queue'] ), $part['skipped_in_store'] ); } $prog_raw = get_option( WAVE_WOO_PROGRESS_OPTION, array() ); $import_snapshot = null; if ( is_array( $prog_raw ) && ! empty( $prog_raw['import'] ) && is_array( $prog_raw['import'] ) ) { $san = self::sanitize_progress_for_json( $prog_raw ); $import_snapshot = isset( $san['import'] ) ? $san['import'] : null; } wp_send_json_success( array( 'queued' => count( $part['queue'] ), 'skipped_in_store' => $part['skipped_in_store'], 'notice' => $notice, 'import_progress' => $import_snapshot, ) ); } /** * AJAX: manual fetch + parse chain. */ public static function ajax_run_fetch() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } $path = CSV_Sync::fetch_remote(); if ( is_wp_error( $path ) ) { wp_send_json_error( array( 'message' => $path->get_error_message() ) ); } $beg = CSV_Sync::begin_parse( $path ); if ( is_wp_error( $beg ) ) { wp_send_json_error( array( 'message' => $beg->get_error_message() ) ); } Scheduler::queue_parse_batch(); wp_send_json_success( array( 'ok' => true ) ); } /** * AJAX: one batch of scheduled reconcile (staging → linked simple/external products). */ public static function ajax_reconcile_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } $r = Reconcile::run_batch(); wp_send_json_success( array( 'done' => ! empty( $r['done'] ), 'updated' => isset( $r['updated'] ) ? (int) $r['updated'] : 0, 'skipped' => isset( $r['skipped'] ) ? (int) $r['skipped'] : 0, ) ); } /** * AJAX: validate OpenAI key, return fingerprint + model list (saved key or optional test_key). */ public static function ajax_openai_probe() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } $test = isset( $_POST['test_key'] ) ? trim( (string) wp_unslash( $_POST['test_key'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $key = '' !== $test ? $test : Settings::get_secrets()['openai_key']; $out = OpenAI_Admin::probe( $key ); if ( is_wp_error( $out ) ) { wp_send_json_error( array( 'message' => $out->get_error_message() ) ); } wp_send_json_success( $out ); } /** * AJAX: probe export URL + Basic auth (form values or saved). */ public static function ajax_feed_probe() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } $s = Settings::get(); $secrets = Settings::get_secrets(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in check_ajax_referer above. $url_raw = isset( $_POST['export_url'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['export_url'] ) ) : ''; $url = '' !== $url_raw ? esc_url_raw( trim( $url_raw ) ) : $s['export_url']; $user = isset( $_POST['basic_user'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['basic_user'] ) ) : $s['basic_user']; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( '' === $url ) { $url = $s['export_url']; } $pass = $secrets['basic_pass']; if ( isset( $_POST['basic_pass_test'] ) && (string) wp_unslash( $_POST['basic_pass_test'] ) !== '' ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $pass = (string) wp_unslash( $_POST['basic_pass_test'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } $probe = CSV_Sync::probe_feed( $url, $user, $pass ); if ( is_wp_error( $probe ) ) { wp_send_json_error( array( 'message' => $probe->get_error_message() ) ); } wp_send_json_success( $probe ); } /** * AJAX: sync one product from WAVE staging (product edit screen). */ public static function ajax_sync_product() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $result = Product_Sync::sync_product( $pid ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } $last = get_post_meta( $pid, Product_Sync::META_LAST_SYNC, true ); wp_send_json_success( array( 'message' => __( 'Success: price, stock, and SKU updated from staging.', 'wave-woo-bridge' ), 'last_sync' => is_string( $last ) ? $last : '', ) ); } /** * AJAX: resolve product video (same logic as import) and save to video meta key. */ public static function ajax_fetch_product_video() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $s = Settings::get(); $vkey = isset( $s['video_meta_key'] ) ? trim( (string) $s['video_meta_key'] ) : ''; if ( '' === $vkey ) { wp_send_json_error( array( 'message' => __( 'Set a video meta key under WAVE → Settings.', 'wave-woo-bridge' ) ) ); } $r = Enrichment::fetch_product_video_url_for_editor( $pid ); if ( is_wp_error( $r ) ) { wp_send_json_error( array( 'message' => $r->get_error_message() ) ); } Product_Video::apply_url_to_product( $pid, $r['url'] ); wp_send_json_success( array( 'url' => $r['url'], 'message' => isset( $r['message'] ) ? (string) $r['message'] : '', ) ); } /** * AJAX: scrape WAVE product page for EU energy class; save meta + attribute when found (no error when missing). */ public static function ajax_fetch_product_energy_class() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $res = Enrichment::fetch_energy_class_for_editor( $pid ); if ( ! empty( $res['found'] ) && ! empty( $res['value'] ) ) { $pdf_id = (int) ( $res['pdf_attachment_id'] ?? 0 ); $img_id = (int) ( $res['preview_image_attachment_id'] ?? 0 ); $lafu_class = isset( $res['lafu_class'] ) ? (string) $res['lafu_class'] : ''; if ( function_exists( 'lafu_eel_set_product_energy' ) && '' !== $lafu_class ) { lafu_eel_set_product_energy( $pid, $lafu_class, $pdf_id, array( 'preview_image_id' => $img_id, ) ); } Product_Importer::apply_energy_class_to_wc_product( $pid, (string) $res['value'] ); wp_send_json_success( array( 'applied' => true, 'value' => (string) $res['value'], 'lafu_class' => $lafu_class, 'pdf_attached' => $pdf_id > 0, 'image_attached' => $img_id > 0, 'message' => '', ) ); } wp_send_json_success( array( 'applied' => false, 'value' => '', 'lafu_class' => '', 'pdf_attached' => false, 'image_attached' => false, 'message' => isset( $res['message'] ) ? (string) $res['message'] : '', ) ); } /** * AJAX: start bulk re-sync of all WAVE-linked products. */ public static function ajax_bulk_shop_sync_start() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } $out = Bulk_Shop_Sync::start(); if ( is_wp_error( $out ) ) { wp_send_json_error( array( 'message' => $out->get_error_message() ) ); } wp_send_json_success( $out ); } /** * AJAX: process one batch of bulk shop sync. */ public static function ajax_bulk_shop_sync_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } wp_send_json_success( Bulk_Shop_Sync::step() ); } /** * AJAX: hide markup-changed notice without running sync (clears pending flag). */ public static function ajax_dismiss_markup_shop_sync_notice() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => 'denied' ), 403 ); } delete_option( Settings::OPTION_MARKUP_NEEDS_SHOP_SYNC ); wp_send_json_success(); } /** * AJAX: list image URL candidates for product-edit modal (no download). */ public static function ajax_preview_product_images() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $source = isset( $_POST['source'] ) ? sanitize_key( (string) wp_unslash( $_POST['source'] ) ) : 'wave'; if ( ! in_array( $source, array( 'wave', 'icecat', 'hinta' ), true ) ) { $source = 'wave'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing $page_raw = isset( $_POST['page_url'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['page_url'] ) ) : ''; $page_url = Product_Importer::first_picker_page_url_from_text( $page_raw ); $res = Product_Importer::preview_picker_source( $pid, $source, $page_url ); if ( is_wp_error( $res ) ) { wp_send_json_error( array( 'message' => $res->get_error_message() ) ); } wp_send_json_success( $res ); } /** * AJAX: OpenAI extracts image URLs from HTML of user-supplied pages (picker fallback). */ public static function ajax_preview_product_images_ai() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $urls_text = isset( $_POST['urls_text'] ) ? sanitize_textarea_field( wp_unslash( (string) $_POST['urls_text'] ) ) : ''; $res = Product_Importer::preview_ai_product_images( $pid, $urls_text ); if ( is_wp_error( $res ) ) { wp_send_json_error( array( 'message' => $res->get_error_message() ) ); } wp_send_json_success( $res ); } /** * AJAX: sideload images chosen in the product-edit modal. */ public static function ajax_apply_product_images() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $featured_raw = isset( $_POST['featured_url'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['featured_url'] ) ) : ''; $featured = '' !== $featured_raw ? esc_url_raw( trim( $featured_raw ) ) : ''; $gallery = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST['gallery_urls_json'] ) ) { $dec = json_decode( sanitize_textarea_field( wp_unslash( (string) $_POST['gallery_urls_json'] ) ), true ); if ( is_array( $dec ) ) { foreach ( $dec as $u ) { if ( is_string( $u ) ) { $gallery[] = esc_url_raw( trim( $u ) ); } } } } $res = Product_Importer::apply_picked_images_for_wave_product( $pid, $featured, $gallery, true ); if ( is_wp_error( $res ) ) { wp_send_json_error( array( 'message' => $res->get_error_message() ) ); } wp_send_json_success( array( 'added' => (int) $res, 'message' => sprintf( /* translators: %d: number of imported images now assigned to the product. */ _n( 'Replaced product images with %d image.', 'Replaced product images with %d images.', (int) $res, 'wave-woo-bridge' ), (int) $res ), ) ); } /** * AJAX: one product in Store products bulk WAVE image fetch. */ public static function ajax_store_bulk_wave_images_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing $overwrite_existing = ! empty( $_POST['overwrite_existing'] ); if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $res = Product_Importer::store_bulk_apply_wave_images( $pid, $overwrite_existing ); $out = array( 'product_id' => $pid, 'title' => Store_Products::decode_product_title_for_display( get_the_title( $pid ) ), 'edit_url' => (string) get_edit_post_link( $pid, 'raw' ), ); if ( is_wp_error( $res ) ) { $out['status'] = 'error'; $out['count'] = 0; $out['message'] = $res->get_error_message(); wp_send_json_success( $out ); return; } $out['status'] = isset( $res['status'] ) ? (string) $res['status'] : 'ok'; $out['count'] = isset( $res['count'] ) ? (int) $res['count'] : 0; $out['message'] = isset( $res['message'] ) ? (string) $res['message'] : ''; wp_send_json_success( $out ); } /** * AJAX: one product in Store products bulk video fetch. */ public static function ajax_store_bulk_video_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $s = Settings::get(); $vkey = isset( $s['video_meta_key'] ) ? trim( (string) $s['video_meta_key'] ) : ''; if ( '' === $vkey ) { wp_send_json_error( array( 'message' => __( 'Set a video meta key under WAVE → Settings.', 'wave-woo-bridge' ) ) ); } $out = array( 'product_id' => $pid, 'title' => Store_Products::decode_product_title_for_display( get_the_title( $pid ) ), 'edit_url' => (string) get_edit_post_link( $pid, 'raw' ), ); $existing = Product_Video::get_display_url_for_meta( $pid, $vkey ); if ( '' !== $existing ) { $out['status'] = 'skipped'; $out['count'] = 0; $out['message'] = __( 'Video already exists.', 'wave-woo-bridge' ); wp_send_json_success( $out ); return; } $r = Enrichment::fetch_product_video_url_for_editor( $pid ); if ( is_wp_error( $r ) || empty( $r['url'] ) ) { $out['status'] = 'error'; $out['count'] = 0; $out['message'] = is_wp_error( $r ) ? $r->get_error_message() : __( 'No video found.', 'wave-woo-bridge' ); wp_send_json_success( $out ); return; } Product_Video::apply_url_to_product( $pid, (string) $r['url'] ); $stored = Product_Video::get_display_url_for_meta( $pid, $vkey ); if ( '' === $stored ) { $out['status'] = 'error'; $out['count'] = 0; $out['message'] = __( 'Video URL was resolved but could not be saved.', 'wave-woo-bridge' ); wp_send_json_success( $out ); return; } $out['status'] = 'ok'; $out['count'] = 1; $out['message'] = isset( $r['message'] ) ? (string) $r['message'] : ''; wp_send_json_success( $out ); } /** * AJAX: update one store product title inline. */ public static function ajax_store_update_title() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'edit_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to edit products.', 'wave-woo-bridge' ) ), 403 ); } $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $key = get_post_meta( $pid, Settings::META_SUPPLIER, true ); if ( ! is_string( $key ) || '' === trim( $key ) ) { wp_send_json_error( array( 'message' => __( 'Product is not linked to WAVE.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['title'] ) ) : ''; $title = trim( preg_replace( '/\s+/u', ' ', $title ) ); if ( '' === $title ) { wp_send_json_error( array( 'message' => __( 'Title cannot be empty.', 'wave-woo-bridge' ) ) ); } $r = wp_update_post( array( 'ID' => $pid, 'post_title' => $title, ), true ); if ( is_wp_error( $r ) || ! $r ) { wp_send_json_error( array( 'message' => __( 'Could not save title.', 'wave-woo-bridge' ) ) ); } wp_send_json_success( array( 'title' => Store_Products::decode_product_title_for_display( get_the_title( $pid ) ), 'message' => __( 'Title saved.', 'wave-woo-bridge' ), ) ); } /** * AJAX: move a WAVE-linked product to the trash from Factory. */ public static function ajax_store_delete_product() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'delete_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to delete products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $pid = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; if ( $pid <= 0 || ! current_user_can( 'delete_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $key = get_post_meta( $pid, Settings::META_SUPPLIER, true ); if ( ! is_string( $key ) || '' === trim( $key ) ) { wp_send_json_error( array( 'message' => __( 'Product is not linked to WAVE.', 'wave-woo-bridge' ) ) ); } $r = wp_trash_post( $pid ); if ( ! $r ) { wp_send_json_error( array( 'message' => __( 'Could not delete product.', 'wave-woo-bridge' ) ) ); } wp_send_json_success( array( 'product_id' => $pid ) ); } /** * AJAX: publish selected WAVE-linked products (draft / pending / private → publish). */ public static function ajax_store_bulk_publish() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; ids parsed safely below. $ids = self::parse_import_staging_ids_from_post( $_POST ); if ( empty( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'wave-woo-bridge' ) ) ); } if ( count( $ids ) > 200 ) { wp_send_json_error( array( 'message' => __( 'Max 200 per batch.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $target_status = isset( $_POST['target_status'] ) ? sanitize_key( (string) wp_unslash( $_POST['target_status'] ) ) : 'publish'; if ( ! in_array( $target_status, array( 'publish', 'pending' ), true ) ) { $target_status = 'publish'; } $eligible = array( 'publish', 'draft', 'pending', 'private', 'future' ); $updated = 0; $skipped = 0; $failed = 0; foreach ( $ids as $pid ) { if ( ! current_user_can( 'edit_post', $pid ) || ! current_user_can( 'publish_post', $pid ) ) { ++$failed; continue; } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { ++$failed; continue; } $key = get_post_meta( $pid, Settings::META_SUPPLIER, true ); if ( ! is_string( $key ) || '' === trim( $key ) ) { ++$failed; continue; } if ( $target_status === $post->post_status ) { ++$skipped; continue; } if ( ! in_array( $post->post_status, $eligible, true ) ) { ++$skipped; continue; } $r = wp_update_post( array( 'ID' => $pid, 'post_status' => $target_status, ), true ); if ( is_wp_error( $r ) || ! $r ) { ++$failed; continue; } ++$updated; } $status_human = 'publish' === $target_status ? __( 'published', 'wave-woo-bridge' ) : __( 'pending', 'wave-woo-bridge' ); $msg = sprintf( /* translators: 1: updated count, 2: status label, 3: skipped count, 4: failed count */ __( 'Updated %1$d to %2$s. Skipped %3$d. Failed %4$d.', 'wave-woo-bridge' ), $updated, $status_human, $skipped, $failed ); wp_send_json_success( array( 'message' => $msg, 'updated' => $updated, 'target_status' => $target_status, 'skipped' => $skipped, 'failed' => $failed, ) ); } /** * AJAX: move selected WAVE-linked products to the trash (Factory bulk). */ public static function ajax_store_bulk_delete() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'delete_products' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to delete products.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; ids parsed safely below. $ids = self::parse_import_staging_ids_from_post( $_POST ); if ( empty( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'wave-woo-bridge' ) ) ); } if ( count( $ids ) > 200 ) { wp_send_json_error( array( 'message' => __( 'Max 200 per batch.', 'wave-woo-bridge' ) ) ); } $trashed_ids = array(); $skipped = 0; $failed = 0; foreach ( $ids as $pid ) { if ( ! current_user_can( 'delete_post', $pid ) ) { ++$failed; continue; } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { ++$failed; continue; } $key = get_post_meta( $pid, Settings::META_SUPPLIER, true ); if ( ! is_string( $key ) || '' === trim( $key ) ) { ++$skipped; continue; } $r = wp_trash_post( $pid ); if ( ! $r ) { ++$failed; continue; } $trashed_ids[] = $pid; } $msg = sprintf( /* translators: 1: trashed count, 2: skipped count, 3: failed count */ __( 'Moved %1$d to trash. Skipped %2$d. Failed %3$d.', 'wave-woo-bridge' ), count( $trashed_ids ), $skipped, $failed ); wp_send_json_success( array( 'message' => $msg, 'trashed' => count( $trashed_ids ), 'trashed_ids' => $trashed_ids, 'skipped' => $skipped, 'failed' => $failed, ) ); } /** * AJAX: set product slug from current title for selected WAVE-linked products. */ public static function ajax_store_bulk_update_slugs() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; ids parsed safely below. $ids = self::parse_import_staging_ids_from_post( $_POST ); if ( empty( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'wave-woo-bridge' ) ) ); } if ( count( $ids ) > 200 ) { wp_send_json_error( array( 'message' => __( 'Max 200 per batch.', 'wave-woo-bridge' ) ) ); } $updated = 0; $skipped = 0; $failed = 0; foreach ( $ids as $pid ) { if ( ! current_user_can( 'edit_post', $pid ) ) { ++$failed; continue; } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { ++$failed; continue; } $res = Product_Importer::store_bulk_update_slug_from_title( $pid ); if ( is_wp_error( $res ) ) { ++$failed; continue; } if ( ! empty( $res['updated'] ) ) { ++$updated; } else { ++$skipped; } } $msg = sprintf( /* translators: 1: updated count, 2: skipped count (already matched), 3: failed count */ __( 'Updated %1$d slug(s). Skipped %2$d (already matched title or could not change). Failed %3$d.', 'wave-woo-bridge' ), $updated, $skipped, $failed ); wp_send_json_success( array( 'message' => $msg, 'updated' => $updated, 'skipped' => $skipped, 'failed' => $failed, ) ); } /** * Whether attachment dimensions are below configured import quality minimums. * * @param int $attachment_id Attachment ID. * @param int $min_w Min width. * @param int $min_h Min height. * @return bool */ private static function attachment_below_quality_thresholds( $attachment_id, $min_w, $min_h ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 ) { return false; } $meta = wp_get_attachment_metadata( $attachment_id ); if ( ! is_array( $meta ) ) { return false; } $w = isset( $meta['width'] ) ? (int) $meta['width'] : 0; $h = isset( $meta['height'] ) ? (int) $meta['height'] : 0; if ( $w <= 0 || $h <= 0 ) { return false; } if ( $min_w > 0 && $w < $min_w ) { return true; } if ( $min_h > 0 && $h < $min_h ) { return true; } return false; } /** * Whether an attachment is still used as a WooCommerce product or variation featured image, or in a product gallery. * * @param int $attachment_id Attachment post ID. * @return bool */ private static function attachment_referenced_by_wc_product_image( $attachment_id ) { global $wpdb; $aid = (int) $attachment_id; if ( $aid <= 0 ) { return false; } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- No cached API for cross-meta gallery refcounts; admin-only during low-res image cleanup. $thumb_refs = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s AND p.post_type IN ( 'product', 'product_variation' ) AND p.post_status NOT IN ( 'trash', 'auto-draft' )", (string) $aid ) ); if ( $thumb_refs > 0 ) { return true; } $gallery_refs = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_product_image_gallery' AND pm.meta_value != '' AND FIND_IN_SET(%d, pm.meta_value) > 0 AND p.post_type = 'product' AND p.post_status NOT IN ( 'trash', 'auto-draft' )", $aid ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return $gallery_refs > 0; } /** * Permanently delete an attachment if it is no longer referenced by any product image meta. * * @param int $attachment_id Attachment ID. * @param array $already_tried Internal dedupe map attachment_id => true (by ref). * @return bool Whether the attachment was deleted. */ private static function maybe_delete_orphaned_product_image_attachment( $attachment_id, array &$already_tried ) { $aid = (int) $attachment_id; if ( $aid <= 0 || isset( $already_tried[ $aid ] ) ) { return false; } $already_tried[ $aid ] = true; if ( ! apply_filters( 'wave_woo_delete_low_res_attachment_from_library', true, $aid ) ) { return false; } if ( ! current_user_can( 'delete_post', $aid ) ) { return false; } $post = get_post( $aid ); if ( ! $post || 'attachment' !== $post->post_type ) { return false; } if ( self::attachment_referenced_by_wc_product_image( $aid ) ) { return false; } return (bool) wp_delete_attachment( $aid, true ); } /** * AJAX: remove low-res featured/gallery images from selected WAVE-linked products. */ public static function ajax_store_bulk_remove_low_res_images() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } $settings = Settings::get(); $min_w = isset( $settings['import_image_min_width'] ) ? max( 0, (int) $settings['import_image_min_width'] ) : 0; $min_h = isset( $settings['import_image_min_height'] ) ? max( 0, (int) $settings['import_image_min_height'] ) : 0; if ( $min_w <= 0 && $min_h <= 0 ) { wp_send_json_error( array( 'message' => __( 'Set minimum image width or height in WAVE → Settings first.', 'wave-woo-bridge' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above; ids parsed safely below. $ids = self::parse_import_staging_ids_from_post( $_POST ); if ( empty( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No products selected.', 'wave-woo-bridge' ) ) ); } if ( count( $ids ) > 200 ) { wp_send_json_error( array( 'message' => __( 'Max 200 per batch.', 'wave-woo-bridge' ) ) ); } $updated_products = 0; $removed_images = 0; $deleted_attachments = 0; $skipped = 0; $failed = 0; $delete_tried = array(); foreach ( $ids as $pid ) { if ( ! current_user_can( 'edit_post', $pid ) ) { ++$failed; continue; } $post = get_post( $pid ); if ( ! $post || 'product' !== $post->post_type ) { ++$failed; continue; } $key = get_post_meta( $pid, Settings::META_SUPPLIER, true ); if ( ! is_string( $key ) || '' === trim( $key ) ) { ++$failed; continue; } $removed_for_product = 0; $removed_attachment_ids = array(); $thumb_id = (int) get_post_thumbnail_id( $pid ); if ( $thumb_id > 0 && self::attachment_below_quality_thresholds( $thumb_id, $min_w, $min_h ) ) { delete_post_thumbnail( $pid ); ++$removed_for_product; $removed_attachment_ids[] = $thumb_id; } $gallery_raw = get_post_meta( $pid, '_product_image_gallery', true ); $gallery_ids = array_filter( array_map( 'intval', explode( ',', is_string( $gallery_raw ) ? $gallery_raw : '' ) ) ); if ( ! empty( $gallery_ids ) ) { $kept_ids = array(); foreach ( $gallery_ids as $aid ) { if ( self::attachment_below_quality_thresholds( $aid, $min_w, $min_h ) ) { ++$removed_for_product; $removed_attachment_ids[] = $aid; continue; } $kept_ids[] = $aid; } if ( count( $kept_ids ) !== count( $gallery_ids ) ) { update_post_meta( $pid, '_product_image_gallery', implode( ',', $kept_ids ) ); } } if ( $removed_for_product > 0 ) { $removed_images += $removed_for_product; ++$updated_products; clean_post_cache( $pid ); foreach ( array_unique( array_map( 'intval', $removed_attachment_ids ) ) as $raid ) { if ( self::maybe_delete_orphaned_product_image_attachment( $raid, $delete_tried ) ) { ++$deleted_attachments; } } } else { ++$skipped; } } $msg = sprintf( /* translators: 1: products updated, 2: image slots detached from products, 3: skipped products, 4: failed products, 5: attachments permanently deleted from Media Library */ __( 'Updated %1$d product(s), removed %2$d low-res image slot(s) from products, and permanently deleted %5$d file(s) from the Media Library (only when not used by another product). Skipped %3$d. Failed %4$d.', 'wave-woo-bridge' ), $updated_products, $removed_images, $skipped, $failed, $deleted_attachments ); wp_send_json_success( array( 'message' => $msg, 'updated_products' => $updated_products, 'removed_images' => $removed_images, 'deleted_attachments' => $deleted_attachments, 'skipped' => $skipped, 'failed' => $failed, ) ); } /** * AJAX: one product — AI rewrite title, short description, or long description (Store products bulk). */ public static function ajax_store_bulk_copy_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing $field = isset( $_POST['copy_field'] ) ? sanitize_key( wp_unslash( (string) $_POST['copy_field'] ) ) : ''; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $allowed = array( 'title', 'short_description', 'description' ); if ( ! in_array( $field, $allowed, true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid field.', 'wave-woo-bridge' ) ) ); } $title_max_words = null; if ( 'title' === $field ) { $title_max_words = Settings::get_import_title_max_words(); } $res = Product_Importer::store_bulk_apply_copy_improvement( $pid, $field, $title_max_words ); $out = array( 'product_id' => $pid, 'title' => Store_Products::decode_product_title_for_display( get_the_title( $pid ) ), 'edit_url' => (string) get_edit_post_link( $pid, 'raw' ), ); if ( is_wp_error( $res ) ) { $out['status'] = 'error'; $out['count'] = 0; $out['message'] = $res->get_error_message(); wp_send_json_success( $out ); return; } $out['status'] = 'ok'; $out['count'] = 1; $out['message'] = ''; wp_send_json_success( $out ); } /** * AJAX: one product — full enrichment (Store products bulk). */ public static function ajax_store_bulk_enrich_step() { check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'wave-woo-bridge' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $pid = isset( $_POST['product_id'] ) ? (int) $_POST['product_id'] : 0; if ( $pid <= 0 || ! current_user_can( 'edit_post', $pid ) ) { wp_send_json_error( array( 'message' => __( 'Invalid product.', 'wave-woo-bridge' ) ) ); } $res = Product_Importer::store_bulk_apply_full_enrich( $pid ); $out = array( 'product_id' => $pid, 'title' => Store_Products::decode_product_title_for_display( get_the_title( $pid ) ), 'edit_url' => (string) get_edit_post_link( $pid, 'raw' ), ); if ( is_wp_error( $res ) ) { $out['status'] = 'error'; $out['count'] = 0; $out['message'] = $res->get_error_message(); wp_send_json_success( $out ); return; } $out['status'] = isset( $res['status'] ) ? (string) $res['status'] : 'ok'; $out['count'] = isset( $res['count'] ) ? (int) $res['count'] : 1; $out['message'] = isset( $res['message'] ) ? (string) $res['message'] : ''; $out['images_added'] = isset( $res['images_added'] ) ? (int) $res['images_added'] : 0; $out['images_skipped'] = ! empty( $res['images_skipped'] ); if ( isset( $res['title'] ) && is_string( $res['title'] ) && '' !== trim( $res['title'] ) ) { $out['title'] = Store_Products::decode_product_title_for_display( trim( $res['title'] ) ); } wp_send_json_success( $out ); } /** * Offload worker callback endpoint (signed request, fail-open). Workers can report async job status. * * @return void */ public static function ajax_offload_callback() { $body = file_get_contents( 'php://input' ); $headers = CataSync_Offload::callback_headers_from_request(); $data = json_decode( (string) $body, true ); if ( ! is_array( $data ) ) { wp_send_json_error( array( 'message' => 'Invalid JSON body.' ), 400 ); } // Get worker domain from payload $domain = isset($data['domain']) ? trim((string)$data['domain']) : ''; $worker_secret = ''; if ($domain !== '') { $settings = Settings::get(); $domains = isset($settings['worker_domains']) && is_array($settings['worker_domains']) ? $settings['worker_domains'] : array(); $secrets = isset($settings['worker_secrets']) && is_array($settings['worker_secrets']) ? $settings['worker_secrets'] : array(); foreach ($domains as $i => $d) { if (trim($d) === $domain && isset($secrets[$i])) { $worker_secret = (string)$secrets[$i]; break; } } } // Fallback to global webhook_secret for legacy if ($worker_secret === '') { $cfg = Settings::get_offload_config(); $worker_secret = isset($cfg['webhook_secret']) ? (string)$cfg['webhook_secret'] : ''; } // Use the found secret for signature verification if (!CataSync_Offload::verify_callback_signature((string)$body, (string)$headers['ts'], (string)$headers['sig'], $worker_secret)) { wp_send_json_error(array('message' => 'Invalid offload signature.'), 401); } $feature = sanitize_key((string)($data['feature_key'] ?? '')); $status = sanitize_key((string)($data['status'] ?? '')); $sid = isset($data['staging_id']) ? (int)$data['staging_id'] : 0; $pid = isset($data['product_id']) ? (int)$data['product_id'] : 0; Database::log( 'info', 'Offload callback accepted.', array( 'feature' => $feature, 'status' => $status, 'staging_id' => $sid, 'product_id' => $pid, 'domain' => $domain, ) ); if ('waveimportexecute' === $feature && $sid > 0 && in_array($status, array('done', 'ok'), true)) { Scheduler::mark_import_done_from_callback($sid, $pid); } if ('waveimportexecute' === $feature && $sid > 0 && in_array($status, array('failed', 'error'), true)) { $err = isset($data['error']) ? sanitize_text_field((string)$data['error']) : 'Worker reported failure.'; Scheduler::mark_import_failed_from_callback($sid, $err); } wp_send_json_success(array('ok' => true)); } }